From d106c124c43fd3fb6100b3f1f78ccf2fa0ba40fd Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:49:22 +0700 Subject: [PATCH] Glue booth keeps its model: create re-dresses, a hidden scene pauses (#1097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glue booth threw its model away where 1.12.1 keeps it. So the create preview restarted its animation on every customization or class change, and the login scene restarted after Credits. In 1.12.1, the create setters (0x478790 and its siblings) and the re-dress 0x470800 update the same CharacterModel. Only a race or sex change builds a new one (0x470b70). A hidden ModelFFX is unlinked from its strata bucket, so its OnUpdate (0x76d7f0) and its clock stop until it shows again, and the clock steps in whole milliseconds (0x76d846). Now the booth re-dresses its body in place and keeps a hidden or left scene parked, resuming it on return. Each scene has its own clock (one SceneClock on its root), its own materials, so two scenes on one file stay independent. Tests cover the keep, the park, the clocks and the cleanup. Fixes #1083 Fixes #1068 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/entities.rs | 25 + crates/benilla-app/src/game_plugins.rs | 2 +- crates/benilla-app/src/portrait/booth.rs | 477 +++-- crates/benilla-app/src/portrait/dressup.rs | 2 + crates/benilla-app/src/portrait/glue_booth.rs | 1559 +++++++++++++++-- crates/benilla-app/src/portrait/mod.rs | 13 +- crates/benilla-app/src/ui_models/mod.rs | 13 +- .../benilla-app/src/ui_script/glue_scene.rs | 198 ++- crates/benilla-app/src/ui_script/session.rs | 3 + crates/benilla-app/tests/world_api_wall.rs | 4 + crates/benilla-assets/src/spatial_cache.rs | 19 + crates/benilla-ui/src/script/glue_select.rs | 73 +- crates/benilla-ui/src/script/tick.rs | 2 +- crates/benilla-ui/src/widget/kinds/mod.rs | 8 + crates/benilla-ui/src/widget/mod.rs | 10 +- .../benilla-world/src/doodad_anim/mat_anim.rs | 380 +++- crates/benilla-world/src/doodad_anim/mod.rs | 13 +- crates/benilla-world/src/model_render.rs | 5 + .../benilla-world/src/model_render/batch.rs | 54 +- crates/benilla-world/src/rig_anim.rs | 10 + .../benilla-world/src/rig_anim/global_seq.rs | 66 +- 21 files changed, 2606 insertions(+), 330 deletions(-) diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 7596c7995..0b79ad791 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -252,6 +252,31 @@ impl Creatures { } } + /// A cache of built, batch-less displays, each with a rig: `(display id, skeleton, + /// animations)`, for a booth test that bakes off [`Self::display_rig`]. + #[cfg(test)] + pub(crate) fn rigged_for_test( + rigs: Vec<( + u32, + benilla_assets::ModelSkeleton, + Option, + )>, + ) -> Self { + let mut c = Creatures { + catalog: Default::default(), + models: HashMap::new(), + }; + for (display_id, skeleton, animations) in rigs { + let mut dm = display::empty_shell(); + dm.parts = Some(Vec::new()); + dm.skeleton = skeleton; + dm.inverse_bindposes = Some(Handle::default()); + dm.animations = animations; + c.models.insert(display_id, dm); + } + c + } + /// A display's foley material (`Material.dbc` id), the creature half of the footfall rustle. pub(crate) fn foley_material(&self, display_id: u32) -> Option { self.catalog.foley_material(display_id) diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index ce83717a1..c9fa45d9c 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -821,7 +821,7 @@ pub(crate) mod schedule_tests { /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 4_692; + const UPDATE_ACTIONABLE_CEILING: usize = 4_642; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index 02874629e..525683edc 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -24,15 +24,16 @@ pub(super) struct BoothPart { pub(super) twins: BoothTwins, /// Whether the builder registered this batch's material on the UV and tint lane. It records the /// registration, never re-tests the asset: the marker and the registration must be one fact. - /// True only for the glue scenes, which animate texture transforms per frame (`0x715f25` to - /// `0x7163bc` over `md+0x74`); portraits and panes are stills. + /// True only on the glue stage (its scene, character and pet), which animates texture + /// transforms per frame (`0x715f25` to `0x7163bc` over `md+0x74`) on the stage's own clock; + /// portraits and panes are stills. pub(super) mat_anim: bool, } /// Marks a booth child for the draw gate when its material is on the UV and tint lane; an /// unmarked registered batch stays frozen at its seed. -fn mark_mat_anim(child: &mut bevy::ecs::system::EntityCommands<'_>, p: &BoothPart) { - if p.mat_anim { +fn mark_mat_anim(child: &mut bevy::ecs::system::EntityCommands<'_>, mat_anim: bool) { + if mat_anim { child.insert(benilla_world::doodad_anim::AnimMatPart); } } @@ -115,6 +116,8 @@ pub(super) struct BoothRider { /// An attached model composes onto its parent's alpha (`0x714000`), so a ghost's helm /// feathers with the head under it. pub(super) twins: BoothTwins, + /// As [`BoothPart::mat_anim`]: its material has UV or tint rows of its own. + pub(super) mat_anim: bool, } /// One camera-facing batch headed into a booth bake: the undead or night-elf eye glow, or an @@ -130,6 +133,8 @@ pub(super) struct BoothBillboardSpec { pub(super) kind: BillboardKind, /// A card takes the instance alpha like every other batch of the model. pub(super) twins: BoothTwins, + /// As [`BoothPart::mat_anim`]. + pub(super) mat_anim: bool, } /// A spawned booth billboard card, faced to its own booth's camera every frame by @@ -386,6 +391,11 @@ pub(super) enum BoothMotion { Loop, } +/// Marks one of a bake's own batches, a direct child of the booth root, so a re-dress +/// ([`redress_booth_batches`]) tells them from the rig's anchors. +#[derive(Component)] +pub(super) struct BoothBatch; + /// Spawns a booth bake under `root`: a fresh throwaway instance posed at Stand (the reference's /// instance `0x707400`, pose `0x7121a0`), never the unit's live pose. /// @@ -419,37 +429,9 @@ pub(super) fn spawn_booth_model( // A re-bake must not inherit the previous model's animation state, pose buffer or palette slot. clear_booth_rig(commands, root); let Some((skeleton, ibp, anims)) = rig.filter(|(s, _, _)| !s.joints.is_empty()) else { + // No palette slot: every part draws its static twin at bind pose. for p in parts { - let mut child = commands.spawn(( - Mesh3d(p.static_mesh.clone()), - MeshMaterial3d(instance.material(&p.material, &p.twins)), - Transform::IDENTITY, - layer.clone(), - ChildOf(root), - )); - mark_mat_anim(&mut child, p); - // The batch's material alpha times the instance alpha, the reference's - // `model+0x180 ร— the batch factor` (`0x707680`). - if let Some(anim) = &p.alpha_anim { - let mat_anim = - benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None); - child.insert(( - bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( - 0, - mat_anim.current * instance.alpha, - )), - mat_anim, - BoothMatAlpha, - )); - } else if instance.feathering() { - child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( - 0, - instance.alpha, - ))); - } - if let Some(fm) = instance.fade_materials(&p.material, &p.twins) { - child.insert(fm); - } + spawn_batch(commands, root, &layer, p, 0, instance, None); } return BoothRig { root, @@ -508,107 +490,25 @@ pub(super) fn spawn_booth_model( } } } + // A live booth's batch alphas follow the sequence its root plays, on its clock; a still's + // stay pinned. + let host = (motion == BoothMotion::Loop && anims.is_some()).then_some(root); for p in parts { - let use_rig = p.skinned.is_some() && rig_slot != 0; - let mesh = if use_rig { - p.skinned.clone().expect("use_rig โ‡’ skinned twin present") - } else { - p.static_mesh.clone() - }; - let mut child = commands.spawn(( - Mesh3d(mesh), - MeshMaterial3d(instance.material(&p.material, &p.twins)), - Transform::IDENTITY, - layer.clone(), - ChildOf(root), - )); - mark_mat_anim(&mut child, p); - // The material alpha drives the tag's alpha field itself (nothing else writes a booth - // part's alpha) beside the rig slot. `alpha_bits` floors a true zero just above 0, so the - // shader's all-zero "untagged, opaque" sentinel cannot fire on an invisible batch. - let tag_slot = if use_rig { rig_slot } else { 0 }; - if let Some(anim) = &p.alpha_anim { - let mat_anim = - benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None); - child.insert(( - bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( - tag_slot, - mat_anim.current * instance.alpha, - )), - mat_anim, - BoothMatAlpha, - )); - } else if use_rig || instance.feathering() { - child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( - tag_slot, - instance.alpha, - ))); - } - // While translucent, `benilla_world::zfill` keeps a colour-masked z-writing child on it, so - // overlapping limbs blend as one layer (the reference's `M2UseZFill`). - if let Some(fm) = instance.fade_materials(&p.material, &p.twins) { - child.insert(fm); - } - if use_rig { - child.insert(benilla_world::rig_palette::RigPart(root)); - // A skinned booth part is never frustum-culled: Bevy's bound is the bind-pose box, the - // camera frames the Stand pose, and on a model whose rest pose is far from Stand - // (`Creature\CarrionBird`, bind z to 1.19, Stand z 0.54 to 6.23) every batch culls. - child.insert(bevy::camera::visibility::NoFrustumCulling); - } + spawn_batch(commands, root, &layer, p, rig_slot, instance, host); } let mut booth_rig = BoothRig { root, rig: Some(pose), slot: rig_slot, }; - for r in riders { - let Some(anchor) = booth_rig.anchor(commands, r.bone) else { - continue; // bad bone index: bake the body without this rider - }; - let mut child = commands.spawn(( - Mesh3d(r.mesh.clone()), - MeshMaterial3d(instance.material(&r.material, &r.twins)), - Transform::from_translation(r.offset), - layer.clone(), - ChildOf(anchor), - )); - // An attached model composes onto its parent's colour and alpha (`0x714000`). Untagged - // means opaque and slot 0, so the tag goes up only for a translucent instance or a slot. - if instance.feathering() || rig_slot != 0 { - child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( - rig_slot, - instance.alpha, - ))); - } - if let Some(fm) = instance.fade_materials(&r.material, &r.twins) { - child.insert(fm); - } - } - // Camera-facing batches seat under their bone's anchor for `face_booth_billboards` to face; - // a bone the rig lacks drops the card, like a rider. - for bb in billboards { - let Some(anchor) = booth_rig.anchor(commands, bb.bone) else { - continue; - }; - let mut card = commands.spawn(( - Mesh3d(bb.mesh.clone()), - MeshMaterial3d(instance.material(&bb.material, &bb.twins)), - Transform::from_translation(bb.offset), - layer.clone(), - ChildOf(anchor), - BoothBillboard { kind: bb.kind }, - )); - if instance.feathering() || rig_slot != 0 { - card.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( - rig_slot, - instance.alpha, - ))); - } - if let Some(fm) = instance.fade_materials(&bb.material, &bb.twins) { - card.insert(fm); - } - } + hang_attachments( + commands, + &mut booth_rig, + &layer, + riders, + billboards, + instance, + ); // The player is configured before insertion, so the pose lands with the first animation pass; // `ModelAnimations` goes beside it because the rig evaluator reads the player through it. if let Some(anims) = anims { @@ -623,15 +523,7 @@ pub(super) fn spawn_booth_model( player.play(clip.node).repeat(); } } - // A weapon hand plays its `HandsClosed` finger overlay over Stand, repeated because it - // is a one-key clamp pose; armed once, since a bake's grip never changes. - for (hand, want) in grip.into_iter().enumerate() { - if let (true, Some(node)) = (want, anims.hand_close[hand]) { - let active = player.play(node); - active.repeat(); - active.set_weight(crate::creature_anim::HAND_GRIP_WEIGHT); - } - } + set_grip(&mut player, anims.hand_close, grip); commands.entity(root).insert(( player, AnimationGraphHandle(anims.graph.clone()), @@ -642,6 +534,255 @@ pub(super) fn spawn_booth_model( booth_rig } +/// The mesh a batch draws against palette slot `rig_slot`: its skinned twin when it skins there, +/// else its static one (slot 0 is no rig). +fn batch_mesh(p: &BoothPart, rig_slot: u16) -> &Handle { + match &p.skinned { + Some(skinned) if rig_slot != 0 => skinned, + _ => &p.static_mesh, + } +} + +/// Spawns one batch under `root`, skinned against `rig_slot` when it has a skinned twin. +fn spawn_batch( + commands: &mut Commands, + root: Entity, + layer: &RenderLayers, + p: &BoothPart, + rig_slot: u16, + instance: BoothInstance, + host: Option, +) -> Entity { + let use_rig = p.skinned.is_some() && rig_slot != 0; + let mut child = commands.spawn(( + Mesh3d(batch_mesh(p, rig_slot).clone()), + Transform::IDENTITY, + layer.clone(), + ChildOf(root), + BoothBatch, + )); + dress_batch(&mut child, p, rig_slot, instance, host); + if use_rig { + child.insert(benilla_world::rig_palette::RigPart(root)); + // A skinned booth part is never frustum-culled: Bevy's bound is the bind-pose box, the + // camera frames the Stand pose, and on a model whose rest pose is far from Stand + // (`Creature\CarrionBird`, bind z to 1.19, Stand z 0.54 to 6.23) every batch culls. + child.insert(bevy::camera::visibility::NoFrustumCulling); + } + child.id() +} + +/// A batch's dressing: its material, its tag (the palette slot beside the instance alpha, or the +/// material alpha that drives it) and its depth-prime record. `host` is the root whose player the +/// material alpha follows, its sequence and its scene's clock; `None` pins it. +fn dress_batch( + child: &mut bevy::ecs::system::EntityCommands<'_>, + p: &BoothPart, + rig_slot: u16, + instance: BoothInstance, + host: Option, +) { + child.insert(MeshMaterial3d(instance.material(&p.material, &p.twins))); + mark_mat_anim(child, p.mat_anim); + // The material alpha drives the tag's alpha field itself (nothing else writes a booth part's + // alpha) beside the rig slot. `alpha_bits` floors a true zero just above 0, so the shader's + // all-zero "untagged, opaque" sentinel cannot fire on an invisible batch. + let use_rig = p.skinned.is_some() && rig_slot != 0; + let tag_slot = if use_rig { rig_slot } else { 0 }; + if let Some(anim) = &p.alpha_anim { + // The batch's material alpha times the instance alpha, the reference's + // `model+0x180 ร— the batch factor` (`0x707680`). + let mat_anim = benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None) + .following_host(host); + child.insert(( + bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( + tag_slot, + mat_anim.current * instance.alpha, + )), + mat_anim, + BoothMatAlpha, + )); + } else if use_rig || instance.feathering() { + child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( + tag_slot, + instance.alpha, + ))); + } + // While translucent, `benilla_world::zfill` keeps a colour-masked z-writing child on it, so + // overlapping limbs blend as one layer (the reference's `M2UseZFill`). + if let Some(fm) = instance.fade_materials(&p.material, &p.twins) { + child.insert(fm); + } +} + +/// Seats the riders on their bones' anchors and the camera-facing cards under theirs; a bone the +/// rig lacks drops its rider or card. +fn hang_attachments( + commands: &mut Commands, + booth_rig: &mut BoothRig, + layer: &RenderLayers, + riders: &[BoothRider], + billboards: &[BoothBillboardSpec], + instance: BoothInstance, +) { + let rig_slot = booth_rig.slot; + for r in riders { + let Some(anchor) = booth_rig.anchor(commands, r.bone) else { + continue; // bad bone index: bake the body without this rider + }; + let mut child = commands.spawn(( + Mesh3d(r.mesh.clone()), + MeshMaterial3d(instance.material(&r.material, &r.twins)), + Transform::from_translation(r.offset), + layer.clone(), + ChildOf(anchor), + )); + mark_mat_anim(&mut child, r.mat_anim); + // An attached model composes onto its parent's colour and alpha (`0x714000`). Untagged + // means opaque and slot 0, so the tag goes up only for a translucent instance or a slot. + if instance.feathering() || rig_slot != 0 { + child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( + rig_slot, + instance.alpha, + ))); + } + if let Some(fm) = instance.fade_materials(&r.material, &r.twins) { + child.insert(fm); + } + } + // Camera-facing batches seat under their bone's anchor for `face_booth_billboards` to face. + for bb in billboards { + let Some(anchor) = booth_rig.anchor(commands, bb.bone) else { + continue; + }; + let mut card = commands.spawn(( + Mesh3d(bb.mesh.clone()), + MeshMaterial3d(instance.material(&bb.material, &bb.twins)), + Transform::from_translation(bb.offset), + layer.clone(), + ChildOf(anchor), + BoothBillboard { kind: bb.kind }, + )); + mark_mat_anim(&mut card, bb.mat_anim); + if instance.feathering() || rig_slot != 0 { + card.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag( + rig_slot, + instance.alpha, + ))); + } + if let Some(fm) = instance.fade_materials(&bb.material, &bb.twins) { + card.insert(fm); + } + } +} + +/// Re-dresses the instance standing on `root` without replacing it, as the reference's setters +/// act on one `CharacterModel`: the skin setter `0x478790` rebinds its textures and raises the +/// dirty byte `[cm+8]`, and the class re-dress `0x470800` re-equips the same model. The root keeps +/// its player, global sequences and palette slot `rig_slot`, so its clock runs on. A batch whose +/// mesh is still worn keeps its entity and takes its new dressing; the others spawn or go, as a +/// geoset toggles. `standing` is the root's [`BoothBatch`] children by the mesh each draws. +/// +/// Opaque instances only: a feathering batch's depth-prime twin belongs to the zfill lane, which +/// does not reap it when the record goes. +pub(super) fn redress_booth_batches( + commands: &mut Commands, + root: Entity, + layer: &RenderLayers, + parts: &[BoothPart], + standing: &[(Entity, AssetId)], + rig_slot: u16, + instance: BoothInstance, +) { + debug_assert!( + !instance.feathering(), + "a re-dress keeps opaque batches only" + ); + let mut free: Vec)>> = + standing.iter().copied().map(Some).collect(); + for p in parts { + let want = batch_mesh(p, rig_slot).id(); + let kept = free + .iter_mut() + .find(|s| s.is_some_and(|(_, mesh)| mesh == want)) + .and_then(Option::take); + match kept { + Some((batch, _)) => { + let mut child = commands.entity(batch); + // Strip the old dressing; `Visibility` too, which the material-alpha writer owns. + child + .remove::<( + bevy::mesh::MeshTag, + benilla_world::doodad_anim::MatAnim, + BoothMatAlpha, + benilla_world::doodad_anim::AnimMatPart, + )>() + .insert(Visibility::Inherited); + dress_batch(&mut child, p, rig_slot, instance, Some(root)); + } + None => { + spawn_batch(commands, root, layer, p, rig_slot, instance, Some(root)); + } + } + } + for (batch, _) in free.into_iter().flatten() { + commands.entity(batch).despawn(); + } +} + +impl BoothRig { + /// A fresh bind-pose buffer for the instance standing on `root`, on its palette slot `slot`: + /// a re-dress re-hangs its attachments on new anchors ([`Self::hang`]), and the pose + /// evaluator re-poses it from the root's player before it draws. + pub(super) fn rehang( + root: Entity, + skeleton: &benilla_assets::ModelSkeleton, + slot: u16, + ) -> Self { + Self { + root, + rig: Some( + benilla_world::rig_anim::RigPose::new(root, skeleton).without_camera_billboards(), + ), + slot, + } + } + + /// Seats `riders` and `billboards` on this rig ([`hang_attachments`]). + pub(super) fn hang( + &mut self, + commands: &mut Commands, + layer: &RenderLayers, + riders: &[BoothRider], + billboards: &[BoothBillboardSpec], + instance: BoothInstance, + ) { + hang_attachments(commands, self, layer, riders, billboards, instance); + } +} + +/// Closes each hand whose attach point holds a weapon (`CloseHand` `0x479660`) and opens the +/// other, as an unequip's detach does (`0x47a380`): the `HandsClosed` finger overlay over Stand, +/// repeated because it is a one-key clamp pose. +pub(super) fn set_grip( + player: &mut AnimationPlayer, + hand_close: [Option; 2], + grip: [bool; 2], +) { + for (hand, want) in grip.into_iter().enumerate() { + let Some(node) = hand_close[hand] else { + continue; + }; + if !want { + player.stop(node); + } else if player.animation(node).is_none() { + let active = player.play(node); + active.repeat(); + active.set_weight(crate::creature_anim::HAND_GRIP_WEIGHT); + } + } +} + /// The ids `PlayerModel:SetRotation` chooses between, from the reference's name table /// (`0x6143b6`, `[ecx*4+0x8686a8]`): 0 `Stand`, 11 `ShuffleLeft`, 12 `ShuffleRight`. const STAND: u16 = 0; @@ -1218,6 +1359,68 @@ mod tests { ); } + /// A live booth's batch alpha follows its root's player, its sequence and its scene's clock, + /// so a glue scene's dimming loops stop with the scene; a still's stays pinned. + #[test] + fn a_live_booths_batch_alpha_follows_its_root() { + let skeleton = benilla_assets::ModelSkeleton { + joints: vec![benilla_assets::ModelJoint { + parent: -1, + local_translation: Vec3::ZERO, + billboard: None, + parent_arm: None, + }], + spine_bone: None, + head_bone: None, + }; + let anims = turning_model(); + for (motion, follows) in [(BoothMotion::Loop, true), (BoothMotion::Frozen, false)] { + let mut app = App::new(); + app.init_resource::(); + let parts = vec![BoothPart { + skinned: None, + static_mesh: Handle::default(), + material: Handle::default(), + alpha_anim: Some(constant_alpha(0.55)), + twins: BoothTwins::default(), + mat_anim: false, + }]; + let root = app.world_mut().spawn(Transform::IDENTITY).id(); + let mut palettes = app + .world_mut() + .remove_resource::() + .expect("just inserted"); + let mut queue = bevy::ecs::world::CommandQueue::default(); + { + let mut commands = Commands::new(&mut queue, app.world()); + spawn_booth_model( + &mut commands, + &mut palettes, + root, + RenderLayers::layer(9), + &parts, + &[], + Some((&skeleton, &Handle::default(), Some(&anims))), + None, + motion, + [false, false], + &[], + BoothInstance::default(), + ) + .finish(&mut commands); + } + queue.apply(app.world_mut()); + let mut q = app.world_mut().query::<&MatAnim>(); + let hosts: Vec<_> = q.iter(app.world()).map(MatAnim::host).collect(); + assert_eq!( + hosts, + vec![follows.then_some(root)], + "{} booth", + if follows { "a live" } else { "a still" } + ); + } + } + /// World parts carry `MatAnim` too; their alpha belongs to the world's visibility pass. #[test] fn the_booth_alpha_writer_ignores_unmarked_parts() { diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index 773c66d07..f3fbb539b 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -229,6 +229,7 @@ pub(super) fn sync_dressup_booth( bone: r.bone, offset: r.offset, twins: BoothTwins::default(), + mat_anim: false, }) .collect(); let booth_billboards: Vec = bake @@ -241,6 +242,7 @@ pub(super) fn sync_dressup_booth( offset: b.offset, kind: b.kind, twins: BoothTwins::default(), + mat_anim: false, }) .collect(); // Never latch a world-lane material into a pane ([`super::light`]): retry instead. diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 70245518a..f2b9a9f62 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -15,6 +15,7 @@ use crate::entities::Creatures; use benilla_assets::m2_url; use benilla_assets::materials::WowModelMaterial; +use super::booth::{redress_booth_batches, set_grip, BoothBatch, BoothRig}; use super::framing::{ attachment_point, diag_to_vert, glue_box_aspect, glue_box_physical, glue_scene_framing, stock_pane_view, ArtExtent, GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT, @@ -228,11 +229,20 @@ pub(crate) struct GluePreview { /// from the top-left ([`super::framing::stock_pane_view`]); `None` frames the native way. pub(crate) pane: Option<[f32; 4]>, /// The stage's instance: a change starts the scene over, as each `SetModel` makes a new - /// instance (`0x76cce0`), the same file included. + /// instance (`0x76cce0`), the same file included. The stock feeder numbers each (pane, + /// instance) once, so a pane shown again on the instance it held brings its number back. pub(crate) stage: u64, + /// The stages whose pane still holds them, shown or hidden: a scene left for another pane's + /// is parked while its number is here, and resumes where it stopped when the number returns + /// ([`CreateScene::parked`]). Empty from a feeder that numbers no panes. + pub(crate) live: Vec, /// Bumped as a glue screen's scene shows: the `ModelFFX`'s OnShow pins the glow back to /// 0.30 (`0x46fa60`), which nothing else does ([`sync_glue_ffx`]). pub(crate) shown: u64, + /// The scene's pane is hidden: the scene stays up, undrawn, and its clock stops, as a hidden + /// `ModelFFX` keeps its model and scene and advances its clock only in its `OnUpdate` + /// (`0x76d7f0`), which the visible-frame pump (`0x765650`) calls ([`pace_glue_clock`]). + pub(crate) hidden: bool, } impl Default for GluePreview { @@ -245,6 +255,8 @@ impl Default for GluePreview { pane: None, stage: 0, shown: 0, + hidden: false, + live: Vec::new(), } } } @@ -311,6 +323,38 @@ pub(crate) struct CreateScene { rev: u64, /// The requested stage not yet committed ([`PendingSwap`]). pending: Option, + /// The stage's scene clock in milliseconds, the reference's per-`ModelFFX` scene clock + /// (`CSimpleModel+0x314`): it steps in whole milliseconds, only while the pane shows + /// ([`pace_glue_clock`]). + clock_ms: u64, + /// The scenes left for another pane's while their own pane still holds them + /// ([`GluePreview::live`]), each to resume where it stopped when its stage returns. + parked: Vec, + /// Scene roots gone since [`sync_glue_scene`] last looked, whose own materials it forgets + /// (`M2BatchMaterials::forget_scene`). + dropped: Vec, +} + +/// A scene left standing for another pane's, its pane still holding it: a hidden `ModelFFX` keeps +/// its model and scene, and only its clock stops (`0x76d7f0`). The login screen's `UI_MainMenu` is +/// the case: `AccountLogin.xml`'s `file=` is its only `SetModel` (the attribute handler +/// `0x76cce0`), so its scene outlives every visit to the other screens. Its root is hidden, its +/// pose held, its players at speed 0 and its emitters frozen; its clock and its materials' clock +/// keep the values they stopped at. +struct ParkedScene { + root: Entity, + token: &'static str, + handle: Handle, + cam: Option, + art: Option, + char_spot: Vec3, + char_facing: Quat, + char_scale: f32, + pet_spot: Vec3, + pet_facing: Quat, + pet_stage_scale: f32, + stage: u64, + clock_ms: u64, } impl CreateScene { @@ -339,8 +383,162 @@ impl CreateScene { variants: default(), rev: 0, pending: None, + clock_ms: 0, + parked: Vec::new(), + dropped: Vec::new(), } } + + /// Step off the standing scene for stage `next`: park it while its pane still holds it + /// (`live`), else strip its root; then stand `next` back up if it is parked, returning whether + /// it was, so the caller lights it instead of spawning. `children` lists an entity's children, + /// for the emitters to freeze and thaw. Token and handle stay for the caller to set on a + /// spawn; a resumed scene brings its own. + fn leave_for( + &mut self, + commands: &mut Commands, + layer: &RenderLayers, + live: &[u64], + next: u64, + children: &dyn Fn(Entity) -> Vec, + ) -> bool { + let returning = self + .parked + .iter() + .position(|p| p.stage == next) + .map(|i| self.parked.remove(i)); + let parks = self.spawned && self.stage != next && live.contains(&self.stage); + match (parks, self.token, self.handle.clone()) { + (true, Some(token), Some(handle)) => { + hold_scene(commands, self.root, true, children); + self.parked.push(ParkedScene { + root: self.root, + token, + handle, + cam: self.cam, + art: self.art, + char_spot: self.char_spot, + char_facing: self.char_facing, + char_scale: self.char_scale, + pet_spot: self.pet_spot, + pet_facing: self.pet_facing, + pet_stage_scale: self.pet_stage_scale, + stage: self.stage, + clock_ms: self.clock_ms, + }); + } + _ => { + // Gone for good, its root and its own materials with it. + commands.entity(self.root).despawn(); + self.dropped.push(self.root); + } + } + // A fresh root for the incoming spawn, whose materials are its own; a resumed scene + // brings its root back. + if returning.is_none() { + self.root = commands.spawn(stage_root(layer)).id(); + } + self.spawned = false; + self.cam = None; + self.art = None; + // `viewport_aspect` stays: the frame is the window's, not the stage's. + self.char_spot = Vec3::ZERO; + let Some(p) = returning else { + // A new instance's clock starts from 0, as the widget scene's constructor zeroes it + // (`0x706ff0`): its sequence bands then sample `clock โˆ’ lo` (`0x714530`) from an arm + // at 0, and its global sequences `clock โˆ’ attach`. + self.clock_ms = 0; + return false; + }; + hold_scene(commands, p.root, false, children); + self.root = p.root; + self.token = Some(p.token); + self.handle = Some(p.handle); + self.cam = p.cam; + self.art = p.art; + self.char_spot = p.char_spot; + self.char_facing = p.char_facing; + self.char_scale = p.char_scale; + self.pet_spot = p.pet_spot; + self.pet_facing = p.pet_facing; + self.pet_stage_scale = p.pet_stage_scale; + self.stage = p.stage; + self.clock_ms = p.clock_ms; + self.spawned = true; + true + } + + /// Off the glue screens: drop every scene, parked or standing, and the relit twins of the + /// models that stood (`variants`), so nothing holds their materials and the tick frees their + /// rows. Returns whether a scene stood, for the caller to square its target back. + fn stand_down(&mut self, commands: &mut Commands) -> bool { + self.evict(commands, &[]); + self.variants.clear(); + if self.token.is_none() { + return false; + } + commands.entity(self.root).despawn_related::(); + self.dropped.push(self.root); + // Strip the rig state (pose buffer, player, palette slot): the root outlives its bakes. + super::clear_booth_rig(commands, self.root); + self.token = None; + self.handle = None; + self.spawned = false; + self.cam = None; + self.art = None; + self.viewport_aspect = None; + self.char_spot = Vec3::ZERO; + self.char_facing = Quat::IDENTITY; + self.char_scale = 1.0; + clear_pet(commands, self); + true + } + + /// Drop every parked scene whose pane let it go: a new `SetModel` there (`0x76cce0`), or the + /// glue screens gone. + fn evict(&mut self, commands: &mut Commands, live: &[u64]) { + let dropped = &mut self.dropped; + self.parked.retain(|p| { + let keep = live.contains(&p.stage); + if !keep { + commands.entity(p.root).despawn(); + dropped.push(p.root); + } + keep + }); + } +} + +/// Hold a scene's root still and out of sight, or release it: hidden, its pose evaluation parked, +/// its players at speed 0 ([`pace_glue_clock`] sets the standing scene's each frame) and its +/// emitters, the root's children, frozen. +fn hold_scene( + commands: &mut Commands, + root: Entity, + held: bool, + children: &dyn Fn(Entity) -> Vec, +) { + let mut model = commands.entity(root); + if held { + model + .insert((Visibility::Hidden, benilla_world::rig_anim::AnimParked)) + .entry::() + .and_modify(|mut player| { + for (_, active) in player.playing_animations_mut() { + active.set_speed(0.0); + } + }); + } else { + model + .insert(Visibility::Visible) + .remove::(); + } + for child in children(root) { + commands + .entity(child) + .entry::() + .and_modify(move |mut emitter| emitter.set_frozen(held)); + } } /// A requested stage swap, held until both halves are ready. The scene M2 and the character's @@ -567,9 +765,7 @@ pub(super) fn spawn_glue_booth( .0 .insert(GLUE_SLOT.to_string(), PortraitSource::Live(image.clone())); let layer = RenderLayers::layer(GLUE_LAYER); - let root = commands - .spawn((Transform::IDENTITY, Visibility::Visible, layer.clone())) - .id(); + let root = commands.spawn(stage_root(&layer)).id(); commands.spawn(( // The shared booth view shape; [`super`] says why every booth camera spawns through it. super::booth_view_shape(), @@ -625,16 +821,24 @@ pub(super) fn spawn_glue_booth( }, ); // The background scene's own root; the character root yaws, this one never does. - let scene_root = commands - .spawn((Transform::IDENTITY, Visibility::Visible, layer.clone())) - .id(); + let scene_root = commands.spawn(stage_root(&layer)).id(); // The pet's root never yaws: the reference's facing call turns only the character (`0x4730e0`). - let pet_root = commands - .spawn((Transform::IDENTITY, Visibility::Visible, layer)) - .id(); + let pet_root = commands.spawn(stage_root(&layer)).id(); commands.insert_resource(CreateScene::empty(scene_root, pet_root)); } +/// A model root on the glue stage, its scene clock at 0 from birth (the widget scene's constructor +/// zeroes it, `0x706ff0`), so no reader latches an anchor off the world's clock before +/// [`pace_glue_clock`] first writes it. +fn stage_root(layer: &RenderLayers) -> impl Bundle { + ( + Transform::IDENTITY, + Visibility::Visible, + layer.clone(), + benilla_world::rig_anim::SceneClock(0.0), + ) +} + /// Drive the glue FFX state ([`benilla_world::ffx_glow::GlueFfx`]) off the live look: the pass /// installed and the pinned `LightParams.glow`. /// @@ -712,8 +916,13 @@ pub(super) fn sync_glue_scene( ResMut, ResMut, ), - // The swap gate's inputs ([`PendingSwap`]). - swap: (Res, Res>), + // The swap gate's inputs ([`PendingSwap`]), and the roots' children, where a parked scene's + // emitters are ([`CreateScene::leave_for`]). + swap: ( + Res, + Res>, + Query<&Children>, + ), ) { let ( mut palettes, @@ -726,28 +935,19 @@ pub(super) fn sync_glue_scene( mut anim_table, mut anim_mirrors, ) = particle_assets; - let (bake, real) = swap; + let (bake, real, kids) = swap; let Some(booth) = booths.0.get(GLUE_SLOT) else { return; }; + let children = |e: Entity| kids.get(e).map(|c| c.to_vec()).unwrap_or_default(); let Some(which) = preview.scene else { // Off the glue screens: tear the scene down, free the model, square target back. - if scene.token.is_some() { - commands.entity(scene.root).despawn_related::(); - // Strip the rig state (pose buffer, player, palette slot): the root outlives its bakes. - super::clear_booth_rig(&mut commands, scene.root); - scene.token = None; - scene.handle = None; - scene.spawned = false; - scene.cam = None; - scene.art = None; - scene.viewport_aspect = None; - scene.char_spot = Vec3::ZERO; - scene.char_facing = Quat::IDENTITY; - scene.char_scale = 1.0; - clear_pet(&mut commands, &mut scene); + if scene.stand_down(&mut commands) { resize_target(&mut images, &booth.target, GLUE_SIZE, GLUE_SIZE); } + for root in scene.dropped.drain(..) { + mats.forget_scene(root); + } return; }; @@ -768,6 +968,7 @@ pub(super) fn sync_glue_scene( } } + scene.evict(&mut commands, &preview.live); let token = which.token(); let fog = preview.fog; // The ghost light fork (`0x472150`, [`ghost_rig`]), shared by the scene, character and pet. @@ -779,6 +980,8 @@ pub(super) fn sync_glue_scene( // The requested stage is already standing: no swap pends. scene.pending = None; } + // A parked scene that stands again ([`CreateScene::leave_for`]). + let mut resumed = false; if scene.token != Some(token) { // The pair swaps together or not at all ([`PendingSwap`]). let now = real.elapsed_secs(); @@ -808,20 +1011,31 @@ pub(super) fn sync_glue_scene( {art_ready}, body {body_ready}) โ€” the bound is GLUE_SWAP_HOLD_SECS" ); } - scene.token = Some(token); - scene.handle = Some(pending.handle); - scene.spawned = false; - scene.cam = None; - scene.art = None; - // `viewport_aspect` stays: the frame is the window's, not the stage's. - scene.char_spot = Vec3::ZERO; clear_pet(&mut commands, &mut scene); - commands.entity(scene.root).despawn_related::(); - // The model may still be loading: strip the old rig state off the root now. - super::clear_booth_rig(&mut commands, scene.root); - } else if scene.spawned && (scene.fog != fog || scene.stage != preview.stage) { - // Same scene, other screen or a new instance of it: rebuild in place (the handle is - // cached, so it respawns now). + resumed = scene.leave_for( + &mut commands, + &booth.layer, + &preview.live, + preview.stage, + &children, + ); + if !resumed { + scene.token = Some(token); + scene.handle = Some(pending.handle); + } + } else if scene.spawned && scene.stage != preview.stage { + // Same file, another instance: this pane's new one or another pane's (the handle is + // cached, so a spawn is now). + clear_pet(&mut commands, &mut scene); + resumed = scene.leave_for( + &mut commands, + &booth.layer, + &preview.live, + preview.stage, + &children, + ); + } else if scene.spawned && scene.fog != fog { + // Same scene, other screen: rebuild in place. scene.spawned = false; commands.entity(scene.root).despawn_related::(); } else if scene.spawned && scene.ghost != ghost { @@ -848,6 +1062,41 @@ pub(super) fn sync_glue_scene( ); } } + // The scenes gone this frame take their own materials with them. + for root in scene.dropped.drain(..) { + mats.forget_scene(root); + } + if resumed { + // The shared light buffer carries the standing scene's rig and fog: put this one's back. + if let (Some(model), Some(light)) = ( + scene.handle.as_ref().and_then(|h| m2s.get(h)), + scene.light.clone(), + ) { + let rig = if ghost { + ghost_rig() + } else { + scene_rig(&model.lights) + }; + scene_light_blob(&rig, fog).write(&queue, &light); + } + scene.fog = fog; + scene.ghost = ghost; + // The character and the pet re-seat and re-light on the stage that came back. + scene.pet_baked = None; + scene.rev += 1; + info!( + "glue scene: UI_{token} resumed at {} ms on its own clock", + scene.clock_ms + ); + apply_yaw( + &mut commands, + booth.root, + scene.char_spot, + scene.char_facing, + scene.char_scale, + preview.yaw, + ); + } if !scene.spawned { let Some(model) = scene.handle.as_ref().and_then(|h| m2s.get(h)) else { return; // still loading (the flat page shows in the meantime) @@ -901,6 +1150,7 @@ pub(super) fn sync_glue_scene( forms.ensure_now_rigged(scene_handle, &model.submeshes, &mut mesh_assets); let built = forms.slices(scene_handle); let (stat_forms, skin_forms) = (built.stat, built.skin.unwrap_or(&[])); + let scene_root = scene.root; let scene_parts: Vec = model .submeshes .iter() @@ -909,7 +1159,9 @@ pub(super) fn sync_glue_scene( // The authored batch order (index + 1) is all that separates batches sharing the // scene root's sort distance. let order = u16::try_from(pi + 1).unwrap_or(u16::MAX); - let material = mats.off_world(s, s.texture.clone(), order, &light, true); + // The scene's own material, never another scene's of the same file: its UV and + // tint rows run on this scene's clock and go with it. + let material = mats.off_world_for(s, s.texture.clone(), order, &light, scene_root); // Texture transforms and M2Color tints run live, as in the reference's // per-frame animate kernel (`0x715f25`-`0x7163bc`). Every glue model's loops are // shared, none per-placement, so `host: None` is exact. @@ -936,6 +1188,16 @@ pub(super) fn sync_glue_scene( ); mat_anim = true; } + // Both lanes run on the stage's own clock ([`pace_glue_clock`]), which stops + // while the pane is hidden, as every model in a `ModelFFX`'s scene does. + if mat_anim { + benilla_world::doodad_anim::scene_clocked( + &mut uv_reg, + &mut tint_reg, + material.id(), + scene_root, + ); + } BoothPart { skinned: skin_forms.get(pi).cloned(), static_mesh: stat_forms @@ -1153,8 +1415,94 @@ fn resize_target(images: &mut Assets, target: &Handle, w: u32, h: ); } +/// The material lanes a stage model's own rows go on ([`benilla_world::doodad_anim::clocked_twin`]). +type StageLanes<'w> = ( + ResMut<'w, benilla_world::doodad_anim::UvAnimMaterials>, + ResMut<'w, benilla_world::doodad_anim::TintAnimMaterials>, + ResMut<'w, benilla_world::mat_anim_table::MatAnimTable>, +); + +/// What [`sync_glue_booth`] keeps between frames. +#[derive(Default)] +pub(super) struct GlueStanding { + /// The bake revision, yaw and scene revision last committed. + last: Option<(u64, f32, u64)>, + /// The create character standing as one instance a re-dress keeps. `None` for a select + /// record, an empty stage or a boneless bake. + create: Option, +} + +/// The create body standing: one `CharacterModel` while its display and its stage last. +struct StandingCreate { + /// The race and sex body: a change is the builder's new model (`0x470b70`). + display: u32, + /// The stage it was built on ([`GluePreview::stage`]): every create `OnShow` builds anew, its + /// `SetCharCustomizeBackground` making a new scene (`0x4707b0` โ†’ `0x76cce0`, unconditional) and + /// `ResetCharCustomize` a new body (`0x4709f3`). + stage: u64, + /// Its attachments as hung. + hung: Hung, +} + +/// The attachments a bake hung, by what each draws and where: a re-dress re-hangs them only when +/// this changes, so a customization leaves the worn items' models and emitters running, as the +/// reference's setters touch only the body (`0x478790`), while a class's new outfit re-hangs them +/// (`0x470800`'s unequip `0x478960` and equip `0x479010`). +#[derive(PartialEq)] +struct Hung { + riders: Vec, + cards: Vec<(HungSeat, benilla_formats::BillboardKind)>, + /// Each effect model by its seat and emitter count; its geometry is among the riders. + effects: Vec<(u16, [u32; 3], usize)>, + /// The light buffer the effects bind ([`super::booth::spawn_booth_effects`]). + light: Option, +} + +/// One hung model: its mesh, its material, its bone and its offset's bits. +type HungSeat = (AssetId, AssetId, u16, [u32; 3]); + +impl Hung { + fn of( + riders: &[BoothRider], + cards: &[BoothBillboardSpec], + effects: &[BoothEffects], + light: Option<&bevy::render::render_resource::Buffer>, + ) -> Self { + let at = |v: Vec3| v.to_array().map(f32::to_bits); + Hung { + riders: riders + .iter() + .map(|r| (r.mesh.id(), r.material.id(), r.bone, at(r.offset))) + .collect(), + cards: cards + .iter() + .map(|c| ((c.mesh.id(), c.material.id(), c.bone, at(c.offset)), c.kind)) + .collect(), + effects: effects + .iter() + .map(|e| (e.bone, at(e.offset), e.emitters.len())) + .collect(), + light: light.map(|b| b.id()), + } + } +} + +/// The standing instance's rig on the booth root, which a re-dress keeps ([`sync_glue_booth`]): +/// its palette slot, its children, and whether its player stands. The player is reached through +/// commands, so this system shares no access with the world's animation systems. +type StandingRig<'a> = ( + &'a benilla_world::rig_palette::RigSkin, + Option<&'a Children>, + Has, +); + /// Re-bake the glue booth when the parts or the scene's rig change, relit onto the scene's rig /// (the reference's glue character is scene-lit) or the studio buffer, and spin it to the yaw. +/// +/// The create character is one model across its customizations and class re-dresses, which +/// update it in place (`0x478790` and kin, `0x470800`); only the race and sex builder `0x470b70` +/// makes a new one. So a create bake on the standing body display re-dresses the standing +/// instance ([`redress_booth_batches`]) and its clock runs on; anything else spawns afresh. pub(super) fn sync_glue_booth( mut commands: Commands, preview: Res, @@ -1169,7 +1517,10 @@ pub(super) fn sync_glue_booth( mut palettes: ResMut, // The ghost's whole-model tint, the reference's `cc+0x184..0x18c`. mut tints: ResMut, - mut last: Local>, + standing_rigs: Query, + batches: Query<&Mesh3d, With>, + mut state: Local, + mut lanes: StageLanes, ) { let Some(booth) = booths.0.get_mut(GLUE_SLOT) else { return; @@ -1184,7 +1535,7 @@ pub(super) fn sync_glue_booth( (Vec3::ZERO, Quat::IDENTITY, 1.0, s.rev) } }); - let (last_rev, last_yaw, last_scene) = last.unwrap_or((u64::MAX, f32::NAN, u64::MAX)); + let (last_rev, last_yaw, last_scene) = state.last.unwrap_or((u64::MAX, f32::NAN, u64::MAX)); // `sync_glue_scene` runs first and commits a stage only beside its character, so a standing // stage matching the request means this bake may show ([`PendingSwap`]). let staged = scene @@ -1204,7 +1555,8 @@ pub(super) fn sync_glue_booth( super::clear_booth_rig(&mut commands, booth.root); booth.rigged = false; booth.parked = false; - *last = Some((bake.revision, preview.yaw, scene_rev)); + state.last = Some((bake.revision, preview.yaw, scene_rev)); + state.create = None; apply_yaw( &mut commands, booth.root, @@ -1280,7 +1632,7 @@ pub(super) fn sync_glue_booth( mat_anim: false, }); } - let booth_riders: Vec = bake + let mut booth_riders: Vec = bake .riders .iter() .map(|r| BoothRider { @@ -1289,9 +1641,10 @@ pub(super) fn sync_glue_booth( bone: r.bone, offset: r.offset, twins: relight_twins(&r.twins, &mut scene, &mut booth_light, &mut materials), + mat_anim: false, }) .collect(); - let booth_billboards: Vec = bake + let mut booth_billboards: Vec = bake .billboards .iter() .map(|b| BoothBillboardSpec { @@ -1301,75 +1654,207 @@ pub(super) fn sync_glue_booth( offset: b.offset, kind: b.kind, twins: relight_twins(&b.twins, &mut scene, &mut booth_light, &mut materials), + mat_anim: false, }) .collect(); - // `WOW_BOOTH_LOG=1`: which display was baked, and whether the look or the scene rig - // triggered it. + // On the stage, each animated material runs on the stage's clock, following the body. + if scene_buf.is_some() { + let (uv, tint, table) = &mut lanes; + let mut on_clock = |source: &Handle, + twin: &Handle, + blend: ( + Option<&Handle>, + Option<&Handle>, + )| { + let steady = benilla_world::doodad_anim::clocked_twin( + uv, + tint, + table, + &mut materials, + source.id(), + twin.id(), + booth.root, + ); + let feathered = match blend { + (Some(source), Some(twin)) => benilla_world::doodad_anim::clocked_twin( + uv, + tint, + table, + &mut materials, + source.id(), + twin.id(), + booth.root, + ), + _ => false, + }; + steady || feathered + }; + for (p, bp) in bake.parts.iter().zip(booth_parts.iter_mut()) { + bp.mat_anim = on_clock( + &p.material, + &bp.material, + (p.twins.blend.as_ref(), bp.twins.blend.as_ref()), + ); + } + for (r, br) in bake.riders.iter().zip(booth_riders.iter_mut()) { + br.mat_anim = on_clock( + &r.material, + &br.material, + (r.twins.blend.as_ref(), br.twins.blend.as_ref()), + ); + } + for (b, bb) in bake.billboards.iter().zip(booth_billboards.iter_mut()) { + bb.mat_anim = on_clock( + &b.material, + &bb.material, + (b.twins.blend.as_ref(), bb.twins.blend.as_ref()), + ); + } + } + let booth_effects: Vec = bake + .effects + .iter() + .map(|fx| BoothEffects { + bone: fx.bone, + offset: fx.offset, + emitters: fx.emitters.clone(), + }) + .collect(); + let hung = Hung::of( + &booth_riders, + &booth_billboards, + &booth_effects, + scene_buf.as_ref(), + ); + // The standing create body, kept when this bake wears the same display on the same stage + // ([`StandingCreate`]). + let create = matches!(bake.look, Some(GlueLook::Create(_))); + let keep = create + && !instance.feathering() + && state + .create + .as_ref() + .is_some_and(|c| c.display == bake.display_id && c.stage == preview.stage); + let standing = if keep { + standing_rigs + .get(booth.root) + .ok() + .filter(|(_, _, player)| *player) + } else { + None + }; + // `WOW_BOOTH_LOG=1`: which display was baked, whether the look or the scene rig + // triggered it, and whether the standing instance was kept. if super::booth_log() { println!( - "[booth] glue rebake display={} parts={} riders={} bake_rev={} scene_rev={scene_rev} by={}", + "[booth] glue rebake display={} parts={} riders={} bake_rev={} scene_rev={scene_rev} by={} {}", bake.display_id, bake.parts.len(), bake.riders.len(), bake.revision, if last_rev != bake.revision { "look" } else { "scene" }, + if standing.is_some() { "redress" } else { "fresh" }, ); } - commands.entity(booth.root).despawn_related::(); - let mut booth_rig = spawn_booth_model( - &mut commands, - &mut palettes, - booth.root, - booth.layer.clone(), - &booth_parts, - &booth_riders, - rig.inverse_bindposes - .as_ref() - .map(|ibp| (rig.skeleton, ibp, rig.animations)), - anim_data.as_deref().map(|a| &a.0), - BoothMotion::Loop, // the glue preview is a live scene, not a still - bake.grip, // close each hand that holds a weapon - &booth_billboards, - instance, - ); + let slot = match standing { + Some((skin, children, _)) => { + let slot = skin.slot; + let kids: Vec = children.map(|c| c.to_vec()).unwrap_or_default(); + let worn: Vec<(Entity, AssetId)> = kids + .iter() + .filter_map(|&c| batches.get(c).ok().map(|m| (c, m.0.id()))) + .collect(); + redress_booth_batches( + &mut commands, + booth.root, + &booth.layer, + &booth_parts, + &worn, + slot, + instance, + ); + if state.create.as_ref().is_none_or(|c| c.hung != hung) { + // The outfit changed: the old items come off with their anchors, and the new + // ones hang on a fresh pose buffer the root's player re-poses before it draws. + for c in kids.iter().filter(|c| !worn.iter().any(|(w, _)| w == *c)) { + commands.entity(*c).despawn(); + } + let mut booth_rig = BoothRig::rehang(booth.root, rig.skeleton, slot); + booth_rig.hang( + &mut commands, + &booth.layer, + &booth_riders, + &booth_billboards, + instance, + ); + spawn_glue_effects( + &mut commands, + &mut booth_rig, + &booth.layer, + scene_buf.as_ref(), + &booth_effects, + instance, + ); + booth_rig.finish(&mut commands); + } + // A class's weapons close or open its hands on the same model (`0x479660`). + if let Some(hands) = rig.animations.map(|a| a.hand_close) { + let grip = bake.grip; + commands + .entity(booth.root) + .entry::() + .and_modify(move |mut player| set_grip(&mut player, hands, grip)); + } + slot + } + None => { + commands.entity(booth.root).despawn_related::(); + let mut booth_rig = spawn_booth_model( + &mut commands, + &mut palettes, + booth.root, + booth.layer.clone(), + &booth_parts, + &booth_riders, + rig.inverse_bindposes + .as_ref() + .map(|ibp| (rig.skeleton, ibp, rig.animations)), + anim_data.as_deref().map(|a| &a.0), + BoothMotion::Loop, // the glue preview is a live scene, not a still + bake.grip, // close each hand that holds a weapon + &booth_billboards, + instance, + ); + spawn_glue_effects( + &mut commands, + &mut booth_rig, + &booth.layer, + scene_buf.as_ref(), + &booth_effects, + instance, + ); + // A fresh bake is animated by construction; the park state is the new rig's. + booth.rigged = booth_rig.rigged(); + let slot = booth_rig.slot(); + booth_rig.finish(&mut commands); + booth.parked = false; + slot + } + }; // The whole-model tint ([`GhostKit::tint`], the reference's `0x472939` โ†’ `0x710cf0`), // per rig slot, identity when living so a ghost's colour never carries over. tints.set( - booth_rig.slot(), + slot, match bake.ghost { Some(g) => benilla_world::instance_tint::pack(g.tint), None => benilla_world::instance_tint::IDENTITY, }, ); - // The worn items' effects, through the reference's world attach primitive (`0x472c91` โ†’ - // `0x47a0c0` โ†’ `0x4798c0`); the enum carries no enchant, so only intrinsic effects show. - let (fx_emitters, fx_frames) = spawn_booth_effects( - &mut commands, - &mut booth_rig, - &booth.layer, - scene_buf.as_ref(), - &bake - .effects - .iter() - .map(|fx| BoothEffects { - bone: fx.bone, - offset: fx.offset, - emitters: fx.emitters.clone(), - }) - .collect::>(), - instance, - ); - if fx_emitters > 0 { - info!( - "glue booth: {fx_emitters} item emitter(s) up on {} effect model(s), \ - {fx_frames} on a billboard frame", - bake.effects.len() - ); - } - // A fresh bake is animated by construction; the park state is the new rig's. - booth.rigged = booth_rig.rigged(); - booth_rig.finish(&mut commands); - booth.parked = false; + state.create = (create && booth.rigged).then_some(StandingCreate { + display: bake.display_id, + stage: preview.stage, + hung, + }); // The fallback framing with no scene up: the model's `` pane camera, at the // bake's aspect, as it shows only for the frames before the scene's camera takes over. let (t, _) = body_frame(&anchors, 1.0); @@ -1401,13 +1886,101 @@ pub(super) fn sync_glue_booth( preview.yaw, ); // A yaw-only pass must not record revisions it did not commit, or a held pair never retries. - *last = Some(( + state.last = Some(( if rebake { bake.revision } else { last_rev }, preview.yaw, if rebake { scene_rev } else { last_scene }, )); } +/// The worn items' effects, through the reference's world attach primitive (`0x472c91` โ†’ +/// `0x47a0c0` โ†’ `0x4798c0`); the enum carries no enchant, so only intrinsic effects show. +fn spawn_glue_effects( + commands: &mut Commands, + booth_rig: &mut BoothRig, + layer: &RenderLayers, + light: Option<&bevy::render::render_resource::Buffer>, + effects: &[BoothEffects], + instance: BoothInstance, +) { + let (fx_emitters, fx_frames) = + spawn_booth_effects(commands, booth_rig, layer, light, effects, instance); + if fx_emitters > 0 { + info!( + "glue booth: {fx_emitters} item emitter(s) up on {} effect model(s), \ + {fx_frames} on a billboard frame", + effects.len() + ); + } +} + +/// Pace the stage's one scene clock ([`CreateScene::clock_ms`]) as a `ModelFFX` paces its own: +/// its `OnUpdate` (`0x76d7f0`), which only the visible-frame pump (`0x765650`) calls, adds the +/// frame's elapsed truncated to whole milliseconds ([`benilla_ui::widget::pane_clock_step_ms`]), +/// the step the stock panes' clocks take too. So a hidden pane's stage stands still and resumes +/// where it stopped, and a shown one runs at or below wall clock, never above. Every model on the +/// stage, the scene, the character and the pet, reads that clock +/// ([`benilla_world::rig_anim::SceneClock`] on each root): each player advances by the step, and +/// each global-sequence drive and the scene's texture transforms and colour tracks sample it. +pub(super) fn pace_glue_clock( + mut commands: Commands, + preview: Res, + time: Res