the paper doll steps its feet round, and the Raid Info button behaves
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Time<bevy::time::Real>>,
|
||||
mut booths: ResMut<super::Booths>,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
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<u32>,
|
||||
) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<f32>,
|
||||
/// 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<u16>,
|
||||
/// 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<bevy::animation::graph::AnimationNodeIndex>,
|
||||
}
|
||||
|
||||
/// 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<Assets<Image>>,
|
||||
warm: Res<crate::pipe_warm::WarmPass>,
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
mut cams: Query<(&BoothCam, &mut Camera)>,
|
||||
mut cams: Query<(
|
||||
Entity,
|
||||
&BoothCam,
|
||||
&mut Camera,
|
||||
Has<benilla_world::particles::ViewThrottled>,
|
||||
)>,
|
||||
rate: Res<PaneRate>,
|
||||
frames: Res<bevy::diagnostic::FrameCount>,
|
||||
// `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::<benilla_world::particles::ViewThrottled>();
|
||||
}
|
||||
}
|
||||
// `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<bool> = std::sync::OnceLock::new();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SavedInstanceInfo>,
|
||||
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<SavedInstanceInfo> = 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]
|
||||
|
||||
@@ -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<benilla_protocol::messages::RaidInstanceEntry>,
|
||||
/// **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<benilla_protocol::messages::RaidInstanceEntry>,
|
||||
) {
|
||||
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]
|
||||
|
||||
@@ -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::<i64>("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::<i64>("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
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<PaneRate>`-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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ParticleEmitter>`/`Without<ChildDraw>` keep the read provably disjoint from
|
||||
// the `&mut GlobalTransform` writes above.
|
||||
booth_cams: Query<
|
||||
(Entity, &GlobalTransform, &RenderLayers, &Camera),
|
||||
(
|
||||
Entity,
|
||||
&GlobalTransform,
|
||||
&RenderLayers,
|
||||
&Camera,
|
||||
Has<super::ViewThrottled>,
|
||||
),
|
||||
(
|
||||
With<Camera3d>,
|
||||
Without<WorldCamera>,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user