diff --git a/crates/benilla-app/src/aura_visual/tests.rs b/crates/benilla-app/src/aura_visual/tests.rs index 65b5b748..6512b1a0 100644 --- a/crates/benilla-app/src/aura_visual/tests.rs +++ b/crates/benilla-app/src/aura_visual/tests.rs @@ -244,6 +244,7 @@ fn app_with_chains() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); + app.add_message::(); app.add_message::(); app.add_message::(); app.add_message::(); @@ -354,7 +355,9 @@ fn a_proc_only_state_kit_arms_stealth_translucency() { let stealthed = ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)]); let unit = app .world_mut() - .spawn(crate::net::ObjectStore(stealthed)) + .spawn(crate::net::ObjectStore( + stealthed.into_created(benilla_protocol::messages::ObjectType::Unit), + )) .id(); app.update(); @@ -368,11 +371,12 @@ fn a_proc_only_state_kit_arms_stealth_translucency() { assert!(n.head_tint().is_none(), "kit 312 carries no tint"); // The aura drops: the node goes and the target ramps back to opaque. - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( - 95, 0, - )]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(95, 0)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); let n = app.world().entity(unit).get::().unwrap(); assert!(n.alpha.is_empty(), "reaped on the aura-remove edge"); @@ -386,10 +390,10 @@ fn a_kit_with_both_procs_installs_both_nodes() { let mut app = app_with_chains(); let unit = app .world_mut() - .spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[ - (47, GHOST), - (95, 0x0E), - ]))) + .spawn(crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + )) .id(); app.update(); @@ -404,7 +408,12 @@ fn a_kit_with_both_procs_installs_both_nodes() { fn two_auras_stack_and_unstack_through_the_slots() { let mut app = app_with_chains(); let both = ObjectFields::from_pairs(&[(47, STEALTH), (48, GHOST), (95, 0xEE)]); - let unit = app.world_mut().spawn(crate::net::ObjectStore(both)).id(); + let unit = app + .world_mut() + .spawn(crate::net::ObjectStore( + both.into_created(benilla_protocol::messages::ObjectType::Unit), + )) + .id(); app.update(); let n = app.world().entity(unit).get::().unwrap(); assert!( @@ -413,12 +422,12 @@ fn two_auras_stack_and_unstack_through_the_slots() { ); // Stealth alone drops (slot 0 cleared, slot 1 still occupied). - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[ - (48, GHOST), - (95, 0xE0), - ]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(48, GHOST), (95, 0xE0)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); let n = app.world().entity(unit).get::().unwrap(); assert_eq!(n.alpha.as_slice(), [(GHOST, 0.5)]); @@ -458,10 +467,10 @@ fn an_unmodelled_proc_only_kit_arms_nothing() { let unit = app .world_mut() - .spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[ - (47, SAP), - (95, 0x0E), - ]))) + .spawn(crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, SAP), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + )) .id(); app.update(); assert!( @@ -606,7 +615,10 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), skin, )) .id(); @@ -623,11 +635,12 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() { // The aura leaves its slot: the reference drops the tint node (`0x5ff320`) with no ease, and the // channel goes back to identity — which is the shader's no-op word, not a colour. - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( - 95, 0x00, - )]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(95, 0x00)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); let tints = app .world() @@ -663,7 +676,10 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), body, )) .id(); @@ -683,11 +699,12 @@ fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() { ); // The aura drops: both ends of the chain go back to identity, not just the unit's own slot. - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( - 95, 0x00, - )]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(95, 0x00)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); let tints = app .world() @@ -721,7 +738,10 @@ fn an_unchained_rig_inherits_nothing() { }; let loose_slot = loose.slot; app.world_mut().spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), body, )); app.world_mut().spawn(loose); @@ -753,7 +773,10 @@ fn the_rig_free_hook_clears_a_dead_units_tint() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), skin, )) .id(); @@ -821,7 +844,10 @@ fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), rig_playing(&[(4, 1.35), (54, 1.0)]), )) .id(); @@ -841,11 +867,12 @@ fn ice_block_holds_the_clocks_and_the_drop_lets_them_go() { app.update(); // idempotent // The aura leaves the slots. - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( - 95, 0, - )]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(95, 0)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); assert!(app .world() @@ -875,7 +902,10 @@ fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), rig_playing(&[(4, 1.35), (54, 1.0)]), )) .id(); @@ -902,11 +932,12 @@ fn a_clip_armed_under_the_freeze_comes_back_at_its_own_speed() { app.update(); assert!(clip(&app, unit, 156).0, "held on arrival"); - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( - 95, 0, - )]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(95, 0)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); assert!(!clip(&app, unit, 156).0); assert_eq!( @@ -928,7 +959,10 @@ fn the_freeze_reaches_the_mount_body() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), rig_playing(&[(91, 1.0), (54, 1.0)]), crate::entities::mount::MountChild(mount), )) @@ -938,11 +972,12 @@ fn the_freeze_reaches_the_mount_body() { assert!(clip(&app, unit, 91).0, "the rider"); assert!(clip(&app, mount, 5).0, "and the mount under them"); - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( - 95, 0, - )]))); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + ObjectFields::from_pairs(&[(95, 0)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); assert!(!clip(&app, mount, 5).0); assert_eq!(clip(&app, mount, 5).1, 2.0); @@ -956,7 +991,10 @@ fn an_aura_without_the_proc_never_touches_a_clock() { let unit = app .world_mut() .spawn(( - crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])), + crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ), rig_playing(&[(4, 1.35), (54, 1.0)]), )) .id(); diff --git a/crates/benilla-app/src/capture/probe_bg.rs b/crates/benilla-app/src/capture/probe_bg.rs index a1f05310..9e6165b6 100644 --- a/crates/benilla-app/src/capture/probe_bg.rs +++ b/crates/benilla-app/src/capture/probe_bg.rs @@ -18,7 +18,7 @@ //! — and then, from inside, prints a structured census every [`CENSUS_EVERY`] until //! [`census_samples`] are in. Every later battleground round is judged against these lines. //! -//! ## The one server-side lever, and why it is needed +//! ## The one server-side lever, and why it is needed — and why it is turned back off //! //! A battleground does not start until `min_players_per_team` bodies are queued on each side — //! four for Warsong Gulch on this server's patch, twenty for Alterac Valley @@ -35,6 +35,13 @@ //! (`LANG_DEBUG_BG_ON`/`_OFF`). Both facts are reported on the probe's own lines so a run that //! started from the wrong state reads as such rather than as a broken queue. //! +//! **It is turned back off once the match is running** (see `DOORS_SAMPLE`), because leaving it on +//! makes a solo battleground unendable: vmangos decrements its premature-finish countdown inside an +//! `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`), so while testing is on +//! the countdown is armed and then **frozen**. Decision 2290 said the opposite, from reading +//! `GetPrematureFinishTime()` without the branch that consumes it; 2296 corrects it, having watched +//! a run sit in Warsong Gulch for 492 s with `GetBattlefieldWinner()` nil the whole way. +//! //! ## What the census reports, and why each column is there //! //! | column | the question it answers | @@ -196,6 +203,10 @@ const GHOST_SAMPLES: u32 = 11; /// How many 3 s samples the flag leg takes after the use. const FLAG_SAMPLES: u32 = 5; +/// The census sample by which the doors have opened (they drop ~116 s after entry, and the census +/// ticks every 12 s). The testing flag is turned back off here — see the `Inside` arm. +const DOORS_SAMPLE: u32 = 11; + /// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the reference's area-spirit-healer acquire /// scan keys on (wow-re `interact-dead-fork-and-npc-service-ladder.md` §C row 6). const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6; @@ -349,6 +360,7 @@ fn bg_probe( states: Res, dropped: Res, go_templates: Res, + mut idle: ResMut, mut script: Option>, ) { let Ok(store) = me.single() else { @@ -357,6 +369,15 @@ fn bg_probe( let arena = arena(); let now = time.elapsed_secs_f64(); + // **This probe stands in for a player who is AT the keyboard.** benilla implements the + // reference's idle handler faithfully (`ui_chat::idle`: auto-sit then auto-AFK at 300 000 ms + // of no input), and vmangos removes an AFK player from a battleground outright + // (`Player::ToggleAFK` → `LeaveBattleground`). Two correct behaviours, one on each side, and + // together they eject an unattended probe five minutes in — which is exactly what the first + // long run hit: at t=300 s the census read Stormwind, not Warsong Gulch, and the chat said + // "You are now AFK". A battleground match cannot be watched to its end without this. + idle.stamp_present(std::time::Duration::from_secs_f64(now)); + // **The tap goes in as soon as there is a VM to put it in, and not before.** The probe's own // first frame is a world-entry frame: the in-game interface has not materialized yet, so an // install at Setup gets `no VM` and every later `EVENTS` line reads `(none)` — which looks @@ -619,6 +640,26 @@ fn bg_probe( &mut script, ); let samples = samples + 1; + + // **Flip the testing flag back OFF once the match is running**, which is the only way + // a solo battleground can ever end. vmangos decrements `m_prematureCountDownTimer` + // inside an `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`), + // so while testing is on the countdown is armed and **frozen** — the arm above it runs + // once, the fire below it never reaches its condition, and a one-player Warsong Gulch + // runs forever (vanilla WSG has no time limit; three captures is its only other end). + // A run that measured this stayed in for 492 s with `winner` nil the whole way. + // + // Turned off at the first sample past the doors, so the flag is only on for the queue + // that needed it. The countdown then runs its full `BattleGround.PrematureFinishTimer` + // (5 min) from that moment, which is what [`census_samples`] has to cover. + if samples == DOORS_SAMPLE { + info!( + "PROBE bg: TESTING off — the premature-finish countdown is frozen while it is \ + on, so a solo match could never end" + ); + gm(&net, ".debug bg"); + } + if samples >= census_samples() { // **The graveyard leg.** `.die` is the one thing that clears the probe's god // shield by design (0677), so a probe CAN die on purpose; a battleground death is @@ -1056,6 +1097,22 @@ fn census( fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &mut UiScript) { // The scoreboard and the battlefield minimap: both are LoadOnDemand-shaped roads nothing in // this tree has ever walked. A raise here is the finding. + // **Read both windows BEFORE toggling them.** The scoreboard shows itself when a match ends — + // `WorldStateScoreFrame_Update` does `if (GetBattlefieldWinner()) then ShowUIPanel(...)`, + // driven by `UPDATE_BATTLEFIELD_SCORE` — so after a real ending the toggle below *hides* it, + // and reading only the post-toggle state reports `IsShown=nil` for the run where the feature + // worked. That is the wrong way round, and it cost a round of inference to notice. + // + // Through `getglobal`, because `BattlefieldMinimap` is LoadOnDemand: indexing it before the + // toggle that loads it is a nil-index raise, and "not loaded yet" is a reading, not an error. + for name in ["WorldStateScoreFrame", "BattlefieldMinimap"] { + let shown = script + .eval::(&format!( + "local f = getglobal(\"{name}\") if not f then return \"not-loaded\" end return tostring(f:IsShown())" + )) + .unwrap_or_else(|e| format!("")); + info!("PROBE bg: BEFORE-TOGGLE {name}:IsShown={shown}"); + } for (what, chunk) in [ ("SCOREFRAME", "ToggleWorldStateScoreFrame()"), ("BFMINIMAP", "ToggleBattlefieldMinimap()"), @@ -1092,7 +1149,7 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m ); let _ = script.eval::<()>("LeaveBattlefield()"); gm(net, ".recall"); - // Put the lever back. A toggle, so this run's second `.debug bg` undoes this run's first. - gm(net, ".debug bg"); - info!("PROBE bg: DONE — `.debug bg` toggled back off"); + // The lever was already put back at `DOORS_SAMPLE` (see the `Inside` arm) — a second toggle + // here would turn it back ON and leave it that way for the next run. + info!("PROBE bg: DONE"); } diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index 68f2519c..c8d894a4 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -363,7 +363,7 @@ pub(super) fn drive_animations( ), // The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream, // shared by every play; [`select::msvc_rand`]). - mut rng: Local, + mut rng: ResMut, // The SELF unit's last-written anim state line of the `WOW_MOVE_TRACE` debug trace (the // diff-only filter; see the trace block after the mode machine). mut anim_trace_last: Local>, @@ -685,9 +685,7 @@ pub(super) fn drive_animations( let c = if first { c } else { - anims - .pick_variation(c.anim_id, select::msvc_rand(&mut rng)) - .unwrap_or(c) + anims.pick_variation(c.anim_id, rng.draw()).unwrap_or(c) }; let active = tr.play(&mut player, c.node, Duration::from_secs_f32(c.blend_time)); diff --git a/crates/benilla-app/src/creature_anim/driver/mode.rs b/crates/benilla-app/src/creature_anim/driver/mode.rs index e8bc7103..2cabfee6 100644 --- a/crates/benilla-app/src/creature_anim/driver/mode.rs +++ b/crates/benilla-app/src/creature_anim/driver/mode.rs @@ -63,7 +63,7 @@ pub(super) fn run( drv: &mut AnimDriver, tr: &mut AnimationTransitions, player: &mut AnimationPlayer, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, ) { let Frame { entity, diff --git a/crates/benilla-app/src/creature_anim/driver/play.rs b/crates/benilla-app/src/creature_anim/driver/play.rs index c314fd61..72237cd7 100644 --- a/crates/benilla-app/src/creature_anim/driver/play.rs +++ b/crates/benilla-app/src/creature_anim/driver/play.rs @@ -144,7 +144,7 @@ pub(super) fn cut_loop( anims: &ModelAnimations, id: u16, catalog: Option<&AnimDataCatalog>, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>, ) { let Some(head) = find_resolved(anims, id, catalog) else { @@ -178,12 +178,12 @@ fn arm( pub(super) fn roll_oneshot<'a>( anims: &'a ModelAnimations, head: &'a AnimClip, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, ) -> (&'a AnimClip, RepeatAnimation) { let c = anims - .pick_variation(head.anim_id, select::msvc_rand(rng)) + .pick_variation(head.anim_id, rng.draw()) .unwrap_or(head); - let repeat = match select::replay_count(c.replay, select::msvc_rand(rng)) { + let repeat = match rng.replay_count(c.replay) { r if r > 1 => RepeatAnimation::Count(r), _ => RepeatAnimation::Never, }; @@ -201,11 +201,11 @@ pub(super) fn pick_loop_variation<'a>( anims: &'a ModelAnimations, head: &'a AnimClip, relaxed: bool, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, ) -> &'a AnimClip { if relaxed { anims - .pick_variation(head.anim_id, select::msvc_rand(rng)) + .pick_variation(head.anim_id, rng.draw()) .unwrap_or(head) } else { head @@ -222,10 +222,10 @@ pub(super) fn roll_loop<'a>( anims: &'a ModelAnimations, head: &'a AnimClip, relaxed: bool, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, ) -> (&'a AnimClip, u32) { let c = pick_loop_variation(anims, head, relaxed, rng); - let r = select::replay_count(c.replay, select::msvc_rand(rng)); + let r = rng.replay_count(c.replay); (c, r) } @@ -247,7 +247,7 @@ pub(super) fn play( relaxed: bool, rate: f32, catalog: Option<&AnimDataCatalog>, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>, ) { // The lock's guard is `PlayAnimation`'s **front door**, above the arm helper that draws the @@ -389,7 +389,7 @@ pub(super) fn enter_special( player: &mut AnimationPlayer, anims: &ModelAnimations, catalog: Option<&AnimDataCatalog>, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>, ) -> Mode { if sp == Special::Fall { @@ -443,7 +443,7 @@ pub(super) fn leave_special( player: &mut AnimationPlayer, anims: &ModelAnimations, catalog: Option<&AnimDataCatalog>, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, window: &mut Option<(bevy::animation::graph::AnimationNodeIndex, u32)>, frozen: &mut Option, ) -> Mode { diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index cbbafff4..718c7c78 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -147,6 +147,8 @@ fn advance(app: &mut App, ms: u64) { fn app() -> App { let mut app = App::new(); + // The client's ONE `rand()` stream (2301) — the play's variation and replay rolls. + app.init_resource::(); // Asset + animation plugins so tests with REAL clip assets (the watchdog test) get Bevy's // `advance_animations` ticking completions; units without a graph handle are skipped by it, // so the asset-less tenants are unaffected. diff --git a/crates/benilla-app/src/creature_anim/driver/wound.rs b/crates/benilla-app/src/creature_anim/driver/wound.rs index cdcac44b..b19b7df4 100644 --- a/crates/benilla-app/src/creature_anim/driver/wound.rs +++ b/crates/benilla-app/src/creature_anim/driver/wound.rs @@ -121,7 +121,7 @@ pub(super) fn wound_trigger( player: &mut AnimationPlayer, anims: &ModelAnimations, catalog: Option<&AnimDataCatalog>, - rng: &mut u32, + rng: &mut benilla_assets::AnimRng, id: u16, mv: &MovementState, mounted: bool, @@ -136,7 +136,7 @@ pub(super) fn wound_trigger( // (`end = clock`, expired on arrival) — skip. No resolvable clip at all is the // `0x711a20` asset-presence abort. let clip = find_resolved(anims, id, catalog) - .and_then(|h| anims.pick_variation(h.anim_id, select::msvc_rand(rng))) + .and_then(|h| anims.pick_variation(h.anim_id, rng.draw())) .filter(|c| c.duration > 0.0); let node = clip.and_then(|c| { if full { diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index dc6c451e..cc429e62 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -1005,32 +1005,6 @@ pub(crate) fn blend_lambda(remaining_frac: f32) -> f32 { (3.0 - 2.0 * t) * t * t } -/// The client's `_rand` — the MSVCRT LCG (`state × 214013 + 2531011`, output `(state >> 16) & -/// 0x7fff`; byte-verified wow-re `rf36-rand-stub.md` at `0x7400e5`) — the roll feeding op4's -/// per-play **variation pick** (`ModelAnimations::pick_variation`) and its **replay-count roll** -/// ([`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(crate) fn msvc_rand(state: &mut u32) -> u16 { - *state = state.wrapping_mul(214013).wrapping_add(2531011); - ((*state >> 16) & 0x7fff) as u16 -} - -/// The per-arm **replay-count roll** (wow-re `loop-replay-fidget.md`, op4's second `_rand` site -/// `0x712692..0x7126cd`): `R = max(1, min + ⌊roll·(max−min)/32768⌋)` from the sequence's -/// `(minReplay, maxReplay)`. The client multiplies `R` into the play window (`0x7126d8`) — a -/// clamp-flag one-shot runs its timeline `R` times before freezing; loop-flag sequences ignore it. -/// Benilla expresses the same window as a repeat count on the one-shot play. `(0, 0)` → 1. -pub(super) fn replay_count(replay: (u32, u32), roll: u16) -> u32 { - let (min, max) = replay; - let extra = if max > min { - (u64::from(roll) * u64::from(max - min) / 32768) as u32 - } else { - 0 - }; - (min + extra).max(1) -} - /// The engaged standing idle (decision 0073 — the `0x5fd360` arm's weapon-class Ready pick, /// `0x5fcdc0`). Note the buckets differ from the swing table: fist **and** dagger ready as 1H. pub(super) fn ready_anim(main: Option<(u8, u8)>) -> u16 { diff --git a/crates/benilla-app/src/creature_anim/select/tests.rs b/crates/benilla-app/src/creature_anim/select/tests.rs index 57826ed1..0b88eab4 100644 --- a/crates/benilla-app/src/creature_anim/select/tests.rs +++ b/crates/benilla-app/src/creature_anim/select/tests.rs @@ -834,36 +834,6 @@ fn wound_weight_decays_smoothstep_to_zero() { assert!((wound_weight(1.0, 9.0) - 27.0).abs() < 1e-4); } -#[test] -fn msvc_rand_is_the_crt_lcg() { - // srand(1)'s canonical first outputs: 41, 18467, 6334 — the exact MSVCRT stream the client's - // variation roll consumes (wow-re rf36-rand-stub.md). - let mut state = 1u32; - assert_eq!(msvc_rand(&mut state), 41); - assert_eq!(msvc_rand(&mut state), 18467); - assert_eq!(msvc_rand(&mut state), 6334); - // Output stays in the client's 0..0x7fff roll domain. - let mut state = 0xdead_beefu32; - for _ in 0..100 { - assert!(msvc_rand(&mut state) <= 0x7fff); - } -} - -#[test] -fn replay_count_rolls_the_window_multiplier() { - // (0,0) — the overwhelming majority — always 1. - assert_eq!(replay_count((0, 0), 0), 1); - assert_eq!(replay_count((0, 0), 0x7fff), 1); - // A fixed authored count passes through (clamped ≥ 1). - assert_eq!(replay_count((3, 3), 0x7fff), 3); - // A range: R = min + ⌊roll·(max−min)/32768⌋ — max is approached, never exceeded. - assert_eq!(replay_count((2, 4), 0), 2); - assert_eq!(replay_count((2, 4), 16384), 3); - assert_eq!(replay_count((2, 4), 0x7fff), 3); // ⌊32767·2/32768⌋ = 1 - // Malformed (max < min) degrades to min. - assert_eq!(replay_count((5, 2), 0x7fff), 5); -} - #[test] fn defense_anim_matches_the_byte_lut() { // Decision 0279: the `0x60ec98` LUT read off WoW.exe — dagger parries 1H (unlike its swing), diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 339f7e66..26197d15 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -19,7 +19,7 @@ use benilla_protocol::EntityKind; use crate::aura_visual::AuraProc; use crate::entities::ItemDisplays; use crate::items::Items; -use crate::net::{NetCommands, NetEntity, ObjectStore}; +use crate::net::{FieldChanged, NetCommands, NetEntity, ObjectStore}; use benilla_assets::{LockRecover, WorldAssets}; use super::{ @@ -1322,14 +1322,24 @@ pub(super) fn route_cast_visuals( /// exactly that bug: Stealth's kit 312 carries no models, no anim and no attach at all (its whole /// visual is one proc-14 CharProc), so an effects-only test dropped it and the character showed /// nothing. +/// The reference's one aura watch: `UNIT_FIELD_AURA` + `UNIT_FIELD_AURAFLAGS` as a single +/// registration — `0x604d00`, registered `604226 mov edx,0xa4` / `604221 push 0xd8` for class 3 +/// (wow-re `object-layer.md`): field offset `0xa4` = dword 41 past the 6 OBJECT fields = +/// `FIELD_UNIT_AURA`, length `0xd8` bytes = 54 dwords = the 48 slot ids and the 6 packed flag words. +const AURA_WATCH_BASE: u16 = benilla_protocol::field::FIELD_UNIT_AURA; +const AURA_WATCH_SPAN: u16 = 54; + pub(crate) fn arm_aura_state_fx( - // The slot diff below is a pure function of the store's aura fields, so it re-runs only when - // the store was written — `arm_level_up_fx`'s idiom (decision 1357's sibling gate): at the - // LBRS pin this was ~800 full aura-slot walks/frame re-deriving an unchanged answer. The + // The slot diff below is a pure function of the store's aura fields, so it re-runs for a unit + // only when one of THOSE moved — the field edges inside the reference's own aura watch span + // (decision 2297; before that, any store write: at the LBRS pin ~800 full aura-slot walks per + // frame re-deriving an unchanged answer, most of them health ticks) — and on the unit's first + // sight, because a standing aura is a STATE the streamed-in unit wears, not an edge. The // unfiltered twin runs exactly once per DBC-resource arrival: a unit that streamed in before - // `SpellVisuals`/`Spells` landed carries standing auras no store write will re-announce. + // `SpellVisuals`/`Spells` landed carries standing auras no edge will re-announce. units: Query<(Entity, &ObjectStore)>, - changed: Query<(Entity, &ObjectStore), Changed>, + arrived: Query>, + mut edges: MessageReader, visuals: Option>, spells: Option>, mut fx: MessageWriter, @@ -1346,7 +1356,17 @@ pub(crate) fn arm_aura_state_fx( let scan = if full_sweep { units.iter().collect::>() } else { - changed.iter().collect::>() + let mut due: EntityHashSet = arrived.iter().collect(); + due.extend( + edges + .read() + .filter(|e| { + e.unit_array_slot(AURA_WATCH_BASE, AURA_WATCH_SPAN) + .is_some() + }) + .map(|e| e.entity), + ); + due.into_iter().filter_map(|e| units.get(e).ok()).collect() }; for (entity, store) in scan { let prev = armed.entry(entity).or_default(); @@ -1452,14 +1472,17 @@ pub(crate) fn arm_aura_state_fx( /// [`LOOT_FX_KEY`] — the client hangs loot art on the same `Effect_C` node type its spell /// visuals use, so sharing the one attach body is the faithful shape. pub(super) fn arm_loot_fx( - // Dead+lootable is a pure function of store fields — the same `Changed` gate as - // `arm_level_up_fx` below and `arm_aura_state_fx` above, with the same one-shot full sweep - // when the DBC resource lands after units already streamed in. `NetEntity` splits the two - // predicates: a **unit** answers `UNIT_DYNFLAG_LOOTABLE`, a **corpse object** answers its own + // Dead+lootable is a pure function of four store fields, so a unit is re-read on a field + // edge on any of them and on first sight (a body that streams in lootable wears the sparkle — + // the reference arms it at build, `0x5d6e30`, and re-arms off the field's own mirror handler), + // with the same one-shot full sweep as `arm_aura_state_fx` when the DBC resource lands after + // units already streamed in (decision 2297). `NetEntity` splits the two predicates: a **unit** + // answers `UNIT_DYNFLAG_LOOTABLE`, a **corpse object** answers its own // `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 (decision 1723) — different fields at different indices, - // and a corpse descriptor has no UNIT block to ask at all. + // and a corpse descriptor has no UNIT block to ask at all; the edge's `kind` keeps them apart. units: Query<(Entity, &ObjectStore, &crate::net::NetEntity)>, - changed: Query<(Entity, &ObjectStore, &crate::net::NetEntity), Changed>, + arrived: Query>, + mut edges: MessageReader, visuals: Option>, mut fx: MessageWriter, mut armed: Local, @@ -1472,7 +1495,24 @@ pub(super) fn arm_loot_fx( let scan = if full_sweep { units.iter().collect::>() } else { - changed.iter().collect::>() + use benilla_protocol::field::{ + FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_HEALTH, + FIELD_UNIT_MAXHEALTH, + }; + let mut due: EntityHashSet = arrived.iter().collect(); + due.extend( + edges + .read() + .filter(|e| { + e.unit_field(FIELD_UNIT_DYNAMIC_FLAGS) + || e.unit_field(FIELD_UNIT_HEALTH) + || e.unit_field(FIELD_UNIT_MAXHEALTH) + || (e.kind == benilla_protocol::messages::ObjectType::Corpse + && e.index == FIELD_CORPSE_DYNAMIC_FLAGS) + }) + .map(|e| e.entity), + ); + due.into_iter().filter_map(|e| units.get(e).ok()).collect() }; for (entity, store, net) in scan { // The **corpse object**'s own sparkle (wow-re `corpse-decal-and-loot-sparkle.md` §Q2, §5 @@ -1537,51 +1577,46 @@ pub(super) fn arm_loot_fx( /// `UNIT_FIELD_LEVEL` **change** on any streamed unit (the client's descriptor change-watcher /// `CMirrorHandler 0x6045b0` → `SpawnHardcodedEffect(5)`) spawns [`LEVEL_UP_EFFECT`] /// (`Spells\LevelUp\LevelUp.mdl`) at the base attach — the ding visual is DECOUPLED from -/// `SMSG_LEVELUP_INFO`, so anyone leveling nearby flashes too. First sight of a unit arms -/// silently (it streamed in *carrying* a level, it didn't just gain one). The instance -/// self-terminates on its own 1.867 s clip (spell id 0, the kit-push stage-0 shape — no reap -/// key needed, unlike the persistent loot sparkle above), and its **sound is the model's own** -/// `$SND(888)` event → `Sound\Spells\LevelUp.wav`, fired by +/// `SMSG_LEVELUP_INFO`, so anyone leveling nearby flashes too. A unit that streams in +/// *carrying* a level didn't just gain one — and the field-edge stream is create-suppressed, so +/// that needs no memory here (decision 2297; this was the first `Local` shadow +/// to go). The instance self-terminates on its own 1.867 s clip (spell id 0, the kit-push +/// stage-0 shape — no reap key needed, unlike the persistent loot sparkle above), and its +/// **sound is the model's own** `$SND(888)` event → `Sound\Spells\LevelUp.wav`, fired by /// `crate::entities::spell_fx`'s event-track scanner. pub(super) fn arm_level_up_fx( - changed: Query<(Entity, &ObjectStore), Changed>, - units: Query<(), With>, + mut edges: MessageReader, visuals: Option>, - mut levels: Local>, mut fx: MessageWriter, ) { - for (entity, store) in &changed { - let Some(level) = store.0.unit_level() else { + for e in edges.read() { + if !e.unit_field(benilla_protocol::field::FIELD_UNIT_LEVEL) { continue; - }; - match levels.insert(entity, level) { - Some(prev) if prev != level => { - let Some((effect, path)) = visuals - .as_ref() - .and_then(|v| v.0.hardcoded_effect(LEVEL_UP_EFFECT)) - else { - continue; // no client data / no such row (the DBC-resource degrade shape) - }; - debug!("anim: level {prev} → {level}, the ding flashes ({entity})"); - fx.write(SpellKitFx::Begin { - entity, - spell_id: 0, - persistent: false, - class: FxClass::Hold, - // The ding self-terminates on its own 1.867 s clip — the stage-0 shape. - stage: FxStage::OneShot, - effects: vec![FxSlot { - tag: HARDCODED_FX_ATTACH, - effect, - path: path.to_string(), - }], - }); - } - _ => {} // first sight arms; an unchanged write is a no-op } + let Some((effect, path)) = visuals + .as_ref() + .and_then(|v| v.0.hardcoded_effect(LEVEL_UP_EFFECT)) + else { + continue; // no client data / no such row (the DBC-resource degrade shape) + }; + debug!( + "anim: level {} → {}, the ding flashes ({})", + e.old, e.new, e.entity + ); + fx.write(SpellKitFx::Begin { + entity: e.entity, + spell_id: 0, + persistent: false, + class: FxClass::Hold, + // The ding self-terminates on its own 1.867 s clip — the stage-0 shape. + stage: FxStage::OneShot, + effects: vec![FxSlot { + tag: HARDCODED_FX_ATTACH, + effect, + path: path.to_string(), + }], + }); } - // Streamed units despawn on range-out — drop their level memory with them. - levels.retain(|e, _| units.contains(*e)); } /// The **mount poof** — the cloud the director reports seeing at mount-up and we never drew. @@ -1615,45 +1650,41 @@ pub(super) fn arm_level_up_fx( /// today (the mounted gate refuses a second mount spell) and bounded at "one extra puff", so it /// is named rather than modelled. pub(super) fn arm_mount_poof_fx( - changed: Query<(Entity, &ObjectStore), Changed>, - units: Query<(), With>, + mut edges: MessageReader, visuals: Option>, - mut displays: Local>, mut fx: MessageWriter, ) { - for (entity, store) in &changed { - let mount_display = store.0.unit_mount_display_id(); - match displays.insert(entity, mount_display) { - // The build leg's gate is on the NEW value, so a dismount (`mount_display == 0`) - // spawns nothing — and first sight arms silently, exactly like the ding: a unit that - // streams in already mounted did not just mount. - Some(prev) if prev != mount_display && mount_display != 0 => { - let Some((effect, path)) = visuals - .as_ref() - .and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT)) - else { - continue; // no client data / no such row (the DBC-resource degrade shape) - }; - debug!("anim: mount display {prev} → {mount_display}, the poof puffs ({entity})"); - fx.write(SpellKitFx::Begin { - entity, - spell_id: 0, - persistent: false, - class: FxClass::Hold, - // `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s. - stage: FxStage::OneShot, - effects: vec![FxSlot { - tag: HARDCODED_FX_ATTACH, - effect, - path: path.to_string(), - }], - }); - } - _ => {} + for e in edges.read() { + // The build leg's gate is on the NEW value, so a dismount (`new == 0`) spawns nothing — + // and a unit that streams in already mounted did not just mount, which the + // create-suppressed edge stream says for us, exactly as for the ding (2297). + if !e.unit_field(benilla_protocol::field::FIELD_UNIT_MOUNTDISPLAYID) || e.new == 0 { + continue; } + let Some((effect, path)) = visuals + .as_ref() + .and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT)) + else { + continue; // no client data / no such row (the DBC-resource degrade shape) + }; + debug!( + "anim: mount display {} → {}, the poof puffs ({})", + e.old, e.new, e.entity + ); + fx.write(SpellKitFx::Begin { + entity: e.entity, + spell_id: 0, + persistent: false, + class: FxClass::Hold, + // `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s. + stage: FxStage::OneShot, + effects: vec![FxSlot { + tag: HARDCODED_FX_ATTACH, + effect, + path: path.to_string(), + }], + }); } - // Streamed units despawn on range-out — drop their mount memory with them. - displays.retain(|e, _| units.contains(*e)); } /// The one-slot **pending-morph latch** — the reference's `[unit+0xd54]`, a `SpellRec*` (wow-re @@ -1687,52 +1718,40 @@ fn is_morph_spell(spells: &crate::ui_action::Spells, spell_id: u32) -> bool { }) } -/// Arm [`MorphLatch`] from the aura-slot diff — the reference's TWO arm sites folded into the -/// one place benilla sees both edges: the aura-add watcher and the aura-remove handler's tail +/// Arm [`MorphLatch`] from the aura-slot edges — the reference's TWO arm sites folded into the +/// one place benilla sees both: the aura-add watcher and the aura-remove handler's tail /// (`0x6123ad`) both call `0x5ff0c0(spellId)`, and a later arm overwrites an earlier one (one -/// slot). First sight of a unit seeds the diff baseline silently — the reference's watchers -/// fire on VALUES deltas, never on create, so a druid streaming in mid-form must not latch. -/// Keyed to its own full-slot diff rather than [`arm_aura_state_fx`]'s `armed` map, which -/// tracks only state-kit spells — the form spells carry none. +/// slot). Each `UNIT_FIELD_AURA` slot edge is one dword of `0x604d00`'s watch (decision 2297): +/// its old spell is a REMOVE arm, its new spell an ADD arm — remove first, add second, so a +/// form→form swap in one slot latches the form being ENTERED (both resolve to the same cloud +/// family regardless). A unit's first sight emits no edge, so a druid streaming in mid-form does +/// not latch — the reference's watchers fire on VALUES deltas, never on create. pub(super) fn arm_morph_latch( - units: Query<(Entity, &ObjectStore)>, - changed: Query<(Entity, &ObjectStore), Changed>, + units: Query<(), With>, + mut edges: MessageReader, spells: Option>, mut latch: ResMut, - mut seen: Local>>, ) { - for (entity, store) in &changed { - let mut cur: Vec = store.0.unit_auras().map(|a| a.spell_id).collect(); - cur.sort_unstable(); - cur.dedup(); - match seen.entry(entity) { - bevy::platform::collections::hash_map::Entry::Vacant(e) => { - e.insert(cur); // first sight arms silently + if let Some(spells) = spells.as_deref() { + for e in edges.read() { + if e.unit_array_slot( + benilla_protocol::field::FIELD_UNIT_AURA, + u16::from(benilla_protocol::messages::UNIT_AURA_SLOTS), + ) + .is_none() + { + continue; } - bevy::platform::collections::hash_map::Entry::Occupied(mut e) => { - let prev = e.get_mut(); - if *prev == cur { - continue; // some other field moved + for spell_id in [e.old, e.new] { + if spell_id != 0 && is_morph_spell(spells, spell_id) { + debug!("anim: morph latch armed ({}, spell {spell_id})", e.entity); + latch.0.insert(e.entity, spell_id); } - if let Some(spells) = spells.as_deref() { - // Remove edges first, add edges second: a form→form swap latches the form - // being ENTERED (both resolve to the same cloud family regardless). - let removed = prev.iter().filter(|s| cur.binary_search(s).is_err()); - let added = cur.iter().filter(|s| prev.binary_search(s).is_err()); - for &spell_id in removed.chain(added) { - if is_morph_spell(spells, spell_id) { - debug!("anim: morph latch armed ({entity}, spell {spell_id})"); - latch.0.insert(entity, spell_id); - } - } - } - *prev = cur; } } } - // Streamed units despawn on range-out — the latch and the baseline die with them. + // Streamed units despawn on range-out — the latch dies with them. latch.0.retain(|e, _| units.contains(*e)); - seen.retain(|e, _| units.contains(*e)); } /// The rebuild's **impact-kit replay** — the tail of the reference's `0x60abe0` (wow-re diff --git a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs index ffa27d95..b8aec7fd 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -47,6 +47,7 @@ fn app() -> App { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -210,6 +211,7 @@ fn precast_kit_sound_rings_once_at_start() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -489,6 +491,7 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); + app.add_message::(); // The watcher's other fan-outs: the kit's CharProc edges (`crate::aura_visual`) and its // sound leg (0852). Food's kit 409 carries neither, so nothing is asserted here — the // messages just have to exist for the writers. @@ -553,7 +556,12 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() { let eating = ObjectFields::from_pairs(&[(47, FOOD), (95, 0x0E)]); let fasted = ObjectFields::from_pairs(&[(95, 0)]); - let unit = app.world_mut().spawn(crate::net::ObjectStore(eating)).id(); + let unit = app + .world_mut() + .spawn(crate::net::ObjectStore( + eating.into_created(benilla_protocol::messages::ObjectType::Unit), + )) + .id(); app.update(); { let log = &app.world().resource::().0; @@ -591,9 +599,11 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() { ); // The aura leaves the slots: one AuraState reap. - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore(fasted)); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + fasted.into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.update(); { let log = &app.world().resource::().0; @@ -639,6 +649,7 @@ fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -747,6 +758,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -861,6 +873,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -1055,6 +1068,7 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); + app.add_message::(); app.insert_resource(SpellVisuals( SpellVisualCatalog::from_tables(HashMap::new(), HashMap::new()).with_hardcoded( "HARDCODED Mount Poof", @@ -1064,10 +1078,14 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { )); app.add_systems(Update, super::arm_mount_poof_fx); - let store = - |v: u32| crate::net::ObjectStore(ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, v)])); - // Streams in ALREADY mounted: first sight arms the memory, silently. - let unit = app.world_mut().spawn(store(2404)).id(); + let fields = |v: u32| ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, v)]); + // Streams in ALREADY mounted: the create block is no edge (decision 2297), so silence. + let unit = app + .world_mut() + .spawn(crate::net::ObjectStore( + fields(2404).into_created(benilla_protocol::messages::ObjectType::Unit), + )) + .id(); app.update(); let puffs = |app: &mut App| -> Vec { let mut out = Vec::new(); @@ -1090,12 +1108,12 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { ); // Dismount — the NEW value is 0, so the build leg (and the whole allocation) is skipped. - app.world_mut().entity_mut(unit).insert(store(0)); + crate::net::apply_fields_for_test(app.world_mut(), unit, fields(0)); app.update(); assert!(puffs(&mut app).is_empty(), "no poof on the way down"); // Mount: 0 → N. - app.world_mut().entity_mut(unit).insert(store(2404)); + crate::net::apply_fields_for_test(app.world_mut(), unit, fields(2404)); app.update(); assert_eq!( puffs(&mut app), @@ -1108,12 +1126,12 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { ); // A steady mounted frame is not an edge — the watcher fires on the field CHANGING. - app.world_mut().entity_mut(unit).insert(store(2404)); + crate::net::apply_fields_for_test(app.world_mut(), unit, fields(2404)); app.update(); assert!(puffs(&mut app).is_empty(), "no re-puff while just riding"); // A swap (N → N′) is a change, and the reference rebuilds and puffs again. - app.world_mut().entity_mut(unit).insert(store(2405)); + crate::net::apply_fields_for_test(app.world_mut(), unit, fields(2405)); app.update(); assert_eq!( puffs(&mut app), @@ -1234,7 +1252,8 @@ fn a_kit_with_a_chain_char_proc_asks_for_a_beam_from_both_dispatcher_sites() { let channeller = app .world_mut() .spawn(crate::net::ObjectStore( - benilla_protocol::ObjectFields::from_pairs(&[(144, BEAM_SPELL)]), + benilla_protocol::ObjectFields::from_pairs(&[(144, BEAM_SPELL)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), )) .id(); app.update(); @@ -1351,6 +1370,7 @@ fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -1523,11 +1543,12 @@ fn a_shooter_with_no_ranged_weapon_resolves_no_clip_at_all() { return; }; // Strip the equipment field — the wire's "nothing in slot 17". - app.world_mut() - .entity_mut(unit) - .insert(crate::net::ObjectStore( - benilla_protocol::ObjectFields::from_pairs(&[(VISIBLE_RANGED_ENTRY_FIELD, 0)]), - )); + crate::net::apply_fields_for_test( + app.world_mut(), + unit, + benilla_protocol::ObjectFields::from_pairs(&[(VISIBLE_RANGED_ENTRY_FIELD, 0)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + ); app.world_mut() .write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Start)); app.world_mut() @@ -1580,6 +1601,7 @@ fn a_state_kits_anim_is_a_recompute_and_never_a_second_play() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -1678,6 +1700,7 @@ fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::() + .add_message::() .add_message::() .add_message::() .add_message::(); @@ -1711,10 +1734,10 @@ fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() { let unit = app .world_mut() - .spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[ - (47, SPELL), - (95, 0x0E), - ]))) + .spawn(crate::net::ObjectStore( + ObjectFields::from_pairs(&[(47, SPELL), (95, 0x0E)]) + .into_created(benilla_protocol::messages::ObjectType::Unit), + )) .id(); app.update(); diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index a972cdfb..9fb0ce34 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -739,7 +739,23 @@ pub(crate) mod schedule_tests { ("UiErrorKeys", TypeId::of::), ("UiErrorTexts", TypeId::of::), ]; - const STREAMS: &[ClassRow] = &[("SoundKits", TypeId::of::)]; + const STREAMS: &[ClassRow] = &[ + ("SoundKits", TypeId::of::), + // The client's ONE `rand()` stream (decision 2301). Four lanes draw from it — the + // placed-doodad host, the creature driver, the GameObject arm and the portrait booth — + // and in the reference they draw from one TLS cell in whatever order the frame runs + // them. The interleaving IS the mechanism: a shared sequence is what de-syncs a stand + // of identical props, and no consumer can observe which draw it got, only that it got + // a fresh one. So an undeclared order here is not a missing `.after`; declaring one + // would be inventing a determinism the reference does not have. + // + // It reduces the count by **nothing** today, and that is not an oversight: the three + // Update-side lanes already conflict on `Query<&mut AnimationPlayer>`, which no class + // explains, so every pair this row would cover is counted for that instead. It is the + // standing claim about the resource — what keeps these pairs from surfacing the day + // that other conflict is declared — not a saving. + ("AnimRng", TypeId::of::), + ]; /// Resolve the table against a world whose schedules have initialized (every param /// has registered its resource by then). A row that resolves to nothing is a stale @@ -863,10 +879,22 @@ pub(crate) mod schedule_tests { /// boundary case decided early is decided again next frame. The target scanner carries the /// same class for the same reason. /// + /// **3,291 (decision 2300)** — the glue create/main-menu scene's material lane: exactly **one** + /// new pair, `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene` + /// over `MatAnimTable`, measured pair by pair on the tree that lands rather than assumed + /// additive (the scene builder's other three new resources add none — it already held + /// `RigPalettes` and `Assets`, so the systems they newly meet were already + /// ambiguous against it). Immaterial by construction, and narrowly: `MatAnimTable` is a slot + /// **allocator**, one owner per slot. A tile reaping its dead rows and the scene claiming + /// fresh ones touch disjoint slots and neither reads the other's; the only thing the order + /// decides is *which* free slot the scene is handed, and a slot number is not observable — + /// the row behind it is, and it is written by whoever owns it. A scene is also built **once**, + /// the frame its model asset lands, against a reaper that fires only when a tile dies. + /// /// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with /// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is /// about a resource that commutes by construction, the claim belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 3_290; + const UPDATE_ACTIONABLE_CEILING: usize = 3_291; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/go_anim.rs b/crates/benilla-app/src/go_anim.rs index 6d91208f..d1e92739 100644 --- a/crates/benilla-app/src/go_anim.rs +++ b/crates/benilla-app/src/go_anim.rs @@ -72,7 +72,7 @@ use bevy::animation::RepeatAnimation; use bevy::prelude::*; use std::time::Duration; -use crate::creature_anim::{advance_track, scan_events, select, AnimSoundEvent}; +use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent}; use crate::net::{GuidIndex, ObjectStore}; use benilla_world::model_fade::DespawnFade; use benilla_world::schedule::WorldStage; @@ -102,10 +102,6 @@ pub(crate) struct GoAnim { /// leaving (first sight settles the resting pose **silently** — a door that streams in already open /// must not replay its swing). shown: Option, - /// The last `GAMEOBJECT_STATE` seen on the wire, so [`sync_wire_go_state`] writes `state` only on a - /// *genuine* wire change — a chest's wire state is constant (its lid is driven by loot events, not the - /// wire), so an unrelated values-update (a dyn-flag, a position) must not re-close an open lid. - last_wire: Option, /// A pending **one-shot** play, as an `AnimationData.dbc` id — the second, disjoint arm /// channel of wow-re `gameobject-anim-arm.md` §2c, never the §243 lid family. The reference /// has ONE such slot fed by ONE entry point (`0x5f8c50(GO, code)` → slot 15), whose 7-entry @@ -437,27 +433,36 @@ fn resolve(prev: Option, cur: u32) -> Option { /// Caller 1 (the wire, §243): track each animated GO's `GAMEOBJECT_STATE` from the wire, acting only on a /// *genuine* wire change. This is the door/button driver (the server flips their state over the wire) and -/// the first-sight rest-pose seed for every animated GO (a chest streams in closed). Runs on the seed -/// (`Added`, when attach tags the entity) and on any later descriptor delta; the `last_wire` -/// guard makes an unrelated field change (position, dyn-flags) a no-op, so a chest whose wire state is -/// constant is never re-closed by one — its lid is owned by the loot callers below. +/// the first-sight rest-pose seed for every animated GO (a chest streams in closed). Two inputs, both +/// exact (decision 2297): the seed (`Added`, when attach tags the entity — read off the store), +/// and the field EDGE on `GAMEOBJECT_STATE` for any later delta. An unrelated field change (position, +/// dyn-flags) is not an edge on this dword, so a chest whose wire state is constant is never re-closed by +/// one — its lid is owned by the loot callers below. That used to need a `last_wire` shadow on the +/// component; the edge carries the old value itself. #[allow(clippy::type_complexity)] fn sync_wire_go_state( - mut gos: Query<(&ObjectStore, &mut GoAnim), Or<(Changed, Added)>>, + mut edges: MessageReader, + // The seed leg and the edge leg both write `GoAnim`; one `ParamSet`, two disjoint views. + mut gos: ParamSet<( + Query<(&ObjectStore, &mut GoAnim), Added>, + Query<&mut GoAnim>, + )>, ) { - for (store, mut anim) in &mut gos { + for (store, mut anim) in &mut gos.p0() { // Absent ⇒ the wire default `0` = ACTIVE (decision 0757) — vmangos omits zero fields, so a // door that spawns OPEN sends none. Reading that as "unknown" left `state` at `None`, and - // the object rested at its loader pose instead of Opened. The `last_wire` guard is - // unaffected: a constant wire state (a chest's `Some(1)`) still compares equal frame to - // frame, so the client-predicted lid is still never re-closed by an unrelated delta. - let wire = Some(store.0.gameobject_state().unwrap_or(GO_STATE_ACTIVE)); - if wire == anim.last_wire { - continue; // an unrelated field changed (position, flags, dyn-flags) — not our transition + // the object rested at its loader pose instead of Opened. + anim.state = Some(store.0.gameobject_state().unwrap_or(GO_STATE_ACTIVE)); + } + let mut live = gos.p1(); + for e in edges.read() { + if e.kind != benilla_protocol::messages::ObjectType::GameObject + || e.index != benilla_protocol::field::FIELD_GAMEOBJECT_STATE + { + continue; } - anim.last_wire = wire; - if let Some(s) = wire { - anim.state = Some(s); + if let Ok(mut anim) = live.get_mut(e.entity) { + anim.state = Some(e.new); } } } @@ -716,6 +721,43 @@ fn retire_transient_anim( } } +/// **The GameObject arm's instrument** (`WOW_MOVE_TRACE_TAGS=goa`) — one line per arm, carrying +/// everything the roll decided: the requested `AnimationData.dbc` id, what the §2c remap turned it +/// into, the `_rand` draw itself, and the **file sequence slot** the weighted walk landed on. +/// +/// That last column is the one this exists for. A GameObject's variation chain is not cosmetic: +/// the per-sequence material lanes (2295) key their UV and tint loops by `seq_index`, so *which +/// take is armed* is what decides whether a Blood of Heroes pool bubbles at all (slot 0 authors no +/// texture transform, slot 1 does) and whether an Onyxia lava trap spurts. From outside, an arm +/// that re-rolls every window and one that armed once and stalled look identical — both are "a +/// still pool" for as long as you happen to watch — and the three legs that produce the difference +/// (`0x5f39fa`'s already-armed skip, the collapse-to-0 leg that jumps past it, the completion that +/// re-enters through slot 14) are invisible in a screenshot. One line per arm makes the cycle, +/// its period and its distribution readable directly. +fn trace_arm( + entity: Entity, + lane: &str, + requested: u16, + want: u16, + frozen: bool, + roll: u16, + clip: &benilla_assets::AnimClip, +) { + if !benilla_assets::trace::enabled_for("goa") { + return; + } + benilla_assets::trace::line( + "goa", + &format!( + "{lane} e={entity} id={requested}->{want}{} roll={roll} seq={} freq={} dur={:.3}", + if frozen { " frozen" } else { "" }, + clip.seq_index, + clip.frequency, + clip.duration, + ), + ); +} + /// Play the §243 sequence for a change of the client-side [`GoAnim::state`] (written by any of the three /// callers). Mirrors the state-transition detection of [`crate::sound::gameobject`] (first sight silent), /// but points it at the model instead of the mixer — one system owns the visual, the other the audio. @@ -733,7 +775,7 @@ fn drive_go_anim( mut dispatch: MessageWriter, // The client's single `_rand` stream (wow-re `rf36-rand-stub.md`), as `creature_anim` keeps // one: op4's variation roll draws from it on every GameObject arm below. - mut rng: Local, + mut rng: ResMut, ) { for (entity, mut go, mut player, mut tr, anims) in &mut gos { // ── The §243 state arm ───────────────────────────────────────────────────────────────── @@ -784,7 +826,9 @@ fn drive_go_anim( if skip { continue; } - if let Some(clip) = anims.pick_variation(want, select::msvc_rand(&mut rng)) { + let roll = rng.draw(); + if let Some(clip) = anims.pick_variation(want, roll) { + trace_arm(entity, "state", play.anim_id(), want, frozen, roll, clip); go.armed_id = Some(want); // Snap a rest pose (blend 0 — a stream-in must not swing); ease a motion // over its authored blend. @@ -858,7 +902,9 @@ fn drive_go_anim( // Slot 15 funnels through the same slot-34 → `0x5f3930` arm as the state channel // (§2c), so it rolls a variation for the same reason — a Custom0 with two authored // takes alternates them. - if let Some(clip) = anims.pick_variation(id, select::msvc_rand(&mut rng)) { + let roll = rng.draw(); + if let Some(clip) = anims.pick_variation(id, roll) { + trace_arm(entity, "custom", id, id, false, roll, clip); go.armed_id = Some(id); go.rest_window = None; let node = clip.node; @@ -884,11 +930,11 @@ fn drive_go_anim( /// scoped to the door/button types ([`collision_follows_state`]); a chest keeps its collider whatever its /// state. /// -/// **Reconciles on an `ObjectStore` change OR on the collider's arrival** (idempotent either way). +/// **Reconciles on a `GAMEOBJECT_STATE` edge OR on the collider's arrival** (idempotent either way). /// The `Added` half is load-bearing, not belt-and-braces (decision 0763): a streamed /// GameObject's descriptor lands with the create block, but its `Collider` is baked from the M2 /// hull and inserted by `entities::attach` only when the **asset finishes loading**, frames later. -/// Watching `Changed` alone meant the two conditions were never true in the same frame +/// Watching the descriptor alone meant the two conditions were never true in the same frame /// for a freshly streamed object — the descriptor changed while there was no collider, the collider /// arrived while the descriptor was quiet — so the reconcile never ran at all, and every door kept /// the enabled collider it was born with. A *closed* door is solid by default and looked correct; @@ -896,17 +942,24 @@ fn drive_go_anim( /// reporter's workaround exactly: toggling the object from the GM panel changes the descriptor /// *after* the collider exists, so the reconcile finally fires. /// -/// Same shape as [`sync_wire_go_state`]'s `Or<(Changed, Added)>` — the -/// seed-plus-delta pair this file already uses for every other state consumer. -#[allow(clippy::type_complexity)] +/// The descriptor half is the field edge on the one dword this reads (decision 2297) — the same +/// seed-plus-edge pair [`sync_wire_go_state`] uses. fn drive_go_collision( mut commands: Commands, - gos: Query< - (Entity, &ObjectStore, Has), - (With, Or<(Changed, Added)>), - >, + gos: Query<(&ObjectStore, Has), With>, + arrived: Query>, + mut edges: MessageReader, ) { - for (entity, store, disabled) in &gos { + let state_edges = edges.read().filter(|e| { + e.kind == benilla_protocol::messages::ObjectType::GameObject + && e.index == benilla_protocol::field::FIELD_GAMEOBJECT_STATE + }); + let mut due: bevy::ecs::entity::EntityHashSet = arrived.iter().collect(); + due.extend(state_edges.map(|e| e.entity)); + for entity in due { + let Ok((store, disabled)) = gos.get(entity) else { + continue; + }; if !collision_follows_state(store.0.gameobject_type_id()) { continue; } @@ -1205,6 +1258,8 @@ mod tests { )); app.init_resource::