From 03fedf1375154dbefdece6d2f0ff163e12b09789 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Aug 2026 18:26:45 +0700 Subject: [PATCH] the paper doll steps its feet round, and the Raid Info button behaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rotating the paper doll plays the reference's turn-in-place shuffle instead of spinning it like a turntable — ShuffleLeft/Right picked by direction off the same scalar `SetRotation` is handed, a 100 ms expiry, a frequency-weighted idle variation on the way back; wow-re's re-derivation caught the pair inverted in the prose (the doll was stepping into its turn), and a display with no shuffle stays standing rather than freezing mid-turn - the doll's item effects run at full speed: the half-rate booth render (our pacing, not a cull) was skipping the emitter tick with the frame; measured off the reporter's side-by-side clip the skeletal clock was never slow, only the 30 fps cadence, so `boothHalfRate` now defaults off - the Raid Info button follows the server's answer: `UPDATE_INSTANCE_INFO` fires per packet as the client does (the reference discards the first), so a character bound to nothing ends with a grey button over an empty panel instead of a live button over nothing; the panel's scroll bar sits centred on the trough drawn behind it - an inspected player's gear keeps the template's bind line, stated as deliberate — there is no item object to read a bound flag from, which is what the reference prints for the same reason Co-Authored-By: Claude Fable 5 --- crates/benilla-app/assets/ui/RaidFrame.xml | 14 +- crates/benilla-app/src/creature_anim.rs | 2 +- .../benilla-app/src/creature_anim/select.rs | 2 +- crates/benilla-app/src/cvars.rs | 7 +- crates/benilla-app/src/portrait/booth.rs | 205 ++++++++++++++++++ crates/benilla-app/src/portrait/dressup.rs | 13 ++ crates/benilla-app/src/portrait/glue_booth.rs | 1 + crates/benilla-app/src/portrait/mod.rs | 110 ++++++++-- crates/benilla-app/src/ui_inspect.rs | 7 + crates/benilla-app/src/ui_party/feed.rs | 114 ++++++++-- crates/benilla-app/src/ui_party/mod.rs | 60 ++++- .../benilla-app/src/ui_script/raid_tests.rs | 113 ++++++++++ crates/benilla-app/tests/world_api_wall.rs | 12 +- crates/benilla-world/src/particles.rs | 21 ++ crates/benilla-world/src/particles/sim.rs | 61 +++++- 15 files changed, 678 insertions(+), 64 deletions(-) diff --git a/crates/benilla-app/assets/ui/RaidFrame.xml b/crates/benilla-app/assets/ui/RaidFrame.xml index 8d66c5d5..968b7e9b 100644 --- a/crates/benilla-app/assets/ui/RaidFrame.xml +++ b/crates/benilla-app/assets/ui/RaidFrame.xml @@ -534,7 +534,19 @@ -- runs on the empty case too — its early `if ( savedInstances > 0 )` leaves a stale -- scroll bar standing when the last lockout expires, which is a bug rather than a -- behaviour (nothing can observe the bar's absence except by seeing it wrongly there). - set_shown(RaidInfoScrollFrameScrollBar, savedInstances > 4) + local scrollable = savedInstances > 4 + set_shown(RaidInfoScrollFrameScrollBar, scrollable) + if scrollable then + -- **The bar has to move to the trough.** `UIPanelScrollFrameTemplate` seats it for + -- a bare ScrollFrame: 6 px out and 16 px down, so its up-arrow fits inside the + -- frame's own span. This panel draws an ornate trough behind it instead + -- (`RaidInfoScrollFrameTop`/`Bottom`, 30 px wide from +1), and the template's seat + -- leaves the 16-wide bar 2 px left of that trough's centre with its arrow floating + -- 13 px below the cap. The ref re-points it here for the same reason and never + -- puts it back — the value never varies, so setting it on each show is idempotent. + RaidInfoScrollFrameScrollBar:SetPoint( + "TOPLEFT", "RaidInfoScrollFrame", "TOPRIGHT", 8, -3) + end RaidInfoScrollFrame:UpdateScrollChildRect() end diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 0f89bed0..e2fe599c 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -45,7 +45,7 @@ use benilla_world::schedule::WorldStage; /// The pure animation-selection logic (RF-0057/0073 tables, movement/Special state, gait/swing/ready /// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector /// logic, separate from the Bevy driver systems in [`driver`]. -mod select; +pub(crate) mod select; pub(crate) use select::{ease_strafe_yaw, move_flags, strafe_body_offset, MovementState}; use select::{Mode, Special}; diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 23d9c7f2..39d50703 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -917,7 +917,7 @@ pub(super) fn blend_lambda(remaining_frac: f32) -> f32 { /// ([`replay_count`] — the second `_rand` site). Owned exactly rather than delegating to a host /// RNG, per the determinism guidance in the same note; one stream shared by every play, like the /// client's single CRT stream. -pub(super) fn msvc_rand(state: &mut u32) -> u16 { +pub(crate) fn msvc_rand(state: &mut u32) -> u16 { *state = state.wrapping_mul(214013).wrapping_add(2531011); ((*state >> 16) & 0x7fff) as u16 } diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 504118ea..718e12e0 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -194,7 +194,12 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ // rate-limit), while our RTT booths (1069) re-run the render graph per pane per frame. "1" = // the doll renders at half the frame rate while its pane is open; the knob is // [`crate::portrait::PaneRate`], and the default mirrors it (welded below). - ("boothHalfRate", "1"), + // + // **Default OFF since 1559.** 1444 shipped it on and named the condition for turning it + // back off — "whether a 30 fps doll *reads* right is the director's call". B312 is that + // call: the reference draws its doll every frame, so every frame is the faithful default + // and the ~1.6 ms/frame is one `SetCVar` away for anyone who wants it back. + ("boothHalfRate", "0"), ]; /// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index 2010271a..434c71c7 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -606,6 +606,162 @@ pub(super) fn spawn_booth_model( booth_rig } +/// The ids `PlayerModel:SetRotation` chooses between — read off the reference's own name table +/// (`0x6143b6 mov eax,[ecx*4+0x8686a8]`: 0 `Stand`, 11 `ShuffleLeft`, 12 `ShuffleRight`), not +/// from DBC lore. +const STAND: u16 = 0; +const SHUFFLE_LEFT: u16 = 11; +const SHUFFLE_RIGHT: u16 = 12; + +/// **Which sequence a facing change arms** — the reference's own direction test, stated once +/// (`0x505bb0`, wow-re `modelframe-camera-law.md` **§13**). +/// +/// The `fcomp` at `0x505bce` compares the **current** facing (in ST(0)) against the argument, and +/// the two branches read out as: current **<** angle ⇒ `0xc` **ShuffleRight**, current **>** angle +/// ⇒ `0xb` **ShuffleLeft**. Equal — and NaN, which falls the same way — arms `0` **Stand**, and +/// that is an *active* play, not a no-op: it is why `Model:SetSequence` cannot stick on one of +/// these panes. +/// +/// **§6's prose had this pair inverted**, and this port was built on it before wow-re's §13 +/// re-derived the compare instruction by instruction. The direction was wrong in exactly the way +/// nothing local can catch — the doll still stepped, just into its turn instead of with it. +/// +/// It runs on the **Lua-facing scalar** — the very value the reference hands `SetRotation` — so +/// the branch transfers with no sign work, and no reasoning about which way a Bevy `+Y` spin goes +/// has to be right for the feet to match the turn. +pub(super) fn turn_shuffle(faced: f32, angle: f32) -> u16 { + if faced < angle { + SHUFFLE_RIGHT + } else if faced > angle { + SHUFFLE_LEFT + } else { + STAND + } +} + +/// How long one rotation keeps its shuffle stepping: the reference's `[+0x3ec] = clock() + 100` +/// (`0x42c010` is a millisecond clock, so `+0x64` is +100 ms), drained by `0x505c50`'s per-paint +/// timer. +/// +/// The arm is **unconditional** — every one of `0x505bb0`'s three early exits jumps to `0x505c28`, +/// where the flag and the deadline are written, so no path through it skips them (wow-re §13.1). +/// A held rotate arrow rewrites the facing every frame (the pane's `OnUpdate`, +/// `ROTATIONS_PER_SECOND`), so the deadline is pushed forward every frame and the doll steps +/// continuously until the button comes up. Taking `spun` before the expiry check below reproduces +/// the reference's second guard for free: `[+0x3e8]` is a one-paint "a rotation happened since the +/// last paint" latch, and a paint preceded by a `SetRotation` can never expire. +const SHUFFLE_HOLD_SECS: f64 = 0.100; + +/// **Step the doll's feet round when it turns** (decision 1559, director report B313). +/// +/// The reference's rotate arrows do not spin the model on the spot: `Model:SetRotation` queues a +/// turn-in-place shuffle by direction *and then* writes the facing. Ours wrote only the facing +/// (0638's bake yaw), so the doll pivoted like a turntable. +/// +/// The pose evaluator's own gate does the switching: a node at literal weight `0.0` is skipped +/// entirely (`rig_anim::pose`), so raising the shuffle and dropping Stand leaves exactly one +/// contribution — committed at full value — without stopping either clock. That matters on the +/// way back: Stand's seek kept running under the shuffle, so resuming it is a continuation of the +/// idle loop, never a pop back to `t = 0`. +/// +/// Body panes only ([`Booth::live`]), which is the same set the reference's turn machinery sits +/// on. The doll cannot clack while it steps: footstep keys are fired by +/// `creature_anim::events::fire_anim_events`, whose query demands an `AnimDriver`, and a booth +/// root has never carried one. +pub(super) fn drive_booth_turn( + time: Res>, + mut booths: ResMut, + anim_data: Option>, + mut rigs: Query<(&mut AnimationPlayer, &benilla_assets::ModelAnimations)>, + // The variation roll's state — the reference passes variation `-1` to `0x7121a0`, which is + // "frequency-weighted random" off the CRT LCG (`0x71249a call 0x7400e5`). Same generator the + // world lane's picks use, so a doll and a world unit roll their idles alike. + mut rng: Local, +) { + let Some(anim_data) = anim_data.as_deref() else { + return; + }; + let now = time.elapsed_secs_f64(); + for booth in booths.0.values_mut() { + if !booth.live { + continue; + } + let Ok((mut player, anims)) = rigs.get_mut(booth.root) else { + // No rig in this booth (empty, or a boneless display): a shuffle in flight when the + // bake went away has nothing left to step. + booth.turn.shuffle = None; + booth.turn.spun = None; + booth.turn.playing = None; + continue; + }; + // Every id through the model's own resolution, the same path the bake's Stand takes. + // `find` is the HEAD variation — the stable identity to compare and to drop back to; + // `pick` is the reference's own weighted roll, which is what an *arm* uses. + let head = |id: u16| { + anims + .find(anims.resolve(id, &anim_data.0).id) + .map(|c| c.node) + }; + let stand = head(STAND); + // Arm: a rotation this frame plays its direction's sequence and re-arms the expiry. A + // reversal mid-step is a fresh arm — the opposite id is, by definition, not the one on the + // root bone — and the reference restarts the clip from t = 0 there, which a `stop` before + // the `play` gives us (`play` alone is idempotent, so a HELD direction arms once and lets + // the clip loop, exactly as the reference's id-equality test does). + if let Some(want) = booth.turn.spun.take() { + let armed = booth.turn.shuffle.map(|(id, _)| id).unwrap_or(STAND); + if want == STAND { + // Equal facings — and NaN — arm Stand outright (wow-re §13.2). Nothing is + // stepping after this. + if let (Some(stand), Some(prev)) = (stand, booth.turn.playing) { + player.stop(prev); + player.play(stand).set_weight(1.0); + } + booth.turn.shuffle = None; + booth.turn.playing = None; + } else if want != armed { + // `resolve` walks the model's own fallback chain, so a display that authors no + // shuffle lands back on Stand — and "switch to Stand, then drop Stand to weight + // 0" would freeze the doll mid-turn instead of leaving it standing. + let next = anims + .pick_variation( + anims.resolve(want, &anim_data.0).id, + crate::creature_anim::select::msvc_rand(&mut rng), + ) + .map(|c| c.node) + .filter(|n| Some(*n) != stand); + if let Some(next) = next { + if let Some(prev) = booth.turn.playing { + player.stop(prev); + } + player.play(next).repeat().set_weight(1.0); + if let Some(stand) = stand { + player.play(stand).set_weight(0.0); + } + booth.turn.playing = Some(next); + booth.turn.shuffle = Some((want, now + SHUFFLE_HOLD_SECS)); + } + } else if let Some((_, until)) = booth.turn.shuffle.as_mut() { + // Same direction still held: only the deadline moves. + *until = now + SHUFFLE_HOLD_SECS; + } + } + // Expire: the arrow came up (nothing re-armed the deadline) — back to Stand, which never + // stopped running underneath, so the idle continues rather than popping to t = 0. + if let Some((_, until)) = booth.turn.shuffle { + if now > until { + if let Some(prev) = booth.turn.playing.take() { + player.stop(prev); + } + if let Some(stand) = stand { + player.play(stand).set_weight(1.0); + } + booth.turn.shuffle = None; + } + } + } +} + /// Re-face each booth billboard card ([`BoothBillboard`]) to its booth's camera — the booth twin of /// the world's [`benilla_world::billboard::face_billboards`]. Each booth owns one camera, matched here by /// their shared render layer. The card is a child of its billboard bone's joint, so we set its @@ -939,4 +1095,53 @@ mod tests { "no BoothMatAlpha marker ⇒ untouched" ); } + + /// **The rotate arrows arm the shuffle the model turns toward** (1559, B313), and the pair is + /// the one wow-re §13.2 read off the `fcomp` at `0x505bce` — *not* the pair §6's prose + /// carried, which was inverted and which this port was first built on. Current facing **<** + /// the new angle ⇒ `0xc` ShuffleRight; **>** ⇒ `0xb` ShuffleLeft; equal (and NaN, which the + /// compare's unordered flags send the same way) ⇒ `0` Stand, an active arm rather than a + /// no-op. + /// + /// The pane's own Lua is what makes the mapping checkable end to end, and it is why an + /// inversion cannot be caught by reading either side alone: the reference uses **opposite + /// sign conventions** in its two callers, and both must still land on the foot the model + /// turns onto. The held arrows (`BenillaPaperDollModel_OnUpdate`) have held-LEFT *add*; the + /// click helpers (`BenillaPaperDollModel_RotateLeft`) have left *subtract*. + #[test] + fn a_turn_arms_the_shuffle_for_the_way_it_turned() { + assert_eq!(turn_shuffle(0.9, 1.0), SHUFFLE_RIGHT, "facing rose"); + assert_eq!(turn_shuffle(1.0, 0.9), SHUFFLE_LEFT, "facing fell"); + assert_eq!(turn_shuffle(0.61, 0.61), STAND, "a re-pose arms Stand"); + assert_eq!( + turn_shuffle(0.61, f32::NAN), + STAND, + "unordered falls to Stand" + ); + let facing = 0.61; + // Held: left ADDS, so a held left arrow steps ShuffleRight — the reference's own pairing, + // and the one that reads backwards until you have both halves in front of you. + assert_eq!( + turn_shuffle(facing, facing + 0.05), + SHUFFLE_RIGHT, + "held left" + ); + assert_eq!( + turn_shuffle(facing, facing - 0.05), + SHUFFLE_LEFT, + "held right" + ); + // Clicked: left SUBTRACTS, so the same arrow lands on the other shuffle. Both are the + // reference's, quoted not re-derived. + assert_eq!( + turn_shuffle(facing, facing - 0.03), + SHUFFLE_LEFT, + "clicked left" + ); + assert_eq!( + turn_shuffle(facing, facing + 0.03), + SHUFFLE_RIGHT, + "clicked right" + ); + } } diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index 7908cc4c..6cb4309b 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -162,6 +162,7 @@ pub(super) fn spawn_dressup_booth( aspect: 1.0, rigged: false, parked: false, + turn: super::Turn::default(), }, ); } @@ -350,6 +351,18 @@ pub(super) fn sync_dressup_booth( } // The yaw (the ref's `Model:SetRotation`) — applied on a fresh bake and on every spin, never on // an idle frame. A spin is a content edge too (decision 0540). + // + // And the other half of `SetRotation`: the turn-in-place shuffle + // ([`super::booth::drive_booth_turn`], 1559). The dressing room wires the same held-arrow + // `OnUpdate` the character window does (`BenillaDressUpModel_OnUpdate`), so it steps its feet + // the same way. Keyed on the yaw alone — this block also runs for a re-bake, which is a + // `RefreshUnit` in the reference and does not turn the model. + if booth.turn.faced != Some(preview.yaw) { + if let Some(prev) = booth.turn.faced { + booth.turn.spun = Some(super::booth::turn_shuffle(prev, preview.yaw)); + } + booth.turn.faced = Some(preview.yaw); + } commands .entity(booth.root) .insert(Transform::from_rotation(Quat::from_rotation_y(preview.yaw))); diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 4df044ea..bbf9e9ed 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -525,6 +525,7 @@ pub(super) fn spawn_glue_booth( aspect: 1.0, rigged: false, parked: false, + turn: super::Turn::default(), }, ); // The background scene's own root (the character root above yaws; the scene never does). diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 5ca6aeab..43a44f51 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -441,6 +441,8 @@ struct Booth { /// A rig stands in this booth ([`booth::BoothRig::rigged`] at the last bake) — the park /// gate's "is there anything to park". Cleared with the emptied booth. rigged: bool, + /// The rotate arrows' turn animation ([`Turn`], decision 1559) — body panes only. + turn: Turn, /// The booth scene is parked: its camera is asleep, so [`gate_booth_cameras`] put /// [`benilla_world::rig_anim::AnimParked`] on the root — the 0712 evaluator, the pose /// composes, the palette writes and the global-sequence writes all skip (the pose HOLDS — @@ -452,6 +454,38 @@ struct Booth { parked: bool, } +/// A body pane's **turn animation** state — the half of the reference's `SetRotation` that is not +/// the facing write (decision 1559, director report B313). +/// +/// `PlayerModel:SetRotation(angle)` (`0x505bb0`, wow-re `modelframe-camera-law.md` §6) does two +/// things: it picks a turn-in-place shuffle by direction, queues it and arms a 100 ms expiry, and +/// *then* writes the facing field `SetFacing` writes. Ours only ever did the second — the doll +/// spun on the spot while the reference's steps its feet round, which is what a held arrow looks +/// like there. +/// +/// The producer/consumer split is one frame's width and deliberate: [`sync_body_booth`] holds the +/// yaw and sets [`Self::spun`]; [`booth::drive_booth_turn`], which holds the `AnimationPlayer`, +/// spends it. The syncs run before it in the same chain. +#[derive(Default)] +struct Turn { + /// The facing this booth's root is posed at — the reference's `[+0x39c]`, and the value its + /// direction test compares the incoming angle against. `None` until the first pose, which is + /// why a fresh bake does not step (the reference re-snapshots on `RefreshUnit` without + /// calling `SetRotation`). + faced: Option, + /// A rotation happened this frame, and the `AnimationData` id it arms + /// ([`booth::turn_shuffle`] picked it — a shuffle, or `Stand` for the equal/NaN case). Set by + /// the sync, taken by the driver, at most one frame old. + spun: Option, + /// The shuffle currently stepping — its `AnimationData` id and the wall-clock second it + /// expires at. `None` = the doll is back on Stand. + shuffle: Option<(u16, f64)>, + /// The graph node that shuffle is actually running, kept because the reference arms a + /// **frequency-weighted random variation** (`0x7121a0`'s `-1`), so the node playing is not + /// recoverable from the id alone. + playing: Option, +} + /// How many frames a content edge keeps a booth camera rendering ([`Booth::wake`]): covers the /// command-applied spawn, the billboard re-face's one-frame lag, and GPU upload of fresh meshes. const BOOTH_SETTLE_FRAMES: u32 = 4; @@ -670,25 +704,26 @@ fn feed_gx_aspect( /// touched); only the camera skips, so the held frame is always one the pose just produced. /// /// `boothHalfRate` is **benilla's own CVar** (the reference has no second view to rate-limit — -/// its doll draws in the main pass, 1069's known rent). Default ON; whether a 30 fps doll -/// *reads* right is the director's call (§7) — flip it live with -/// `/script SetCVar("boothHalfRate", 0)` to A/B. Booth-lane item emitters tick only on drawn -/// frames (the draw-set law, `particles::sim`), so at half rate a sparkle cloud advances at -/// half speed — the law's own honest consequence, flagged for the same morning eye. +/// its doll draws in the main pass, 1069's known rent). **Default OFF since 1559**: 1444 shipped +/// it on and named the condition for the reverse — "whether a 30 fps doll *reads* right is the +/// director's call (§7)" — and B312 is that call. Every frame is the faithful default; the +/// ~1.6 ms/frame is one `/script SetCVar("boothHalfRate", 1)` away. +/// +/// Turning it on no longer slows the item effects. Booth-lane emitters used to freeze on the +/// camera's own `is_active` bit and so ticked once per *drawn* frame — the draw-set law read +/// onto a skip the reference never makes. They key on +/// [`benilla_world::particles::ViewThrottled`] now: a paced camera's scene is still live, so the +/// sim runs at full rate and only the render is halved (1559). /// /// The glue screens (`live_scene`) are exempt: a fullscreen create/select scene at 30 fps is /// not a pane crop, and those screens have no world behind them to pay for. -#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)] pub(crate) struct PaneRate { + /// Off by default — see the type's doc. The registered CVar's "0" is welded to this in + /// `cvars::tests`, so the shipped default cannot drift from the registered one. pub(crate) half: bool, } -impl Default for PaneRate { - fn default() -> Self { - Self { half: true } - } -} - /// The two **framing inputs** a body booth reads, in one param: the pane geometry the UI extract /// publishes ([`BoothPanes`]) and the display aspect ([`GxAspect`]). /// @@ -740,6 +775,8 @@ impl Plugin for PortraitPlugin { // After the scene: the pet's seat and its light are the scene's to publish. glue_booth::sync_glue_pet, dressup::sync_dressup_booth, + // After the syncs, which are the only writers of the yaw it spends (1559). + booth::drive_booth_turn, // Last: it reads the wake/pending state every sync above may have armed. gate_booth_cameras, ) @@ -946,6 +983,7 @@ fn setup_booths( aspect: 1.0, rigged: false, parked: false, + turn: Turn::default(), }, ); } @@ -1019,6 +1057,7 @@ fn setup_booths( aspect: 1.0, rigged: false, parked: false, + turn: Turn::default(), }, ); } @@ -1681,6 +1720,19 @@ fn sync_body_booth( // that (`DISPLAY_SIZE_CHANGED → RefreshUnit()`, which both panes register). Cheap enough to // re-derive here rather than key a whole re-bake on: the CAMERA does not depend on the display // aspect, only the root does. + // + // The **turn animation** rides the facing write, exactly as it does in the reference + // (`0x505bb0` picks the shuffle, arms its expiry, and only then stores the angle — 1559). + // Keyed on the yaw alone, not on this block's condition: a re-bake and a window resize both + // land here without the model having turned, and neither steps its feet in the reference. + // The comparison runs on the **Lua-facing scalar**, which is the very value the reference + // passes to `SetRotation`, so its own `current > angle` branch transfers with no sign work. + if booth.turn.faced != Some(yaw) { + if let Some(prev) = booth.turn.faced { + booth.turn.spun = Some(booth::turn_shuffle(prev, yaw)); + } + booth.turn.faced = Some(yaw); + } if parts_changed || *last_pose != Some((yaw, model_scale)) { commands.entity(booth.root).insert( Transform::from_rotation(Quat::from_rotation_y(yaw)) @@ -1717,7 +1769,12 @@ fn gate_booth_cameras( images: Res>, warm: Res, time: Res>, - mut cams: Query<(&BoothCam, &mut Camera)>, + mut cams: Query<( + Entity, + &BoothCam, + &mut Camera, + Has, + )>, rate: Res, frames: Res, // `WOW_BOOTH_LOG` only: the marker's REAL state beside this gate's `booth.parked` @@ -1731,7 +1788,7 @@ fn gate_booth_cameras( // `satisfied()` is false exactly while the covered warm window runs (the loading screen // holds on it), so this term costs nothing outside that window. let warming = !warm.satisfied(); - for (BoothCam(token), mut cam) in &mut cams { + for (cam_entity, BoothCam(token), mut cam, was_paced) in &mut cams { let Some(booth) = booths.0.get_mut(token.as_str()) else { continue; }; @@ -1776,13 +1833,32 @@ fn gate_booth_cameras( // no fullscreen glue scene — skip every other frame. `active` stays the LOGICAL state: // the park bookkeeping below keys off it (the pose keeps evaluating; only the render // skips), and the wake counter keeps draining per real frame. - let throttled = rate.half + // + // `paced` is the REGIME — is this camera being rate-limited at all — and `throttled` is + // this frame's parity within it. The regime is what the rest of the engine needs to know: + // a camera whose render we skipped is not a camera whose scene stopped, and the emitter + // lane keys on exactly that distinction ([`benilla_world::particles::ViewThrottled`], + // decision 1559 — B312's half-speed item effects were it reading our skip as a cull). + // Marking the regime rather than the parity also keeps the archetype still: the marker + // moves when a pane opens or closes, not twice every frame. + let paced = rate.half && live_pane && !(test || warming || live_scene) && booth.wake == 0 - && booth.pending.is_empty() - && frames.0 % 2 == 1; + && booth.pending.is_empty(); + let throttled = paced && frames.0 % 2 == 1; let render = active && !throttled; + if was_paced != paced { + if paced { + commands + .entity(cam_entity) + .insert(benilla_world::particles::ViewThrottled); + } else { + commands + .entity(cam_entity) + .remove::(); + } + } // `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame, // wall-stamped (the first-login black-pane hunt). static LOG: std::sync::OnceLock = std::sync::OnceLock::new(); diff --git a/crates/benilla-app/src/ui_inspect.rs b/crates/benilla-app/src/ui_inspect.rs index 8dd246c8..481dd7aa 100644 --- a/crates/benilla-app/src/ui_inspect.rs +++ b/crates/benilla-app/src/ui_inspect.rs @@ -156,6 +156,13 @@ fn inspect_slot_view( quality: quality as i32, name, link, + // `already_bound` stays `false` here, deliberately: `0x5da2c0` reads `ITEM_FIELD_FLAGS` + // off an item OBJECT, and an inspected player's gear arrives as descriptor fields on + // THEM — there is no item object and no flags word to read. So an inspect tooltip prints + // the template's own bind line (Binds when equipped), which is what the reference prints + // for the same reason. The enchant half below cannot rescue it: those slots name rows, + // not the instance's bound state. + already_bound: false, // All 7 slots, exactly as the reference's own inspect leg copies and renders them // (§E7) — a 1.12 server happens to fill only PERM and TEMP. No item object here, so no // charges and no `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdown: the reference's inspect diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 52658bf1..c607ca24 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -44,16 +44,27 @@ pub(super) struct FedParty { /// The raid roster's IDENTITY, which is what `RAID_ROSTER_UPDATE` fires on — see the fire /// site for why it is these four fields and not the whole row. raid_key: Vec<(u64, u32, u32, bool)>, - /// The saved-instance list last pushed, and whether the server had answered — the pair - /// `UPDATE_INSTANCE_INFO` fires on. + /// The saved-instance list last pushed, and the answer ticket it came in on + /// ([`GroupState::saved_instances_answers`]). saved: Vec, - saved_seen: bool, + saved_answers: u32, /// The ready-check ticket last seen ([`GroupState::ready_check`]). ready_check: u32, } const PARTY_TOKENS: [&str; 4] = ["party1", "party2", "party3", "party4"]; +/// **The saved-instance edge** — a new LIST *or* a new ANSWER (1561). +/// +/// Its own named rule because the second half is the one that is easy to lose, and losing it is +/// silent: every other edge in this feed is a diff, this one cannot be. The reference throws its +/// first `UPDATE_INSTANCE_INFO` away (`RaidFrame.hasRaidInfo`) and decides the Raid Info button on +/// the second, so a player with no lockouts — whose list is empty and never changes — has to reach +/// a second answer through the ticket alone. Diff the list only, and their button never dies. +fn saved_instances_moved(saved: &[SavedInstanceInfo], answers: u32, fed: &FedParty) -> bool { + saved != fed.saved || answers != fed.saved_answers +} + /// `raid1`..`raid40` — the unit tokens the RaidFrame's rows target, tooltip and re-read levels /// through. Spelled out rather than `format!`ed per push: [`UiScript::set_unit`] wants a `&str`, /// and a table of forty `&'static str` costs nothing where forty `String`s per roster change @@ -383,9 +394,7 @@ pub(super) fn feed_party( // ── UPDATE_INSTANCE_INFO (decision 1549) ──────────────────────────────────────────────── // // The saved-lockout list, pushed with its map names already resolved (the wire carries - // `Map.dbc` ids; a missing catalog degrades to the id rather than to a blank row). The event - // fires on the list OR on the "server has answered" flag moving, because the reference's - // Raid Info button is enabled off exactly that second fact. + // `Map.dbc` ids; a missing catalog degrades to the id rather than to a blank row). let saved: Vec = group .saved_instances .iter() @@ -398,12 +407,24 @@ pub(super) fn feed_party( reset: e.reset, }) .collect(); - if saved != fed.saved || group.saved_instances_seen != fed.saved_seen { + // **The event follows the ANSWER, not the list** (1561). It is the one edge here that is not a + // diff, and it cannot be: the reference throws its first `UPDATE_INSTANCE_INFO` away + // (`RaidFrame.hasRaidInfo`) and decides the Raid Info button on the second, so a player with no + // lockouts — whose list is empty and stays empty — would never reach a second one and would + // keep a live button onto an empty panel. The client signals per packet, empty answers + // included; `GroupState::saved_instances_answers` carries the bytes that settle it. + // + // The list is diffed as well, so a fresh VM is re-seeded with lockouts it never saw arrive — + // but the EVENT is a packet's to fire, and no packet arrives on a `/reload`, so a reset seeds + // in silence and the pane's own `RequestRaidInfo` on show fetches the real one. + if saved_instances_moved(&saved, group.saved_instances_answers, fed) { gate.audit("feed_party", "the saved-instance edge"); fed.saved = saved.clone(); - fed.saved_seen = group.saved_instances_seen; + fed.saved_answers = group.saved_instances_answers; script.set_saved_instances(saved); - script.fire_event("UPDATE_INSTANCE_INFO", vec![]); + if !vm_reset { + script.fire_event("UPDATE_INSTANCE_INFO", vec![]); + } } if group.pending_invite != fed.invite { @@ -1043,8 +1064,13 @@ fn test_apply_local( true } PartyRequest::RequestRaidInfo => { - // `/raidtest` seeds the lockouts up front; the ask just marks them answered. - group.saved_instances_seen = true; + // `/partytest raid` seeds the lockouts up front, so the ask is answered on the spot — + // with the list it already holds, which is exactly what the server does for a second + // ask. Re-applying it is what bumps the answer ticket, and the ticket is what fires + // `UPDATE_INSTANCE_INFO` (1561), so the sandbox reaches the second answer the Raid + // Info button is decided on instead of stalling on the first. + let held = std::mem::take(&mut group.saved_instances); + group.apply_raid_instance_info(held); true } _ => false, @@ -1284,19 +1310,31 @@ pub(crate) fn synthetic_raid( }, ); } - // Two lockouts for the Raid Info panel: Molten Core (map 409) and Onyxia's Lair (249). - group.apply_raid_instance_info(vec![ - benilla_protocol::messages::RaidInstanceEntry { - map: 409, - reset: 3 * 86_400 + 7_200, - instance: 1234, - }, - benilla_protocol::messages::RaidInstanceEntry { - map: 249, - reset: 14_400, - instance: 77, - }, - ]); + // SIX lockouts for the Raid Info panel, not the two this seeded through 1560. The panel fits + // four rows and grows a scroll bar at five (`RaidInfoFrame_Update`), and that bar has to be + // re-seated onto the trough art drawn behind it — so with two rows the one part of the panel + // with any geometry in it was unreachable from the instrument, and the misalignment shipped + // (1561). An instrument that can only reach the states a real character reaches by accident is + // not an instrument. Map ids are real ones, so the rows carry `Map.dbc`'s own names. + group.apply_raid_instance_info( + [ + (409, 3 * 86_400 + 7_200, 1234), // Molten Core + (249, 14_400, 77), // Onyxia's Lair + (469, 5 * 86_400, 812), // Blackwing Lair + (309, 2_700, 3), // Zul'Gurub + (509, 2 * 86_400 + 60, 640), // Ruins of Ahn'Qiraj + (533, 6 * 86_400, 91), // Naxxramas + ] + .into_iter() + .map( + |(map, reset, instance)| benilla_protocol::messages::RaidInstanceEntry { + map, + reset, + instance, + }, + ) + .collect(), + ); // Sandbox on, after `apply_list` cleared it (the wire-wins default) — see [`synthetic_roster`]. group.test = true; lines @@ -1306,6 +1344,34 @@ pub(crate) fn synthetic_raid( mod tests { use super::*; + /// A repeat answer is an edge even when it repeats *nothing* — the rule the Raid Info button + /// hangs off, and the one that was missing (1561). + #[test] + fn a_second_empty_answer_is_still_a_saved_instance_edge() { + let mut fed = FedParty::default(); + assert!( + saved_instances_moved(&[], 1, &fed), + "the first answer moves it" + ); + fed.saved_answers = 1; + assert!( + !saved_instances_moved(&[], 1, &fed), + "and nothing has happened since" + ); + assert!( + saved_instances_moved(&[], 2, &fed), + "the SECOND empty answer is an edge too — the button is decided on it, and a diff \ + over the list alone can never reach it" + ); + // The list half still works on its own, for the answer that actually brings something. + let one = [SavedInstanceInfo { + name: "Molten Core".into(), + instance: 1234, + reset: 86_400, + }]; + assert!(saved_instances_moved(&one, 1, &fed)); + } + /// The sandbox drain half: each group-mutating intent lands on the local mirror exactly as /// the server echo would have — and a real wire list always switches the sandbox back off. #[test] diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index 27217540..36c497c7 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -91,11 +91,20 @@ pub struct GroupState { /// must die with the socket, and [`Self::clear_session`] is that guarantee — a second /// resource would be a second thing to remember to clear. pub saved_instances: Vec, - /// **Answered by the server**: has any `SMSG_RAID_INSTANCE_INFO` arrived this session? The - /// reference gates its Raid Info button on the SECOND `UPDATE_INSTANCE_INFO` - /// (`RaidFrame.hasRaidInfo`), which is the same idea by a different route — an empty list and - /// "we have not asked yet" are indistinguishable without this. - pub saved_instances_seen: bool, + /// **How many times the server has answered** — one per `SMSG_RAID_INSTANCE_INFO`. A TICKET, + /// not a flag (the [`Self::ready_check`] shape), and the difference is load-bearing: the + /// reference decides its Raid Info button on the SECOND `UPDATE_INSTANCE_INFO`, because + /// `RaidFrame.hasRaidInfo` swallows the first. A feed that fires that event on a *diff* never + /// reaches the second for the ordinary player — their lockout list is empty, every answer says + /// so, nothing ever changes, and the button they should not be able to press stays live (1561). + /// + /// The real client fires per PACKET, not per change. VERIFIED off the bytes: the handler at + /// `0x49e070` takes `jbe 0x49e19d` (`0x49e0d8`) when the entry count is zero — straight past + /// the parse loop to `mov ecx, 0x21b` (539 = `UPDATE_INSTANCE_INFO`) and the one + /// `call FrameScript_SignalEvent 0x703e50` at `0x49e1a7`, which is the function's only exit + /// and the event's only fire site in the binary (`re/events/event-firesites.tsv`). The empty + /// answer signals exactly like a full one. + pub saved_instances_answers: u32, /// A ready-check TICKET, not a flag: every `MSG_RAID_READY_CHECK` open bumps it, so the feed /// fires `READY_CHECK` on a counter edge and a second check while the first popup is still up /// re-arms it. A boolean could not tell the two apart. @@ -353,13 +362,15 @@ impl GroupState { } /// `SMSG_RAID_INSTANCE_INFO` — our saved lockouts, replacing the list wholesale (decision - /// 1549). The empty answer is the ordinary one and still counts as an answer. + /// 1549). **The empty answer is the ordinary one and still counts as an answer**: it bumps the + /// ticket like any other, which is what makes the feed fire `UPDATE_INSTANCE_INFO` for a + /// player who has no lockouts at all — see [`GroupState::saved_instances_answers`] (1561). pub fn apply_raid_instance_info( &mut self, entries: Vec, ) { self.saved_instances = entries; - self.saved_instances_seen = true; + self.saved_instances_answers = self.saved_instances_answers.wrapping_add(1); } /// `MSG_RAID_READY_CHECK` (open form) — the leader started one. Bumps the ticket the feed @@ -382,6 +393,41 @@ mod tests { } } + /// **Every answer is an answer, including the one that says nothing changed** (1561). This is + /// the invariant the whole Raid Info button hangs off: the reference throws its first + /// `UPDATE_INSTANCE_INFO` away and decides the button on the second, so an empty list that + /// answers a second time has to be distinguishable from an empty list that answered once. A + /// flag cannot; a ticket can. Revert this to a `bool` and a player with no lockouts keeps a + /// live button onto an empty panel — which is exactly how it shipped. + #[test] + fn an_unchanged_lockout_list_still_counts_as_an_answer() { + let mut g = GroupState::default(); + assert_eq!(g.saved_instances_answers, 0, "nobody has asked yet"); + + // The ordinary player: no lockouts, and the server says so every time it is asked. + g.apply_raid_instance_info(Vec::new()); + assert_eq!(g.saved_instances_answers, 1); + g.apply_raid_instance_info(Vec::new()); + assert_eq!( + g.saved_instances_answers, 2, + "the second empty answer is the one the button is decided on" + ); + + // And a list that DOES change is not counted twice for it. + g.apply_raid_instance_info(vec![benilla_protocol::messages::RaidInstanceEntry { + map: 409, + reset: 86_400, + instance: 7, + }]); + assert_eq!(g.saved_instances_answers, 3); + assert_eq!(g.saved_instances.len(), 1, "and the list is the new one"); + + // The socket dies, the ticket dies with it — a fresh session has not been answered. + g.clear_session(); + assert_eq!(g.saved_instances_answers, 0); + assert!(g.saved_instances.is_empty()); + } + /// The ungated diff (0440 byte law): a FIRST roster prints joins for everyone already /// there; later rosters diff both ways. #[test] diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index 0a80d184..259ac8a0 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -755,6 +755,119 @@ fn the_raid_info_panel_lists_the_saved_lockouts() { assert!(!visible(&s, "RaidInfoFrame"), "and closes it again"); } +/// **The player with no lockouts at all** — the case that shipped broken (1561). Two answers, both +/// empty, is the whole ordinary session: one for `PLAYER_ENTERING_WORLD`'s `RequestRaidInfo` and +/// one for the pane's own on show. The first arms `RaidFrame.hasRaidInfo` and returns; the second +/// is the one that has to put the button away and empty the panel behind it. +/// +/// Its sibling above proves the same arithmetic from a list that had something in it. This one +/// proves it from a list that never did, which is the case a *diff* cannot reach — and the button +/// left live over an empty panel is exactly what the director saw. +#[test] +fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() { + let mut s = setup(); + open_raid_tab(&mut s); + + // Answer one: the latch, and nothing else. The button is still whatever it loaded as — the + // reference does not disable it in `RaidFrame_OnLoad` either, so this window is its own. + s.fire_event("UPDATE_INSTANCE_INFO", Vec::new()); + assert_eq!( + s.eval::("return RaidFrame.hasRaidInfo or 0").unwrap(), + 1, + "the first answer only arms the latch" + ); + + // Answer two, saying the same nothing. + s.fire_event("UPDATE_INSTANCE_INFO", Vec::new()); + assert_eq!( + s.eval::("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0") + .unwrap(), + 0, + "an empty lockout list is a dead button" + ); + + // And the panel behind it is emptied, not left in the state the XML loaded: every row away, + // and no scroll bar standing over a list of nothing. + s.run("RaidInfoFrame:Show()").unwrap(); + assert!(!visible(&s, "RaidInfoInstance1")); + assert!(!visible(&s, "RaidInfoInstance10")); + assert!(!visible(&s, "RaidInfoScrollFrameScrollBar")); + s.run("RaidInfoFrame:Hide()").unwrap(); + + // The button is dead to a real click, not merely drawn grey — the half the director asked for + // by name. Driven through the pointer, because that is the path a press actually takes. + let (bx, by) = { + let l: f32 = s.eval("return RaidFrameRaidInfoButton:GetLeft()").unwrap(); + let r: f32 = s.eval("return RaidFrameRaidInfoButton:GetRight()").unwrap(); + let t: f32 = s.eval("return RaidFrameRaidInfoButton:GetTop()").unwrap(); + let b: f32 = s + .eval("return RaidFrameRaidInfoButton:GetBottom()") + .unwrap(); + ((l + r) / 2.0, (t + b) / 2.0) + }; + s.mouse_button(bx, by, "LeftButton", true); + s.mouse_button(bx, by, "LeftButton", false); + s.tick(0.016); + assert!( + !visible(&s, "RaidInfoFrame"), + "a disabled button does not open the panel" + ); +} + +/// The scroll bar the fifth lockout brings on has to land on the trough drawn behind it, not where +/// `UIPanelScrollFrameTemplate` seats a bare ScrollFrame's bar (1561). +/// +/// Geometry, asserted as the relationship rather than as four numbers: the bar is CENTRED in the +/// trough art on X, and its top is the panel's own -3 rather than the template's -16. Both were +/// wrong before — 2 px left, 13 px low — and neither is visible to any test that only asks whether +/// the bar is shown. +#[test] +fn the_scroll_bar_is_seated_on_the_trough_the_panel_draws_behind_it() { + let mut s = setup(); + open_raid_tab(&mut s); + s.fire_event("UPDATE_INSTANCE_INFO", Vec::new()); + s.set_saved_instances( + (1..=6) + .map(|i| SavedInstanceInfo { + name: format!("Instance {i}"), + instance: 1000 + i, + reset: i * 3_600, + }) + .collect(), + ); + s.fire_event("UPDATE_INSTANCE_INFO", Vec::new()); + s.run("RaidInfoFrame:Show()").unwrap(); + assert!( + visible(&s, "RaidInfoScrollFrameScrollBar"), + "six lockouts do not fit in four rows" + ); + + let mid_x = |s: &UiScript, f: &str| -> f32 { + let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap(); + let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap(); + (l + r) / 2.0 + }; + let top = |s: &UiScript, f: &str| -> f32 { s.eval(&format!("return {f}:GetTop()")).unwrap() }; + + let bar = mid_x(&s, "RaidInfoScrollFrameScrollBar"); + let trough = mid_x(&s, "RaidInfoScrollFrameTop"); + assert!( + (bar - trough).abs() < 0.01, + "the bar rides the middle of its trough: bar {bar} vs trough {trough}" + ); + assert!( + (top(&s, "RaidInfoScrollFrameScrollBar") - (top(&s, "RaidInfoScrollFrame") - 3.0)).abs() + < 0.01, + "and hangs 3 px under the frame's top, not the template's 16" + ); + // The up arrow rides above the bar, so re-seating the bar is what lifts it into the trough's + // own cap — the piece of this that is actually visible. + assert!( + top(&s, "RaidInfoScrollFrameScrollBarScrollUpButton") > top(&s, "RaidInfoScrollFrame"), + "the up arrow clears the scroll frame, where the template left it 13 px inside" + ); +} + // ───────────────────────────────────────────────────────────────────────────────────────────── // The ready check // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index 56f680fa..1af47714 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -275,7 +275,17 @@ fn is_instrument_consumer(rel: &str) -> bool { /// measured on the real client writing *nothing*: no saved variables, no addon files, no camera /// pose. The alternative was hiding it inside `WorldPlugins` to keep the count flat, which is /// gaming the instrument rather than paying for a name. -const CEILING: usize = 161; +/// And 161 → 162: `particles::ViewThrottled`, a PUBLISH of the same shape as +/// `rig_anim::AnimParked` already through this door — a marker the ENGINE reads and the GAME +/// writes, which is the 1160 line drawn exactly where 1163 drew it. Freezing an emitter with the +/// view that stopped drawing it is machinery (the draw-set law lives in `particles::sim`); +/// deciding that a particular booth camera is being *paced* rather than put to sleep is policy, +/// and it is `portrait::gate_booth_cameras`'s, in the game. There is no engine-side way to tell +/// the two apart — a camera's `is_active` bit says only that it did not draw — which is precisely +/// the conflation that ran the body panes' item effects at half speed (decision 1559, B312). The +/// alternative was passing a `Res`-shaped opinion down into the engine's own sim, which +/// puts the game's knob inside the renderer to keep a count flat. +const CEILING: usize = 162; /// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be /// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it diff --git a/crates/benilla-world/src/particles.rs b/crates/benilla-world/src/particles.rs index 14fe8c53..a57725a0 100644 --- a/crates/benilla-world/src/particles.rs +++ b/crates/benilla-world/src/particles.rs @@ -43,6 +43,27 @@ use sim::simulate_particles; // model's emitters and classifies the same way; `sky_order::FAR_SIDE_BIAS`). pub(crate) use sim::{far_side_of_water, model_far_side, WaterInterleave}; +/// This camera is **rate-throttled**: it renders on some frames and skips others *by our own +/// pacing*, while the scene behind it keeps running at full rate. A frame it does not draw is a +/// SKIPPED frame, not a sleeping scene — so the lanes that freeze with a sleeping booth camera +/// ([`sim::simulate_particles`]) must keep simulating through it. +/// +/// The distinction is the whole point (decision 1559). The draw-set law — an emitter ticks only +/// inside a draw the frame performs, one frame's dt, no catch-up — is the *reference's*, and it +/// is keyed on the reference's own cull. `boothHalfRate` (decision 1444) is **ours**: a camera it +/// skips has a scene that is still posing at full rate (the skeleton's `AnimParked` keys on the +/// booth's LOGICAL active state, never on the rendered frame). Reading our skip as the +/// reference's cull ran the body panes' item emitters at half speed — director report B312, +/// which 1444's own record had flagged as the law's honest consequence and which is not: our +/// throttle must not reach the simulation at all. +/// +/// Maintained on the booth camera entity by `portrait::gate_booth_cameras`, which owns both +/// halves of the state (the logical `active` and the physical `render`). It marks the *regime*, +/// not the parity — it is present for as long as the camera is being paced, on drawn frames and +/// skipped ones alike, so the archetype does not churn every frame. +#[derive(Component)] +pub struct ViewThrottled; + /// Hard cap on a single emitter's live particle count — a backstop against a pathological model. Real /// props sit far under this (a campfire's steady state is `rate·lifespan` ≈ 30 + 24 particles). const MAX_PARTICLES: usize = 1024; diff --git a/crates/benilla-world/src/particles/sim.rs b/crates/benilla-world/src/particles/sim.rs index 2ec3dc27..3ef0b5dc 100644 --- a/crates/benilla-world/src/particles/sim.rs +++ b/crates/benilla-world/src/particles/sim.rs @@ -401,6 +401,25 @@ fn is_above(d: f32, r: f32) -> bool { d >= -r } +/// Does a booth-layered emitter freeze this frame? The rule the body panes' item effects turn on +/// (decision 1559), stated once because it was got wrong by being written inline. +/// +/// A booth camera that is **asleep** — `gate_booth_cameras` put its pane's window away — draws +/// none of this lane, so its emitters freeze with it: pool + age held, one frame's dt on +/// re-entry, no catch-up. That is the draw-set law's own shape (the reference ticks an emitter +/// only inside a draw the frame performs) and it is keyed on the reference's own cull. +/// +/// A camera that is merely **rate-throttled** ([`super::ViewThrottled`]) is not asleep. Its scene +/// is posing at full rate; `boothHalfRate` (decision 1444) is *our* pacing of the render, and a +/// frame we chose to skip is not a frame the reference culled. Reading the two as one ran the +/// body panes' item effects at half speed for as long as a pane was open — director report B312. +/// +/// A **draining** emitter never freezes either way: its pool has to run out, and freezing one +/// strands it forever (the world arm's rule further down). +fn booth_frozen(cam_active: bool, throttled: bool, draining: bool) -> bool { + !cam_active && !throttled && !draining +} + /// Per-frame: emit, integrate, and expand each emitter's pool into the shared effect-quad /// stream ([`super::buffer::EffectQuads`]). #[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set @@ -452,7 +471,13 @@ pub(super) fn simulate_particles( // rule. `Without`/`Without` keep the read provably disjoint from // the `&mut GlobalTransform` writes above. booth_cams: Query< - (Entity, &GlobalTransform, &RenderLayers, &Camera), + ( + Entity, + &GlobalTransform, + &RenderLayers, + &Camera, + Has, + ), ( With, Without, @@ -504,14 +529,10 @@ pub(super) fn simulate_particles( // camera. let booth = layers .filter(|l| !l.intersects(&RenderLayers::default())) - .and_then(|l| booth_cams.iter().find(|(_, _, cl, _)| cl.intersects(l))); + .and_then(|l| booth_cams.iter().find(|(_, _, cl, ..)| cl.intersects(l))); let is_booth = booth.is_some(); - // A sleeping booth camera (`gate_booth_cameras` — the pane's window closed) draws none - // of this lane, so its emitters freeze with it: pool + age held, the draw-set law's own - // shape (the reference ticks an emitter only inside a draw the frame performs). Draining - // pools still run out — freezing one strands it (the world arm's rule below). - if let Some((_, _, _, booth_cam)) = booth { - if !booth_cam.is_active && !emitter.draining { + if let Some((_, _, _, booth_cam, throttled)) = booth { + if booth_frozen(booth_cam.is_active, throttled, emitter.draining) { if !emitter.gated { emitter.gated = true; for slot in &emitter.model_instances { @@ -527,7 +548,7 @@ pub(super) fn simulate_particles( emitter.gated = false; } let (draw_cam, e_cam_pos, e_right, e_up) = match booth { - Some((cam_entity, tf, _, _)) => { + Some((cam_entity, tf, ..)) => { let (_, rot, _) = tf.to_scale_rotation_translation(); (cam_entity, tf.translation(), rot * Vec3::X, rot * Vec3::Y) } @@ -1244,8 +1265,8 @@ pub(super) fn simulate_particles( #[cfg(test)] mod tests { use super::{ - inherit_trigger, integrate_particle, is_above, world_motion_kept, ChildEmitter, Particle, - StepEnv, Vec3, + booth_frozen, inherit_trigger, integrate_particle, is_above, world_motion_kept, + ChildEmitter, Particle, StepEnv, Vec3, }; use bevy::prelude::{Quat, Transform}; @@ -1268,6 +1289,24 @@ mod tests { assert!(!is_above(f32::NAN, 2.0)); } + /// **A paced booth camera is not a sleeping one** (1559, director report B312). The draw-set + /// freeze belongs to a camera whose scene stopped; `boothHalfRate` skips a *render* over a + /// scene that is still posing at full rate, so the emitter owes it nothing. Conflating the + /// two is what ran the paper doll's item effects at half speed while its pane was open. + #[test] + fn a_throttled_booth_camera_does_not_freeze_its_emitters() { + // Asleep: the pane closed. Freeze — the reference's cull, faithfully. + assert!(booth_frozen(false, false, false)); + // Paced, on a frame it skipped: NOT frozen. This is the whole fix. + assert!(!booth_frozen(false, true, false)); + // Paced, on a frame it drew: nothing to decide, it was never frozen. + assert!(!booth_frozen(true, true, false)); + assert!(!booth_frozen(true, false, false)); + // A draining pool runs out on any camera — freezing one strands it. + assert!(!booth_frozen(false, false, true)); + assert!(!booth_frozen(false, true, true)); + } + fn particle(pos: Vec3, vel: Vec3) -> Particle { Particle { pos,