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