the create screen's clouds move, and every battleground is entered

- the character-create and main-menu backdrop runs its material animation: the clouds drift on their 20, 30 and 40 second loops, which were seeded once and never sampled (2300)
- Arathi Basin and Alterac Valley are entered and censused beside Warsong Gulch, the flag is picked up, and a match runs to its end (2296)
- the animation variation roll is one clock-seeded stream, as the reference's C runtime is, not four streams seeded 0 (2299, 2301)
- a cancelled channel never turns the bar red and a finished one closes on the bar's own clock, now pinned by tests that drive the real packet sequence (2284)
- the field watch fires one edge per moved descriptor dword, from the merge that holds both sides (2297)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:44:40 +07:00
co-authored by Claude Fable 5.1
parent b85bc81a03
commit befda9da54
44 changed files with 1674 additions and 627 deletions
+92 -54
View File
@@ -244,6 +244,7 @@ fn app_with_chains() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<crate::creature_anim::SpellKitFx>();
app.add_message::<crate::net::FieldChanged>();
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
app.add_message::<crate::creature_anim::BaseAnimRecompute>();
@@ -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::<AuraNodes>().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::<AuraNodes>().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::<AuraNodes>().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();
+61 -4
View File
@@ -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<WorldStates>,
dropped: Res<DroppedOpcodes>,
go_templates: Res<crate::go_templates::GameObjectTemplates>,
mut idle: ResMut<crate::ui_chat::idle::LastInput>,
mut script: Option<NonSendMut<UiScript>>,
) {
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::<String>(&format!(
"local f = getglobal(\"{name}\") if not f then return \"not-loaded\" end return tostring(f:IsShown())"
))
.unwrap_or_else(|e| format!("<raised: {e}>"));
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");
}
@@ -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<u32>,
mut rng: ResMut<benilla_assets::AnimRng>,
// 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<std::collections::HashMap<Entity, String>>,
@@ -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));
@@ -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,
@@ -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<bevy::animation::graph::AnimationNodeIndex>,
) -> Mode {
@@ -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::<benilla_assets::AnimRng>();
// 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.
@@ -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 {
@@ -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 {
@@ -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),
@@ -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<ObjectStore>>,
arrived: Query<Entity, Added<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut fx: MessageWriter<SpellKitFx>,
@@ -1346,7 +1356,17 @@ pub(crate) fn arm_aura_state_fx(
let scan = if full_sweep {
units.iter().collect::<Vec<_>>()
} else {
changed.iter().collect::<Vec<_>>()
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<ObjectStore>` 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<ObjectStore>>,
arrived: Query<Entity, Added<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut fx: MessageWriter<SpellKitFx>,
mut armed: Local<EntityHashSet>,
@@ -1472,7 +1495,24 @@ pub(super) fn arm_loot_fx(
let scan = if full_sweep {
units.iter().collect::<Vec<_>>()
} else {
changed.iter().collect::<Vec<_>>()
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<EntityHashMap>` 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<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut levels: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
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<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
visuals: Option<Res<SpellVisuals>>,
mut displays: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
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<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
spells: Option<Res<crate::ui_action::Spells>>,
mut latch: ResMut<MorphLatch>,
mut seen: Local<EntityHashMap<Vec<u32>>>,
) {
for (entity, store) in &changed {
let mut cur: Vec<u32> = 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
@@ -47,6 +47,7 @@ fn app() -> App {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -210,6 +211,7 @@ fn precast_kit_sound_rings_once_at_start() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -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::<SpellKitFx>();
app.add_message::<crate::net::FieldChanged>();
// 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::<FxLog>().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::<FxLog>().0;
@@ -639,6 +649,7 @@ fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -747,6 +758,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -861,6 +873,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin
.add_message::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -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::<SpellKitFx>();
app.add_message::<crate::net::FieldChanged>();
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<super::FxSlot> {
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::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -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::<crate::weapon_trail::TrailArm>()
.add_message::<super::BaseAnimRecompute>()
.add_message::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
@@ -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::<SpellKitFx>()
.add_message::<crate::net::FieldChanged>()
.add_message::<crate::aura_visual::AuraProc>()
.add_message::<SpellKitSound>()
.add_message::<super::BaseAnimRecompute>();
@@ -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();
+30 -2
View File
@@ -739,7 +739,23 @@ pub(crate) mod schedule_tests {
("UiErrorKeys", TypeId::of::<crate::ui_action::UiErrorKeys>),
("UiErrorTexts", TypeId::of::<crate::ui_action::UiErrorTexts>),
];
const STREAMS: &[ClassRow] = &[("SoundKits", TypeId::of::<crate::sound::SoundKits>)];
const STREAMS: &[ClassRow] = &[
("SoundKits", TypeId::of::<crate::sound::SoundKits>),
// 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::<benilla_assets::AnimRng>),
];
/// 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<WowModelMaterial>`, 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) {
+88 -33
View File
@@ -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<u32>,
/// 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<u32>,
/// 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<u32>, cur: u32) -> Option<Play> {
/// 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<GoAnim>`, 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<GoAnim>`, 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<ObjectStore>, Added<GoAnim>)>>,
mut edges: MessageReader<crate::net::FieldChanged>,
// The seed leg and the edge leg both write `GoAnim`; one `ParamSet`, two disjoint views.
mut gos: ParamSet<(
Query<(&ObjectStore, &mut GoAnim), Added<GoAnim>>,
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<GoStateDispatch>,
// 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<u32>,
mut rng: ResMut<benilla_assets::AnimRng>,
) {
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<Collider>` 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<ObjectStore>` 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<ObjectStore>, Added<GoAnim>)>` — 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<ColliderDisabled>),
(With<Collider>, Or<(Changed<ObjectStore>, Added<Collider>)>),
>,
gos: Query<(&ObjectStore, Has<ColliderDisabled>), With<Collider>>,
arrived: Query<Entity, Added<Collider>>,
mut edges: MessageReader<crate::net::FieldChanged>,
) {
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::<Time>();
app.init_resource::<NextStep>();
// The client's ONE `rand()` stream (2301) — the arm rolls a variation off it.
app.init_resource::<benilla_assets::AnimRng>();
// The state dispatch's audio consumer lives in `sound::gameobject`; here it is recorded
// instead, so a test can assert *which* edges re-enter `0x5f3cb0`.
app.add_message::<GoStateDispatch>();
+185 -1
View File
@@ -14,7 +14,7 @@
//! The bridge runs in [`WorldStage::Net`], before `Input`, so a server teleport snaps → streams →
//! covers in one frame. Coordinates cross from raw WoW into Bevy space here (`wow_to_bevy`).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use benilla_protocol::{
@@ -117,6 +117,7 @@ impl Plugin for NetPlugin {
.init_resource::<crate::items::Items>()
.init_resource::<crate::world_state::WorldStates>()
.add_message::<TeleportMessage>()
.add_message::<FieldChanged>()
.add_message::<SelfMoveMessage>()
.add_message::<SpeedChangeMessage>()
.add_message::<ClientControlMessage>()
@@ -289,6 +290,124 @@ pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 {
#[derive(Component, Clone, Default)]
pub(crate) struct ObjectStore(pub(crate) ObjectFields);
/// **One descriptor dword moved on a streamed object** — the reference's `CMirrorHandler` edge
/// (decision 2297). The real client keeps a shadow copy of every object's field array, and the
/// values-apply notifier (`0x465330`) memcmps live against shadow over each registered span and
/// calls the watcher with the OLD value (`0x465570` hands the mirror pointer as `param3` —
/// wow-re `object-layer.md`); a first CREATE passes a notify-suppress flag and fires nothing, an
/// in-place re-create of a live guid goes down the values path and fires like any delta.
///
/// benilla emits the same edge once, from the drain's merge, which is the one place that holds
/// both sides: every consumer that used to poll `Changed<ObjectStore>` and re-derive "did THIS
/// field move" against its own `Local<EntityHashMap>` shadow reads these instead, with no
/// first-sight rule and no per-frame `retain` of its own (the create is never merged, so the
/// suppression is structural). Unwatched fields cost one `Vec` push each; the consumers filter
/// by `(kind, index)` through [`benilla_protocol::field`]'s named constants — the same constants
/// the store's accessors read through, so a watch and its accessor cannot disagree.
///
/// `kind` is the store's own class off its create block: a raw index means different things per
/// class (`36` is a unit's `BYTES_0` and a corpse's `DYNAMIC_FLAGS`), and a unit-field watcher
/// must accept both `Unit` and `Player` ([`Self::unit_field`]).
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FieldChanged {
pub entity: Entity,
pub guid: u64,
pub kind: benilla_protocol::messages::ObjectType,
pub index: u16,
pub old: u32,
pub new: u32,
}
impl FieldChanged {
/// A unit-block edge — units and players share the UNIT block, so a watch registered for
/// the reference's TYPEID 3 sees both here.
pub fn is_unit(&self) -> bool {
use benilla_protocol::messages::ObjectType;
matches!(self.kind, ObjectType::Unit | ObjectType::Player)
}
/// The edge on one named unit field.
pub fn unit_field(&self, index: u16) -> bool {
self.is_unit() && self.index == index
}
/// The edge's slot inside a unit-block array starting at `base` with `len` dwords — the aura
/// arrays' shape (`UNIT_FIELD_AURA[48]`, `AURAAPPLICATIONS[12]`).
pub fn unit_array_slot(&self, base: u16, len: u16) -> Option<u16> {
(self.is_unit() && (base..base + len).contains(&self.index)).then(|| self.index - base)
}
}
/// The field edges one feed saw this run, keyed the way the unit feeds ask — "did THIS guid's
/// field move" per token — so a per-token event rides the reference's own trigger (its notifier
/// fans a moved field out to every token naming the unit, `0x515e50`) instead of a snapshot diff
/// guarded by `prev.is_some()` (decision 2297). Collected once per run, off the reader.
#[derive(Default)]
pub(crate) struct FieldEdges(HashSet<(u64, u16)>);
impl FieldEdges {
pub fn collect(reader: &mut MessageReader<FieldChanged>) -> Self {
Self(reader.read().map(|e| (e.guid, e.index)).collect())
}
/// Did `guid`'s dword at `index` move this run?
pub fn moved(&self, guid: u64, index: u16) -> bool {
self.0.contains(&(guid, index))
}
#[cfg(test)]
pub fn of(edges: &[(u64, u16)]) -> Self {
Self(edges.iter().copied().collect())
}
}
/// Fold a descriptor update onto a live store and report its field edges — the drain's merge
/// body, shared with the test harness so a fixture drives a watcher through exactly the path
/// the wire does. A store that is not there yet is seeded silently (the first create).
pub(crate) fn merge_store_fields(
store: &mut ObjectFields,
delta: ObjectFields,
entity: Entity,
guid: u64,
mut emit: impl FnMut(FieldChanged),
) {
let Some(kind) = store.created_as() else {
// No create seen for this store (a defensively seeded bare delta): nothing the reference
// would have an object to notify on. The fields still land.
store.merge(delta);
return;
};
store.merge_diff(delta, |index, old, new| {
emit(FieldChanged {
entity,
guid,
kind,
index,
old,
new,
});
});
}
/// Apply a descriptor update to `entity` the way the drain does — seed on first sight (no
/// edges), merge and emit [`FieldChanged`] after — so a watcher test speaks the wire's language
/// instead of poking the component. The fixture must be [`ObjectFields::into_created`] on its
/// first application, like every real create block.
#[cfg(test)]
pub(crate) fn apply_fields_for_test(world: &mut World, entity: Entity, delta: ObjectFields) {
let guid = world.get::<Guid>(entity).map_or(0, |g| g.0);
let mut edges = Vec::new();
match world.get_mut::<ObjectStore>(entity) {
Some(mut store) => merge_store_fields(&mut store.0, delta, entity, guid, |e| edges.push(e)),
None => {
assert!(
delta.created_as().is_some(),
"a fixture's first descriptor is a create block: chain `.into_created(..)`"
);
world.entity_mut(entity).insert(ObjectStore(delta));
}
}
world
.resource_mut::<Messages<FieldChanged>>()
.write_batch(edges);
}
/// Marks our own player's streamed entity (guid == [`SelfGuid`]). This is **identity** — "my
/// character", the thing whose bags, auras, quest log and paper doll are mine. It is deliberately
/// *not* "the thing I steer": that is [`Embodied`], which normally sits on the same entity and
@@ -2752,6 +2871,71 @@ pub(crate) struct PetDismissSoundMessage {
mod tests {
use super::*;
/// The field-edge contract at the one write that emits it (decision 2297), through the same
/// body the drain's merge calls: a first create seeds silently (the reference's create-time
/// notify-suppress), a values delta reports each moved dword with its old value and the
/// store's class, a value re-sent unchanged reports nothing, and an in-place re-create of a
/// live object notifies like any delta — including a field the fresh snapshot dropped.
#[test]
fn the_merge_emits_one_edge_per_moved_dword_and_none_for_a_first_create() {
use benilla_protocol::field::{FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL};
use benilla_protocol::messages::ObjectType;
let mut world = World::new();
world.init_resource::<Messages<FieldChanged>>();
let unit = world.spawn(Guid(0xF130_0000_0000_0042)).id();
let drain = |world: &mut World| -> Vec<FieldChanged> {
world
.resource_mut::<Messages<FieldChanged>>()
.drain()
.collect()
};
// The create block: seeds, says nothing.
apply_fields_for_test(
&mut world,
unit,
ObjectFields::from_pairs(&[(FIELD_UNIT_HEALTH, 100), (FIELD_UNIT_LEVEL, 9)])
.into_created(ObjectType::Unit),
);
assert!(drain(&mut world).is_empty(), "a create is not an edge");
// A values delta: the ding's dword and the health's, the level re-sent unchanged.
apply_fields_for_test(
&mut world,
unit,
ObjectFields::from_pairs(&[(FIELD_UNIT_LEVEL, 9), (FIELD_UNIT_HEALTH, 0)]),
);
assert_eq!(
drain(&mut world),
vec![FieldChanged {
entity: unit,
guid: 0xF130_0000_0000_0042,
kind: ObjectType::Unit,
index: FIELD_UNIT_HEALTH,
old: 100,
new: 0,
}],
"one edge for the dword that moved, carrying the old value; the resend is silent"
);
// A re-create of the live guid: the reference's in-place refresh notifies, and the
// replace makes the dropped health an explicit `0 → 0`... no: it was 0 already, so the
// level's rise is the only edge.
apply_fields_for_test(
&mut world,
unit,
ObjectFields::from_pairs(&[(FIELD_UNIT_LEVEL, 10)]).into_created(ObjectType::Unit),
);
let edges = drain(&mut world);
assert_eq!(edges.len(), 1);
assert!(edges[0].unit_field(FIELD_UNIT_LEVEL) && edges[0].old == 9 && edges[0].new == 10);
assert_eq!(
world.get::<ObjectStore>(unit).unwrap().0.unit_level(),
Some(10)
);
}
/// **The kick sends the player back to the login screen** — decision 1262's whole promise,
/// pinned at the one place that decides it, in the environment the director actually plays in.
///
+11 -1
View File
@@ -121,6 +121,7 @@ pub(crate) fn apply_net_updates(
transports,
casting: casting_units,
engaged_self,
mut field_changes,
} = objects;
let ActionStores {
mut player_actions,
@@ -417,6 +418,7 @@ pub(crate) fn apply_net_updates(
&mut transforms,
&mut stores,
&mut pending,
&mut field_changes,
&mut speed_stage,
&names,
&go_templates,
@@ -498,7 +500,15 @@ pub(crate) fn apply_net_updates(
// reclaim latch is re-asked on that edge, as the reference's `FLAGS` mirror
// handler `0x5d6d60` does (1729).
death::recheck_corpse(guid, &fields, &self_guid, &mut death_net);
objects::object_values(guid, fields, &index, &mut stores, &mut pending, &mut items)
objects::object_values(
guid,
fields,
&index,
&mut stores,
&mut pending,
&mut field_changes,
&mut items,
)
}
SessionEvent::ObjectDestroyed(guid) => {
death::forget_corpse(guid, &mut death_net);
+20 -7
View File
@@ -23,8 +23,8 @@ use super::super::motion::{
trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped, ROOT_APPLY_WIPE,
};
use super::super::{
Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, RemoteMotion, SelfGuid,
SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds,
merge_store_fields, FieldChanged, Guid, GuidIndex, NetCommands, NetEntity, ObjectStore,
RemoteMotion, SelfGuid, SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds,
};
/// A GameObject plays its one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision
@@ -81,6 +81,7 @@ pub(super) fn object_create(
transforms: &mut Query<&mut Transform>,
stores: &mut Query<&mut ObjectStore>,
pending: &mut HashMap<u64, ObjectFields>,
edges: &mut MessageWriter<FieldChanged>,
speed_stage: &mut SpeedStage,
names: &NameCache,
go_templates: &GameObjectTemplates,
@@ -242,8 +243,9 @@ pub(super) fn object_create(
}
}
write_pose(commands, transforms, e, position, placement);
// Overlay the fresh snapshot's descriptor fields onto the existing store.
merge_fields(stores, pending, e, guid, fields);
// Overlay the fresh snapshot's descriptor fields onto the existing store — the reference's
// in-place refresh of a live guid, which notifies its field watchers like any delta.
merge_fields(stores, pending, edges, e, guid, fields);
} else {
// A transport spawns hidden: its create pose is the *stationary* spawn point (or worse,
// the origin), not where the boat is in its cycle — the transport tick unhides it at the
@@ -440,10 +442,11 @@ pub(super) fn object_values(
index: &GuidIndex,
stores: &mut Query<&mut ObjectStore>,
pending: &mut HashMap<u64, ObjectFields>,
edges: &mut MessageWriter<FieldChanged>,
items: &mut Items,
) {
if let Some(&e) = index.0.get(&guid) {
merge_fields(stores, pending, e, guid, fields);
merge_fields(stores, pending, edges, e, guid, fields);
} else if guid::is_item(guid) {
items.merge_object(guid, fields);
}
@@ -814,17 +817,27 @@ pub(super) fn gameobject_info(
/// created earlier this same drain (its spawn `Command` hasn't run, so it isn't queryable yet), else in
/// place on the live component. The final `else` — in the index but neither live nor pending — should not
/// happen (a create always seeds `pending` first), but seeds defensively rather than drop the delta.
///
/// Every merge reports its field edges ([`FieldChanged`], decision 2297) — into the pending seed
/// too: a create and a values delta for the same guid in one drain are two wire blocks, and the
/// reference notifies on the second. The seed itself is never merged, which is the reference's
/// create-time notify-suppress.
fn merge_fields(
stores: &mut Query<&mut ObjectStore>,
pending: &mut HashMap<u64, ObjectFields>,
edges: &mut MessageWriter<FieldChanged>,
entity: Entity,
guid: u64,
delta: ObjectFields,
) {
if let Some(f) = pending.get_mut(&guid) {
f.merge(delta);
merge_store_fields(f, delta, entity, guid, |e| {
edges.write(e);
});
} else if let Ok(mut s) = stores.get_mut(entity) {
s.0.merge(delta);
merge_store_fields(&mut s.0, delta, entity, guid, |e| {
edges.write(e);
});
} else {
pending.insert(guid, delta);
}
@@ -73,6 +73,9 @@ pub(crate) struct ObjectQueries<'w, 's> {
/// [`crate::creature_anim::Engaged`] — read by the GO handler's deferred auto-attack start
/// (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with nothing else in the drain.
pub engaged_self: Query<'w, 's, (), (With<crate::creature_anim::Engaged>, With<SelfPlayer>)>,
/// The per-field descriptor edges the merge reports (decision 2297) — the reference's
/// `CMirrorHandler` notify pass, emitted from the one write that holds both sides.
pub field_changes: MessageWriter<'w, super::super::FieldChanged>,
}
/// The session lifecycle: the glue-screen edges, the player's own teleports and mover
+120
View File
@@ -1472,6 +1472,126 @@ mod tests {
assert!(matches!(feed[0], CastBarEdge::Stop), "…with a STOP");
}
/// **A broken channel flashes green and fades — it never goes red "Interrupted"**, and the
/// whole of `Spell::cancel()`'s wire is driven here because the correctness rests on a gate
/// that does not mention channels.
///
/// vmangos cancels a running channel (moved, turned, target died, `CMSG_CANCEL_CHANNELLING`)
/// with **two** packets, in this order (`Spell.cpp` `Spell::cancel`, `SPELL_STATE_CASTING`):
///
/// ```text
/// SendChannelUpdate(0, true) -> MSG_CHANNEL_UPDATE(0) -> SPELLCAST_CHANNEL_STOP
/// SendInterrupted(0) -> SMSG_SPELL_FAILED_OTHER -> ??? (SendObjectMessageToSet
/// (…, /*self*/ true))
/// ```
///
/// The second one **reaches the caster too**, so the obvious reading is that the bar turns red
/// a frame after it flashed green. It must not, and three independent things say so:
///
/// - vmangos deliberately withholds the *other* interrupt signal —
/// `sendInterrupt = !(m_channeled && m_spellState != SPELL_STATE_PREPARING)` gates
/// `SendCastResult(SPELL_FAILED_INTERRUPTED)` off, with the comment "channeled spells don't
/// display interrupted message even if they are interrupted";
/// - the reference's own `SMSG_SPELL_FAILED_OTHER` handler (`0x6e8e40`, opcode `0x2a6`) fires
/// **no FrameScript event at all** — its whole body is `0x60d040` + `0x614150`, the *unit's*
/// cast state, and wow-re's boundary line for it lists no `-> ui` edge;
/// - and stock `CastingBarFrame.lua` guards its red arm with `not this.channeling`.
///
/// Ours lands on the same behaviour by a **fourth** route, which is the one worth pinning:
/// [`spell_failed_other`]'s red edge is keyed to a live `Casting` component, and a channel
/// never has one — `Casting` is inserted only for `cast_time_ms > 0`, and `SMSG_SPELL_GO`
/// (which always precedes `MSG_CHANNEL_START` in `handle_immediate`) removes it regardless.
/// That is correct and it is also invisible: nothing in `spell_failed_other` says "channel",
/// so a future change to when `Casting` is armed would turn every broken channel red with no
/// test to catch it. This is that test.
#[test]
fn a_cancelled_channel_never_turns_the_bar_red() {
use crate::creature_anim::Casting;
use crate::net::{Guid, SelfPlayer};
use bevy::ecs::system::RunSystemOnce;
const BLIZZARD: u32 = 10;
let t0 = Instant::now();
let mut app = App::new();
app.add_message::<CastEvent>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<ActiveChannel>();
let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(10, self_e);
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
// 1-2. MSG_CHANNEL_START, then the server's own end: MSG_CHANNEL_UPDATE(0).
{
let world = app.world_mut();
let mut feed = world.remove_resource::<CastBarFeed>().unwrap();
let mut channel = world.remove_resource::<ActiveChannel>().unwrap();
channel_start(BLIZZARD, 8_000, &mut channel, &mut feed);
assert_eq!(
channel.current(t0 + Duration::from_secs(1)),
Some(BLIZZARD),
"the mirror is armed while the channel runs"
);
channel_update(0, &mut channel, &mut feed);
assert_eq!(
channel.current(t0 + Duration::from_secs(1)),
None,
"update 0 closes the mirror, so the action button unlights"
);
world.insert_resource(feed);
world.insert_resource(channel);
}
// 3. SendInterrupted(0) — SMSG_SPELL_FAILED_OTHER, addressed to us, for the channel's id.
app.world_mut()
.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
self_guid: Res<SelfGuid>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>| {
spell_failed_other(
10,
BLIZZARD,
&mut commands,
&index,
&casting,
&mut cast_events,
&self_guid,
&mut cast_bar,
&mut pending,
&mut queued_melee,
1,
);
},
)
.unwrap();
let feed = &app.world().resource::<CastBarFeed>().0;
assert!(
!feed
.iter()
.any(|e| matches!(e, CastBarEdge::Interrupted | CastBarEdge::Failed)),
"a cancelled channel pushes NO red edge — the bar flashes green and fades"
);
assert_eq!(feed.len(), 2, "exactly the two channel edges");
assert!(matches!(feed[0], CastBarEdge::ChannelStart { .. }));
assert!(matches!(
feed[1],
CastBarEdge::ChannelUpdate { remaining_ms: 0 }
));
}
/// **The GO's inline miss word: gold, gated, and only for an INSTANT spell** (decision 2229).
///
/// Phase 2 shipped this emit unconditional and hardcoded white. The §5 closed both halves:
+37 -10
View File
@@ -36,6 +36,30 @@ pub(super) struct BoothPart {
/// the char composite's quint); the booth relights them beside the steady one and hands them
/// here. `None` for a batch that cannot feather at all — the reference's own twin gate.
pub(super) twins: BoothTwins,
/// Whether this batch's material was put on the **UV / tint lane** by the builder that made it
/// — i.e. whether [`benilla_world::doodad_anim::AnimMatPart`] belongs on the spawned child.
///
/// **A statement about what was registered, never a re-test of the asset** (decision 2295's
/// lesson, 2038's bug class before it): the marker and the registration have to be one fact,
/// or the draw gate marks batches nothing samples — or samples batches it never marks, which
/// froze every waterfall in the game for three days.
///
/// `false` for every booth but the **glue create / main-menu scene**. A portrait or
/// dressing-room bake is a still by design (0130's bake law — it photographs one instant);
/// the create screen is a live render, and the reference animates a texture transform inside
/// the per-model-per-frame animate kernel (`0x715f25`-`0x7163bc` over `md+0x74`, wow-re
/// `modelframe-texanim-and-sequence-law.md` §3.1), which is not a lane a host opts into.
pub(super) mat_anim: bool,
}
/// Put the draw gate's marker on a booth child when — and only when — its material went onto the
/// UV / tint lane ([`BoothPart::mat_anim`]). `tick_anim_materials` writes a registered row only for
/// a material some marked, visible part draws (decision 1375), so a registered booth batch that
/// nobody marks stays frozen at its seed with a live table row behind it.
fn mark_mat_anim(child: &mut bevy::ecs::system::EntityCommands<'_>, p: &BoothPart) {
if p.mat_anim {
child.insert(benilla_world::doodad_anim::AnimMatPart);
}
}
/// A batch's translucency twins as the bake sees them — see [`BoothPart::twins`]. Default (both
@@ -540,6 +564,7 @@ pub(super) fn spawn_booth_model(
layer.clone(),
ChildOf(root),
));
mark_mat_anim(&mut child, p);
// The authored material alpha reaches the boneless bake too — it is a property of the
// batch, not of the rig. The INSTANCE alpha multiplies into it, the reference's
// `model+0x180 × the batch factor` (`0x707680`).
@@ -644,6 +669,7 @@ pub(super) fn spawn_booth_model(
layer.clone(),
ChildOf(root),
));
mark_mat_anim(&mut child, p);
// The batch's authored material alpha, sampled onto the render-alpha tag field — the same
// `MatAnim` the world lanes use, in its self-driving form (`drives_tag`: nothing else writes
// a booth part's alpha). The rig field rides the same tag, so compose rather than overwrite;
@@ -866,7 +892,7 @@ fn arm_turn(
player: &mut AnimationPlayer,
anims: &benilla_assets::ModelAnimations,
catalog: &benilla_formats::AnimDataCatalog,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
turn: &mut super::Turn,
id: u16,
now: f64,
@@ -881,8 +907,7 @@ fn arm_turn(
if res.id != id {
return false;
}
let Some(clip) = anims.pick_variation(res.id, crate::creature_anim::select::msvc_rand(rng))
else {
let Some(clip) = anims.pick_variation(res.id, rng.draw()) else {
return false;
};
let (node, looping, blend) = (clip.node, clip.looping, clip.blend_time.max(0.0));
@@ -983,7 +1008,7 @@ pub(super) fn drive_booth_turn(
// 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>,
mut rng: ResMut<benilla_assets::AnimRng>,
) {
let Some(anim_data) = anim_data.as_deref() else {
return;
@@ -1016,7 +1041,7 @@ fn step_turn(
player: &mut AnimationPlayer,
anims: &benilla_assets::ModelAnimations,
catalog: &benilla_formats::AnimDataCatalog,
rng: &mut u32,
rng: &mut benilla_assets::AnimRng,
turn: &mut super::Turn,
now: f64,
) {
@@ -1426,6 +1451,7 @@ mod tests {
material: Handle::default(),
alpha_anim: None,
twins: BoothTwins::default(),
mat_anim: false,
},
BoothPart {
skinned: None,
@@ -1433,6 +1459,7 @@ mod tests {
material: Handle::default(),
alpha_anim: None,
twins: BoothTwins::default(),
mat_anim: false,
},
];
@@ -1652,7 +1679,7 @@ mod tests {
turning_model(),
benilla_formats::AnimDataCatalog::from_rows([]),
);
let (mut player, mut rng) = (baked(), 0u32);
let (mut player, mut rng) = (baked(), benilla_assets::AnimRng::default());
let mut turn = super::super::Turn {
// Arrow down. The shuffle is armed but contributes NOTHING yet.
spun: Some(SHUFFLE_LEFT),
@@ -1729,7 +1756,7 @@ mod tests {
);
// Reverse at 0.1 s of a 0.25 s blend — 60% still to run, λ = 0.648. REFUSED.
let (mut player, mut rng) = (baked(), 0u32);
let (mut player, mut rng) = (baked(), benilla_assets::AnimRng::default());
let mut turn = super::super::Turn {
spun: Some(SHUFFLE_LEFT),
..Default::default()
@@ -1756,7 +1783,7 @@ mod tests {
assert_eq!(turn.shuffle.map(|(id, _)| id), Some(SHUFFLE_RIGHT));
// Reverse at 0.2 s instead — 20% still to run, λ = 0.104. The slot is taken.
let (mut player, mut rng) = (baked(), 0u32);
let (mut player, mut rng) = (baked(), benilla_assets::AnimRng::default());
let mut turn = super::super::Turn {
spun: Some(SHUFFLE_LEFT),
..Default::default()
@@ -1784,7 +1811,7 @@ mod tests {
turning_model(),
benilla_formats::AnimDataCatalog::from_rows([]),
);
let (mut player, mut rng) = (baked(), 0u32);
let (mut player, mut rng) = (baked(), benilla_assets::AnimRng::default());
let mut turn = super::super::Turn {
spun: Some(SHUFFLE_LEFT),
..Default::default()
@@ -1833,7 +1860,7 @@ mod tests {
usize::from(SHUFFLE_RIGHT) + 1
];
let catalog = benilla_formats::AnimDataCatalog::from_rows([]);
let (mut player, mut rng) = (baked(), 0u32);
let (mut player, mut rng) = (baked(), benilla_assets::AnimRng::default());
let mut turn = super::super::Turn::default();
for t in [1.0, 1.1, 1.2, 2.0] {
turn.spun = Some(SHUFFLE_LEFT);
@@ -266,6 +266,7 @@ pub(super) fn sync_dressup_booth(
// `None` — the same known gap as the glue preview's (decision 0807).
alpha_anim: None,
twins: BoothTwins::default(),
mat_anim: false,
})
.collect();
let booth_riders: Vec<BoothRider> = bake
+81 -1
View File
@@ -862,13 +862,30 @@ pub(super) fn sync_glue_scene(
ResMut<benilla_world::model_forms::ModelForms>,
ResMut<Assets<Mesh>>,
ResMut<benilla_world::instance_tint::InstanceTintMirrors>,
// …and the scene's **material-animation lane**: the UV and tint registries and the shared
// delta table its samples land in (decisions 1381/2295). Here rather than beside, for the
// same ceiling — the param list is already full.
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
ResMut<benilla_world::mat_anim_table::MatAnimMirrors>,
),
// The swap gate's two inputs ([`PendingSwap`]) — the character assembly this stage must land
// beside, and the real clock its bound is measured on. One tuple param: the 16-SystemParam
// ceiling, same reason as `particle_assets` above.
swap: (Res<GluePreviewBake>, Res<Time<bevy::time::Real>>),
) {
let (mut palettes, mut mirrors, mut forms, mut mesh_assets, mut tint_mirrors) = particle_assets;
let (
mut palettes,
mut mirrors,
mut forms,
mut mesh_assets,
mut tint_mirrors,
mut uv_reg,
mut tint_reg,
mut anim_table,
mut anim_mirrors,
) = particle_assets;
let (bake, real) = swap;
let Some(booth) = booths.0.get(GLUE_SLOT) else {
return;
@@ -1060,6 +1077,18 @@ pub(super) fn sync_glue_scene(
// is the one off-world buffer that carries it — see
// [`benilla_world::instance_tint::InstanceTintMirrors`] for why the portrait booths do not.
tint_mirrors.0.insert("glue_scene", light.clone());
// …and the **mat-anim delta table** beside them (decisions 1381/2023): the scene's
// materials sample `matanim[slot]` out of THIS buffer, so registering a batch's loop is
// only half the act — without the mirror the rows are written every frame into a region
// only the world's materials ever read, and the scene samples its zero-initialised
// identity. That is verbatim 2023's cooldown-sweep failure, and it is what an A/B of this
// screen with the lane frozen against the lane running showed: byte-identical images,
// with 2 of UI_Human's 29 batches registered and marked.
//
// The portrait booths stay off this list on purpose ([`MatAnimMirrors`]'s own note): a
// bake photographs one instant, and its studio buffer's zeroed region IS that instant.
// The create screen is not a bake.
anim_mirrors.0.insert("glue_scene", light.clone());
blob.write(&queue, &light);
scene.fog = fog;
scene.ghost = ghost;
@@ -1095,6 +1124,49 @@ pub(super) fn sync_glue_scene(
// and its street tied, and the tie re-broke every frame.
let order = u16::try_from(pi + 1).unwrap_or(u16::MAX);
let material = mats.off_world(s, s.texture.clone(), order, &light, true);
// **The scene's texture transforms and M2Color tint actually run** — the clouds
// drift, the water and lava creep, the fire sheets scroll. `off_world(rig: true)`
// has always *seeded* the UV at `sample(0.0)`, but seeding is not playing: nothing
// outside the world streamer, the entity dressing path and the effect lane had
// ever been registered, so every one of these froze on its first key while the
// comment above claimed they scrolled.
//
// Not a bake question, which is why it is decided here rather than deferred to
// 0130's bake law: a create/main-menu screen is a LIVE render, and the reference
// runs the texture transform inside the per-model-per-frame animate kernel
// (`0x715f25`-`0x7163bc` over the MD20 `+0x74` table, wow-re
// `modelframe-texanim-and-sequence-law.md` §3.1) — it is what an animated CM2Model
// does, not a lane a host opts into. The portrait and dressing-room bakes are the
// opposite case and stay still (`BoothPart::mat_anim`).
//
// **Shared lane only, and that is measured, not assumed** (`benilla-extract
// uvslotscan interface\glues`): 13 models, 261 batches, **15 live UV loops over 6
// models and 2 live RGB**, and PER-PLACEMENT is empty on both channels — every
// glue batch bakes one loop across its slots, so there is no sequence to key by
// and `host: None` is exact rather than a fallback.
let loops = benilla_world::doodad_anim::UvLoops::of(s);
let mut mat_anim = false;
if loops.animates() {
benilla_world::doodad_anim::register_entity_uv(
&mut uv_reg,
&mut anim_table,
mats.materials(),
material.id(),
&loops,
None,
);
mat_anim = true;
}
if let Some(rgb) = s.rgb_anim.as_ref().filter(|a| a.period > 0.0) {
benilla_world::doodad_anim::register_tint(
&mut tint_reg,
&mut anim_table,
mats.materials(),
material.id(),
benilla_world::doodad_anim::TintLoop::Shared(rgb.clone()),
);
mat_anim = true;
}
BoothPart {
skinned: skin_forms.get(pi).cloned(),
static_mesh: stat_forms
@@ -1108,9 +1180,15 @@ pub(super) fn sync_glue_scene(
// corners — B121.
alpha_anim: s.alpha_anim.clone(),
twins: BoothTwins::default(),
mat_anim,
}
})
.collect();
info!(
"create scene: UI_{token} material lane — {} of {} batches animate (UV/tint registered)",
scene_parts.iter().filter(|p| p.mat_anim).count(),
scene_parts.len(),
);
let mut scene_rig = spawn_booth_model(
&mut commands,
&mut palettes,
@@ -1457,6 +1535,7 @@ pub(super) fn sync_glue_booth(
// previews at 1.0 here. Closing it means threading `alpha_anim` through the
// appearance-assembly layer — deliberately not folded into the B121 fix.
alpha_anim: None,
mat_anim: false,
});
}
// The equipment riders (a Select look — helm/shoulders/sheathed weapons, decision 0465):
@@ -1702,6 +1781,7 @@ pub(super) fn sync_glue_pet(
material: relight(&p.material, &mut scene.variants),
alpha_anim: p.alpha_anim.clone(),
twins: BoothTwins::default(),
mat_anim: false,
})
.collect();
let booth_billboards: Vec<BoothBillboardSpec> = pet
+2
View File
@@ -2123,6 +2123,7 @@ fn sync_portraits(
// mirrored `PortraitPart` doesn't carry the batch's alpha loops.
alpha_anim: None,
twins: BoothTwins::default(),
mat_anim: false,
})
.collect();
let booth_riders: Vec<BoothRider> = riders
@@ -2636,6 +2637,7 @@ fn sync_body_booth(
// `None` — the same known gap as the glue preview's (decision 0807).
alpha_anim: None,
twins: BoothTwins::default(),
mat_anim: false,
})
.collect();
let booth_riders: Vec<BoothRider> = riders
@@ -129,6 +129,7 @@ fn bake_test(
// right here — an eyeball bake should show the batch dimming the artist wrote.
alpha_anim: s.alpha_anim.clone(),
twins: BoothTwins::default(),
mat_anim: false,
})
.collect::<Vec<BoothPart>>()
};
+20 -18
View File
@@ -18,7 +18,7 @@ use benilla_protocol::EntityKind;
use crate::creature_anim::AnimSoundEvent;
use crate::entities::mount::{MountBody, MountChild};
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
use crate::net::{FieldChanged, NetEntity, ObjectStore, SelfPlayer};
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
@@ -149,12 +149,16 @@ fn play_bark(
}
}
/// Play the death vocal on a **live** death: a unit whose store transitions alive→dead. First
/// sight already-dead records silently (a streamed corpse doesn't cry — the same distinction the
/// animation driver makes for the settled-corpse pose).
/// Play the death vocal on a **live** death — the reference's two triggers, each a field edge
/// (decision 2297): the `UNIT_FIELD_HEALTH` watcher's alive→dead arm (`0x6046f0` at `0x6047a3`,
/// `OLD > 0 && NEW ≤ 0` — the real death handler `0x605860`) and the `UNIT_DYNAMIC_FLAGS`
/// watcher's `UNIT_DYNFLAG_DEAD` SET edge (`0x600543`), which fires the very same `0x623a40(4)`
/// — so a feign drops the body with its death cry, exactly like a kill (decision 1022). A unit
/// that streams in already dead cries nothing: the edge stream is create-suppressed, the same
/// distinction the animation driver makes for the settled-corpse pose.
fn death_vocals(
changed: Query<(Entity, &NetEntity, &ObjectStore, &Transform), Changed<ObjectStore>>,
mut known_dead: Local<EntityHashMap<bool>>,
mut edges: MessageReader<FieldChanged>,
units: Query<(&NetEntity, &Transform)>,
voices: Option<Res<CreatureVoices>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
@@ -162,24 +166,22 @@ fn death_vocals(
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
use benilla_protocol::field::{FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_HEALTH, UNIT_DYNFLAG_DEAD};
let (Some(voices), Some(mut kits), Some(assets)) = (voices, kits, assets) else {
return;
};
let listener = listener.pos;
for (entity, net, store, transform) in &changed {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
for e in edges.read() {
let died = (e.unit_field(FIELD_UNIT_HEALTH) && e.old > 0 && e.new == 0)
|| (e.unit_field(FIELD_UNIT_DYNAMIC_FLAGS)
&& e.old & UNIT_DYNFLAG_DEAD == 0
&& e.new & UNIT_DYNFLAG_DEAD != 0);
if !died {
continue;
}
// Reads-dead, not really-dead (decision 1022): the reference's `UNIT_DYNAMIC_FLAGS` watcher
// fires `0x623a40(4)` — the death vocal state — on the `UNIT_DYNFLAG_DEAD` set edge
// (`0x600543`), the very same call its real death handler makes (`0x6251b0`). So a feign
// drops the body with its death cry, exactly like a kill.
let dead = store.0.unit_reads_dead();
let was = known_dead.insert(entity, dead);
let fresh_death = was == Some(false) && dead;
if !fresh_death {
let Ok((net, transform)) = units.get(e.entity) else {
continue;
}
};
let Some(voice) = net.display_id.and_then(|d| voices.0.for_display(d)) else {
continue;
};
@@ -189,7 +191,7 @@ fn death_vocals(
&mut out,
&config,
listener,
entity,
e.entity,
transform.translation,
voice,
BARK_DEATH,
+13 -20
View File
@@ -8,12 +8,9 @@
//! mounted→unmounted transition of any visible unit; first sight of an unmounted unit records
//! silently, and a remount (id→id′) is not a dismount.
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use crate::net::{NetEntity, ObjectStore};
use crate::net::FieldChanged;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
@@ -28,10 +25,12 @@ use super::{AudioListener, SoundConfig, SoundOutput};
/// as `SpiritWolf_DONOTRENAME` — a transcription-level difference, the row is unambiguous.
const DISMOUNT_KIT: &str = "SpiritWolf (DONOTRENAME)";
/// Play the fixed dismount kit on a live mounted→unmounted transition of any streamed unit.
/// Play the fixed dismount kit on a live mounted→unmounted transition of any streamed unit —
/// the `UNIT_FIELD_MOUNTDISPLAYID` edge with a zero NEW value (decision 2297: the field-edge
/// stream is create-suppressed, so streaming in unmounted is not a dismount by construction).
fn dismount_sounds(
changed: Query<(Entity, &NetEntity, &ObjectStore, &Transform), Changed<ObjectStore>>,
mut known_mount: Local<EntityHashMap<u32>>,
mut edges: MessageReader<FieldChanged>,
poses: Query<&Transform>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
@@ -42,21 +41,15 @@ fn dismount_sounds(
return;
};
let listener = listener.pos;
for (entity, net, store, transform) in &changed {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
for e in edges.read() {
if !e.unit_field(benilla_protocol::field::FIELD_UNIT_MOUNTDISPLAYID) || e.new != 0 {
continue;
}
let mount = store.0.unit_mount_display_id();
let was = known_mount.insert(entity, mount);
// Only the live mounted→unmounted edge sounds — streaming in unmounted is not a dismount.
if !(was.is_some_and(|w| w != 0) && mount == 0) {
let Ok(transform) = poses.get(e.entity) else {
continue;
}
debug!(
"dismount kit on {entity:?} (was mount {})",
was.unwrap_or(0)
);
if let Err(e) = play_kit(
};
debug!("dismount kit on {:?} (was mount {})", e.entity, e.old);
if let Err(err) = play_kit(
&mut kits,
&assets,
&mut out,
@@ -66,7 +59,7 @@ fn dismount_sounds(
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("dismount kit ({DISMOUNT_KIT}): {e:#}");
warn!("dismount kit ({DISMOUNT_KIT}): {err:#}");
}
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ mod event;
mod feed;
mod frames;
/// The idle handler — the 5-minute auto-sit / auto-AFK and the 30-minute camp.
mod idle;
pub(crate) mod idle;
mod input;
/// The language gate — the exemptions and the fluency lookup behind the chat garble (B262).
mod language;
+126 -135
View File
@@ -15,14 +15,13 @@
//! [`crate::creature_anim::arm_aura_state_fx`] uses for the aura state kits — and for the same
//! reason: the *edge* is the event, and a descriptor snapshot is all the wire gives us.
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use super::{
aura_gone_kind, classify, death_kind, in_range, periodic_kind, CombatLogRanges, Family, Fills,
Named, PendingCombat, UnitClass, Variant,
};
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid};
use crate::net::{FieldChanged, GuidIndex, ObjectStore, Reputations, SelfGuid};
use crate::target::ring::Factions;
use crate::ui_chat::{ChatEventKind, ChatLog};
use crate::ui_party::GroupState;
@@ -116,9 +115,11 @@ const DESTROYED_EFFECTS: [u32; 10] = [50, 74, 87, 88, 89, 90, 104, 105, 106, 107
/// The unit-death reflex's chat line (`0x625190` → `0x62c160`).
///
/// **The edge is the event.** The wire never says "this unit died"; it says the unit's health is
/// zero, or sets its dead flag, in an ordinary descriptor update — so the line comes off a diff
/// against the previous frame, and a unit that streams in *already* dead produces none (which is
/// what the reference does too: its reflex fires on the transition, not on the state).
/// zero in an ordinary descriptor update — so the line comes off the `UNIT_FIELD_HEALTH` field
/// edge, the reference's own watcher `0x6046f0` whose alive→dead arm (`OLD > 0 && NEW ≤ 0` at
/// `0x6047a3`) reaches the reflex (decision 2297), and a unit that streams in *already* dead
/// produces none (the edge stream is create-suppressed: the reflex fires on the transition, not
/// on the state).
///
/// **The XP-award suppression is deliberately not modelled**, and is named rather than dropped:
/// `[CGUnit+0xc58] bit 3`, set only in the `SMSG_LOG_XPGAIN` chain, makes the death reflex emit the
@@ -129,27 +130,18 @@ const DESTROYED_EFFECTS: [u32; 10] = [50, 74, 87, 88, 89, 90, 104, 105, 106, 107
pub(crate) fn death_lines(
ctx: WatchCtx,
stores: Query<(Entity, &ObjectStore)>,
guids: Query<&crate::net::Guid>,
changed: Query<Entity, Changed<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
poses: Query<&Transform>,
mut log: ResMut<ChatLog>,
mut was_dead: Local<EntityHashMap<bool>>,
) {
for entity in changed.iter() {
let Ok((_, store)) = stores.get(entity) else {
continue;
};
let dead = store.0.unit_is_dead();
let seen = was_dead.insert(entity, dead);
// No previous frame for this entity: it streamed in at whatever state it is in, which is
// not a death.
let Some(false) = seen else { continue };
if !dead {
for e in edges.read() {
if !(e.unit_field(benilla_protocol::field::FIELD_UNIT_HEALTH) && e.old > 0 && e.new == 0) {
continue;
}
let Ok(&crate::net::Guid(guid)) = guids.get(entity) else {
let Ok((_, store)) = stores.get(e.entity) else {
continue;
};
let guid = e.guid;
let class = ctx.classify(guid, &stores);
// A summoned thing is *destroyed*; a living one *dies*. The test is the creating spell's
// first effect, which is why it needs the spell catalog at all.
@@ -177,23 +169,26 @@ pub(crate) fn death_lines(
Named::Ready,
);
}
was_dead.retain(|e, _| stores.contains(*e));
}
/// The aura lines (`0x62b480` / `0x62b800`).
///
/// **Three edges out of one diff**, because the reference reads two descriptor arrays through two
/// callbacks and words three sentences:
/// **Three edges out of two watches**, because the reference reads two descriptor arrays through
/// two callbacks and words three sentences — and since decision 2297 those are literally the
/// triggers here, one field edge each, with no snapshot of the slots kept anywhere:
///
/// - a slot that filled → `AURAADDED{SELF,OTHER}{HARMFUL,HELPFUL}`, at the PERIODIC chat types
/// (harmful takes the `…_DAMAGE` row, helpful the `…_BUFFS` one) — not the `AURA_GONE` block,
/// which is the departure's alone;
/// - a slot that emptied → `AURAREMOVED{SELF,OTHER}`, at `0x41`/`0x42`/`0x43`;
/// - a slot whose stack count ROSE → `AURAAPPLICATIONADDED*`, and only when the spell can stack at
/// all. We approximate the reference's `SpellRec->StackAmount > 1` gate with the observed count:
/// a stack that rose past one is a spell that stacks. It differs only for a first application of
/// - a `UNIT_FIELD_AURA` slot whose NEW value is a spell → `AURAADDED{SELF,OTHER}{HARMFUL,HELPFUL}`,
/// at the PERIODIC chat types (harmful takes the `…_DAMAGE` row, helpful the `…_BUFFS` one) —
/// not the `AURA_GONE` block, which is the departure's alone (`0x604d00`'s add arm);
/// - a slot whose OLD value was a spell → `AURAREMOVED{SELF,OTHER}`, at `0x41`/`0x42`/`0x43`
/// (the remove arm; a replace is one of each, the arrival worded first);
/// - a `UNIT_FIELD_AURAAPPLICATIONS` byte that ROSE, on a slot whose spell did not move this
/// frame → `AURAAPPLICATIONADDED*` (`0x604ea0`), and only when the spell can stack at all. We
/// approximate the reference's `SpellRec->StackAmount > 1` gate with the observed count: a
/// stack that rose past one is a spell that stacks. It differs only for a first application of
/// a stacking aura, which the reference words as a plain `AURAADDED*` — and so do we, because a
/// 0→1 step is a *fill*, not an increase.
/// fresh slot's count is not a rise (its `AURA` dword moved in the same block, so its byte is
/// skipped here).
///
/// **HARMFUL is the SLOT INDEX, not a flag** (§4.4, three byte sites): slots `0x20`–`0x2f` are
/// harmful, every other slot helpful. `UNIT_FIELD_AURAFLAGS` is only an occupancy predicate — using
@@ -201,121 +196,125 @@ pub(crate) fn death_lines(
pub(crate) fn aura_lines(
ctx: WatchCtx,
stores: Query<(Entity, &ObjectStore)>,
guids: Query<&crate::net::Guid>,
changed: Query<Entity, Changed<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
poses: Query<&Transform>,
mut log: ResMut<ChatLog>,
// Per entity, per slot: `(spell id, stacks)` as of the last frame. Slot-keyed rather than
// spell-keyed because the sentence's harmful/helpful half IS the slot.
mut seen: Local<EntityHashMap<Vec<(u8, u32, u8)>>>,
) {
use benilla_protocol::field::{FIELD_UNIT_AURA, FIELD_UNIT_AURAAPPLICATIONS};
/// The first harmful aura slot — `0x20`. Slots below it are helpful (§4.4).
const FIRST_HARMFUL_SLOT: u8 = 0x20;
const FIRST_HARMFUL_SLOT: u16 = 0x20;
const SLOTS: u16 = benilla_protocol::messages::UNIT_AURA_SLOTS as u16;
let harmful = |slot: u16| slot >= FIRST_HARMFUL_SLOT;
for entity in changed.iter() {
let Ok((_, store)) = stores.get(entity) else {
continue;
};
let now: Vec<(u8, u32, u8)> = store
.0
.unit_auras()
.map(|a| (a.slot, a.spell_id, a.stacks))
.collect();
let prev = seen.insert(entity, now.clone());
// First sight of this unit: its standing auras are not arrivals.
let Some(prev) = prev else { continue };
let Ok(&crate::net::Guid(guid)) = guids.get(entity) else {
continue;
};
let class = ctx.classify(guid, &stores);
let harmful = |slot: u8| slot >= FIRST_HARMFUL_SLOT;
let find =
|list: &[(u8, u32, u8)], slot: u8| list.iter().find(|(s, _, _)| *s == slot).copied();
// The whole frame's batch first: the applications leg must know which slots' `AURA` dword
// moved in the same block, and the two arrays sit 66 dwords apart in the ascending walk.
let batch: Vec<FieldChanged> = edges.read().copied().collect();
let slot_edges: Vec<&FieldChanged> = batch
.iter()
.filter(|e| e.unit_array_slot(FIELD_UNIT_AURA, SLOTS).is_some())
.collect();
let slot_moved = |entity: Entity, slot: u16| {
slot_edges
.iter()
.any(|e| e.entity == entity && e.index - FIELD_UNIT_AURA == slot)
};
for &(slot, spell_id, stacks) in &now {
let Some(spell) = ctx.spell_name(spell_id) else {
continue;
};
let fills = |amount: i64| Fills {
spell: spell.clone(),
amount,
..Default::default()
};
match find(&prev, slot) {
// The slot held a DIFFERENT spell, or nothing: this aura arrived.
Some((_, was, _)) if was == spell_id => {
let Some((_, _, before)) = find(&prev, slot) else {
continue;
};
if stacks <= before || stacks <= 1 {
continue;
}
let Some(kind) = periodic_kind(class, !harmful(slot)) else {
continue;
};
queue_one(
&mut log,
&ctx,
&poses,
kind,
if harmful(slot) {
super::AURAAPPLICATIONADDED_HARMFUL
} else {
super::AURAAPPLICATIONADDED_HELPFUL
},
(guid, class),
ctx.ranges.class(class),
fills(i64::from(stacks)),
Named::Ready,
);
}
_ => {
let Some(kind) = periodic_kind(class, !harmful(slot)) else {
continue;
};
queue_one(
&mut log,
&ctx,
&poses,
kind,
if harmful(slot) {
super::AURAADDED_HARMFUL
} else {
super::AURAADDED_HELPFUL
},
(guid, class),
ctx.ranges.class(class),
fills(0),
Named::Ready,
);
}
for e in &slot_edges {
let slot = e.index - FIELD_UNIT_AURA;
let class = ctx.classify(e.guid, &stores);
// Arrival first: the slot now holds a spell (a fill, or a replace's new half).
if e.new != 0 {
if let (Some(spell), Some(kind)) =
(ctx.spell_name(e.new), periodic_kind(class, !harmful(slot)))
{
queue_one(
&mut log,
&ctx,
&poses,
kind,
if harmful(slot) {
super::AURAADDED_HARMFUL
} else {
super::AURAADDED_HELPFUL
},
(e.guid, class),
ctx.ranges.class(class),
Fills {
spell,
..Default::default()
},
Named::Ready,
);
}
}
// Departures: a slot that held a spell and no longer holds THAT spell.
for &(slot, spell_id, _) in &prev {
if find(&now, slot).is_some_and(|(_, id, _)| id == spell_id) {
// Departure: the slot held a spell and no longer holds THAT spell.
if e.old != 0 {
if let Some(spell) = ctx.spell_name(e.old) {
queue_one(
&mut log,
&ctx,
&poses,
aura_gone_kind(class),
super::AURAREMOVED,
(e.guid, class),
ctx.ranges.class(class),
Fills {
spell,
..Default::default()
},
Named::Ready,
);
}
}
}
// The stack-count leg: byte-packed, four slots per dword, the wire byte `stack - 1`.
for e in batch.iter().filter(|e| {
e.unit_array_slot(FIELD_UNIT_AURAAPPLICATIONS, SLOTS / 4)
.is_some()
}) {
let word = e.index - FIELD_UNIT_AURAAPPLICATIONS;
let Ok((_, store)) = stores.get(e.entity) else {
continue; // gone in the same drain
};
for byte in 0..4u16 {
let slot = word * 4 + byte;
let (before, now) = ((e.old >> (byte * 8)) & 0xff, (e.new >> (byte * 8)) & 0xff);
// A rise past one, on a slot whose spell stayed put this block.
if now <= before || now == 0 || slot_moved(e.entity, slot) {
continue;
}
let Some(spell) = ctx.spell_name(spell_id) else {
let Some(aura) = store.0.unit_aura(slot as u8) else {
continue;
};
let Some(spell) = ctx.spell_name(aura.spell_id) else {
continue;
};
let class = ctx.classify(e.guid, &stores);
let Some(kind) = periodic_kind(class, !harmful(slot)) else {
continue;
};
queue_one(
&mut log,
&ctx,
&poses,
aura_gone_kind(class),
super::AURAREMOVED,
(guid, class),
kind,
if harmful(slot) {
super::AURAAPPLICATIONADDED_HARMFUL
} else {
super::AURAAPPLICATIONADDED_HELPFUL
},
(e.guid, class),
ctx.ranges.class(class),
Fills {
spell,
amount: i64::from(now + 1),
..Default::default()
},
Named::Ready,
);
}
}
seen.retain(|e, _| stores.contains(*e));
}
/// The pet-loyalty line (`0x5ff860` → `0x62d440`) — `UNIT_FIELD_BYTES_1` byte 1 moving.
@@ -326,26 +325,19 @@ pub(crate) fn aura_lines(
pub(crate) fn pet_loyalty_lines(
ctx: WatchCtx,
stores: Query<(Entity, &ObjectStore)>,
guids: Query<&crate::net::Guid>,
changed: Query<Entity, Changed<ObjectStore>>,
mut edges: MessageReader<FieldChanged>,
mut log: ResMut<ChatLog>,
mut seen: Local<EntityHashMap<u8>>,
) {
for entity in changed.iter() {
let Ok((_, store)) = stores.get(entity) else {
for e in edges.read() {
if !e.unit_field(benilla_protocol::field::FIELD_UNIT_BYTES_1) {
continue;
};
let level = store.0.unit_loyalty_level();
let Some(before) = seen.insert(entity, level) else {
continue;
};
}
// Byte 1 of the word — the same shift `ObjectFields::unit_loyalty_level` reads through.
let (before, level) = (((e.old >> 8) & 0xff) as u8, ((e.new >> 8) & 0xff) as u8);
if level == before || level == 0 || before == 0 {
continue;
}
let Ok(&crate::net::Guid(guid)) = guids.get(entity) else {
continue;
};
if ctx.classify(guid, &stores) != UnitClass::MyPet {
if ctx.classify(e.guid, &stores) != UnitClass::MyPet {
continue;
}
log.push_combat(PendingCombat {
@@ -363,5 +355,4 @@ pub(crate) fn pet_loyalty_lines(
tries: 0,
});
}
seen.retain(|e, _| stores.contains(*e));
}
+19
View File
@@ -60,6 +60,25 @@ use super::feed::ChatLog;
#[derive(Resource, Debug, Clone, Copy, Default)]
pub(crate) struct LastInput(Duration);
impl LastInput {
/// Stamp the clock as an **unattended probe** — the one caller outside [`stamp_input`].
///
/// A probe is a client with nobody at the keyboard, and this timer is the one place where that
/// is not a harmless difference: a probe that stands still in a battleground goes AFK at five
/// minutes exactly as the reference would, and vmangos then removes an AFK player from the
/// battleground outright (`Player::ToggleAFK` → `LeaveBattleground`). Two faithful behaviours,
/// one on each side, combining to eject an instrument before a match can end — which is what
/// `capture::probe_bg`'s first long run found, at t=300 s, with the census reading Stormwind.
///
/// So a probe that means to stand in for a **present** player says so here, rather than the
/// alternative dodges: turning the idle handler off (it would stop testing the thing the
/// reference does), or having the probe jiggle the mouse (a synthetic input event to fool our
/// own dispatcher, which is a lie told one layer lower down and harder to see).
pub(crate) fn stamp_present(&mut self, now: Duration) {
self.0 = now;
}
}
/// **5 minutes** — `0x482ecd lea ecx,[eax-0x493e0]`, `0x493e0` = 300 000 ms.
const AUTO_AFK_AFTER: Duration = Duration::from_millis(300_000);
+14 -2
View File
@@ -13,7 +13,9 @@ use benilla_ui::script::{
use bevy::prelude::*;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{
ClientCommand, FieldChanged, FieldEdges, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer,
};
use crate::target::Selection;
use crate::ui_script::gate;
@@ -113,6 +115,8 @@ pub(super) fn feed_party(
stores: Query<&ObjectStore>,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
// The per-field edges (decision 2297), for `fire_transitions`' watch-bridge arms.
mut edges: MessageReader<FieldChanged>,
self_q: Query<(Entity, &Guid, &ObjectStore), With<SelfPlayer>>,
factions: Option<Res<crate::target::Factions>>,
names: Res<NameCache>,
@@ -133,6 +137,7 @@ pub(super) fn feed_party(
return;
};
let (fed, vm_reset) = fed.get_reset(&script);
let edges = FieldEdges::collect(&mut edges);
// The ready-check summary the timeout tick composed (decision 1989) — a client-composed
// `CHAT_MSG_SYSTEM` line, pushed the way every other one is. Ahead of the gate: the tick
// runs on the frame clock, not on anything the gate watches.
@@ -333,7 +338,13 @@ pub(super) fn feed_party(
gate.audit("feed_party", "a party-token snapshot");
script.set_unit(token, snap.clone());
if let Some(cur) = &snap {
crate::ui_unit::fire_transitions(&mut script, token, fed.units[i].as_ref(), cur);
crate::ui_unit::fire_transitions(
&mut script,
token,
fed.units[i].as_ref(),
cur,
&edges,
);
}
fed.units[i] = snap;
}
@@ -417,6 +428,7 @@ pub(super) fn feed_party(
token,
fed.raid_units[i].as_ref(),
cur,
&edges,
);
}
fed.raid_units[i] = snap;
+2
View File
@@ -182,6 +182,8 @@ pub(crate) struct PetUnit<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
self_guid: Res<'w, crate::net::SelfGuid>,
/// The per-field edges (decision 2297), for `fire_transitions`' watch-bridge arms.
pub(super) edges: MessageReader<'w, 's, crate::net::FieldChanged>,
}
impl PetUnit<'_, '_> {
+3 -2
View File
@@ -62,7 +62,7 @@ pub(super) const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800;
pub(super) fn feed_pet_unit(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
mut pet: PetUnit,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
mut names: ResMut<NameCache>,
@@ -73,6 +73,7 @@ pub(super) fn feed_pet_unit(
return;
};
let (memory, vm_reset) = memory.get_reset(&script);
let edges = crate::net::FieldEdges::collect(&mut pet.edges);
// The gate (1439): the bar (the token's identity), any descriptor change or DESPAWN (the
// snapshot, the combat flag, and the rename timestamp all live on the pet's store), and the
// name cache by its landed counter.
@@ -140,7 +141,7 @@ pub(super) fn feed_pet_unit(
cur.max_health,
);
}
fire_transitions(&mut script, "pet", memory.pushed.as_ref(), cur);
fire_transitions(&mut script, "pet", memory.pushed.as_ref(), cur, &edges);
memory.pushed = Some(cur.clone());
}
}
+1
View File
@@ -742,6 +742,7 @@ mod tests {
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::net::FieldChanged>()
.insert_resource(NetCommands(tx))
.add_plugins((UiPetStatsPlugin, UiPetPlugin));
@@ -241,6 +241,68 @@ fn channel_counts_down_not_up() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A completed channel closes on the BAR's own clock, because the server's stop runs a second
/// late** — and that is the path every naturally-finished channel in the game takes.
///
/// vmangos does not send `MSG_CHANNEL_UPDATE(0)` when a channel simply runs out.
/// `Spell::SendChannelUpdate(0, interrupted=false)` schedules a `ChannelResetEvent` **+1000 ms**
/// ("Else, we have some visual bugs (arcane projectile, last tick)"), and only that event's
/// `CancelSpellChannelingAnimationInstantly()` finally emits the packet. The *interrupt* path calls
/// the same function inline, which is why a broken channel stops at once and a finished one does
/// not.
///
/// So for a whole second after the fill empties there is no stop edge at all, and the stock
/// `OnUpdate` is what ends it: once `GetTime()` reaches `endTime` it clamps, `time == this.endTime`
/// trips, and the frame hands itself to `fadeOut` with **no green flash** — a finished channel
/// fades, where a cast completes green. The late packet then lands on a hidden frame and both of
/// `CastingBarFrame_OnEvent`'s stop-arm guards reject it.
///
/// Worth a test of its own because nothing else exercises it: the sibling test above fires
/// `SPELLCAST_CHANNEL_STOP` by hand, which is the *interrupt* timing, not this one.
#[test]
fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
let mut s = harness();
s.fire_event(
"SPELLCAST_CHANNEL_START",
vec![
ScriptValue::Int(1000),
ScriptValue::Str("Channeling".into()),
],
);
assert!(s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap());
// The whole second the channel runs: still up, still orange, no stop packet has been sent.
for _ in 0..9 {
s.tick(0.1);
}
assert!(
s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"still channelling at 0.9 s of a 1 s channel"
);
let (r, g, b) = bar_color(&s);
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.7).abs() < 1e-6 && b.abs() < 1e-6,
"never flashes green — a finished channel fades, it does not complete (got {r} {g} {b})"
);
// Past endTime with nothing from the server: the frame ends itself.
for _ in 0..40 {
s.tick(0.1);
}
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"the bar closed on its own clock, a full second before the server says so"
);
// ~1 s after the fill emptied, the deferred ChannelResetEvent finally fires.
s.fire_event("SPELLCAST_CHANNEL_STOP", vec![]);
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"the late stop lands on a hidden frame and does nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The draw list ────────────────────────────────────────────────────────────────────────────────
const FLASH: &str = "UI-CastingBar-Flash";
+79 -32
View File
@@ -34,7 +34,9 @@ use benilla_protocol::messages::ObjectType;
use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::net::{
FieldChanged, FieldEdges, Guid, NetCommands, ObjectStore, Reputations, SelfPlayer,
};
use crate::target::{ring_reaction, Factions, Selection};
use crate::ui_script::{gate, UiInput};
@@ -274,6 +276,7 @@ impl Plugin for UiUnitPlugin {
// nothing. (1348's own `the_login_one_shots_wait_for_the_in_game_ui` is the harness that
// found this — it builds this plugin alone.)
.add_message::<crate::net::WorldportMessage>()
.add_message::<crate::net::FieldChanged>()
// The world latch (2239): this plugin owns one of its two producers, so it declares the
// resource as well as the message — same reason, and `init_resource` is idempotent
// against `UiScriptPlugin`'s own.
@@ -1038,6 +1041,9 @@ pub(crate) struct UnitStores<'w, 's> {
changed: Query<'w, 's, (), Changed<ObjectStore>>,
/// Whose object left the manager — the other half of that gate, and cleared every run.
removed: RemovedComponents<'w, 's, ObjectStore>,
/// The per-field edges this run (decision 2297) — [`fire_transitions`]' three mirror-diff
/// arms fire off these, per token naming the moved unit.
edges: MessageReader<'w, 's, FieldChanged>,
}
/// Build a unit snapshot from a streamed object's descriptor (decision 0061's `ObjectFields`) plus
@@ -1417,12 +1423,16 @@ fn creature_type_word(t: u32) -> Option<&'static str> {
}
/// Diff a token's fresh snapshot against the last one pushed and fire the per-field Era events.
/// `prev = None` (the token just appeared) treats every present field as a transition.
/// `prev = None` (the token just appeared) treats every present field as a transition — except
/// the three arms that are the reference's per-field watch bridge, which fire off `edges`, the
/// descriptor edges this run (decision 2297): those fire per token naming a unit whose dword
/// moved, and a unit's create moves nothing.
pub(crate) fn fire_transitions(
script: &mut UiScript,
token: &str,
prev: Option<&UnitState>,
cur: &UnitState,
edges: &FieldEdges,
) {
let tok = || ScriptValue::Str(token.to_string());
let changed = |f: fn(&UnitState) -> u64| prev.is_none_or(|p| f(p) != f(cur));
@@ -1444,8 +1454,11 @@ pub(crate) fn fire_transitions(
// fires `0x51bd50` → `0x515e50` on any change of the dword's bytes, once per token mapping to
// the unit, `arg1` the token. The create leg runs no notify pass, so a unit's FIRST snapshot
// is not a transition here — unlike the fields above, whose first-appearance fire is this
// feed's own posture (1953, corrected in 1957). The stock pet bar filters it for `"pet"`.
if prev.is_some() && changed(|u| u64::from(u.flags)) {
// feed's own posture (1953, corrected in 1957). Since 2297 that is literally the trigger:
// the field edge for this unit's dword, which a create never emits, and which a retarget
// onto a unit with different flags does not emit either (the old `prev.is_some()` snapshot
// diff fired on that). The stock pet bar filters it for `"pet"`.
if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_FLAGS) {
script.fire_event("UNIT_FLAGS", vec![tok()]);
}
// `PLAYER_FLAGS_CHANGED` (id 407) — the `UNIT_FLAGS` arm above, one descriptor field over, and
@@ -1482,7 +1495,7 @@ pub(crate) fn fire_transitions(
// construction: it is only ever called *per token*, so a tokenless player is never reached
// (wow-re `object-layer/scratch/player-flags-delta-arms.md`, the 2078 correction round).
//
// `prev.is_some()` for the same reason `UNIT_FLAGS` carries it: this is a mirror-diff watcher,
// Off the field edge for the same reason `UNIT_FLAGS` is: this is a mirror-diff watcher,
// and a unit's first snapshot is its CREATE, which runs no notify pass (1098 §4).
//
// **The sole 1.12 consumer is the target frame's PARTY-LEADER icon, not an AFK/DND badge** —
@@ -1492,7 +1505,7 @@ pub(crate) fn fire_transitions(
// `ui/scratch/unit-predicate-return-shape.md` §5, a zero-hit whole-image byte census); the
// `<AFK>`/`<DND>` the era shows are the chat line's `arg6` flag. This comment is here because
// the gap list said "the AFK/DND badge" for months and sent the first look at the wrong window.
if prev.is_some() && changed(|u| u64::from(u.player_flags)) {
if edges.moved(cur.guid, benilla_protocol::field::FIELD_PLAYER_FLAGS) {
script.fire_event("PLAYER_FLAGS_CHANGED", vec![tok()]);
}
// `UNIT_DYNAMIC_FLAGS` (id 137) — the third arm of the same bridge, and one benilla had
@@ -1508,10 +1521,10 @@ pub(crate) fn fire_transitions(
//
// Byte-verified, not inherited (see [`UnitState::dynamic_flags`]): the name-table slot for
// 137 resolves to `"UNIT_DYNAMIC_FLAGS"`, and the watch length is one dword, so the trigger
// is the RAW word and not the four bits this struct decodes. `prev.is_some()` for the same
// reason the two arms above carry it — a unit's first snapshot is its CREATE, and the create
// is the RAW word and not the four bits this struct decodes. Off the field edge for the same
// reason the two arms above are — a unit's first snapshot is its CREATE, and the create
// block runs no notify pass.
if prev.is_some() && changed(|u| u64::from(u.dynamic_flags)) {
if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS) {
script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]);
}
// The POWER pair is named per resource in 1.12, not once with the token as arg2: the reference's
@@ -1634,6 +1647,7 @@ fn feed_units(
// disjoint fields they are — through the `ResMut` deref they would alias.
let feed = &mut *feed;
let (memo, vm_reset) = feed.vm.get_reset(&script);
let edges = FieldEdges::collect(&mut stores.edges);
// The gate (1439): every input the two snapshots and the edge diffs below read — any
// descriptor change or DESPAWN (a removed store is invisible to `Changed`), the selection,
@@ -2183,7 +2197,7 @@ fn feed_units(
let prev = memo.last.get(token);
if prev != Some(cur) {
gate.audit("feed_units", "a unit-token transition");
fire_transitions(&mut script, token, prev, cur);
fire_transitions(&mut script, token, prev, cur, &edges);
memo.last.insert(token.to_string(), cur.clone());
}
}
@@ -2429,7 +2443,7 @@ mod tests {
/// reference's watch bridge has no watch to fire on the create leg (1953, corrected 1957).
#[test]
fn a_flags_change_fires_unit_flags_with_the_token() {
let fired = |prev: Option<UnitState>, cur: UnitState| -> Vec<String> {
let fired = |prev: Option<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
@@ -2440,30 +2454,53 @@ mod tests {
"#,
)
.unwrap();
fire_transitions(&mut s, "pet", prev.as_ref(), &cur);
fire_transitions(&mut s, "pet", prev.as_ref(), &cur, edges);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const PET: u64 = 0xF140_0000_0000_0001;
let base = UnitState {
exists: true,
has_object: true,
guid: PET,
flags: 0x8,
..Default::default()
};
let moved = FieldEdges::of(&[(PET, benilla_protocol::field::FIELD_UNIT_FLAGS)]);
assert_eq!(
fired(
Some(base.clone()),
UnitState {
flags: 0x8 | 0x0400_0000,
..base.clone()
}
},
&moved,
),
vec!["UNIT_FLAGS:pet".to_string()]
);
assert_eq!(
fired(Some(base.clone()), base.clone()),
fired(Some(base.clone()), base.clone(), &FieldEdges::default()),
Vec::<String>::new()
);
// The create block runs no notify pass: no edge, no event — whatever the snapshot says.
assert_eq!(
fired(None, base.clone(), &FieldEdges::default()),
Vec::<String>::new()
);
// …and the trigger is the UNIT'S edge, not the token's history: a token acquired on the
// very frame the field moved hears it (the reference fans out to whoever names the unit
// at notify time), and another unit's edge is not this one's.
assert_eq!(
fired(None, base.clone(), &moved),
vec!["UNIT_FLAGS:pet".to_string()]
);
assert_eq!(
fired(
Some(base.clone()),
base.clone(),
&FieldEdges::of(&[(PET + 1, benilla_protocol::field::FIELD_UNIT_FLAGS)]),
),
Vec::<String>::new()
);
assert_eq!(fired(None, base.clone()), Vec::<String>::new());
}
/// The two edges on the player's own state (1953): the control flag's, which fires LOST on
@@ -2557,7 +2594,7 @@ mod tests {
"#,
)
.unwrap();
fire_transitions(&mut s, "target", Some(&prev), &cur);
fire_transitions(&mut s, "target", Some(&prev), &cur, &FieldEdges::default());
s.eval::<Vec<String>>("return SEEN").unwrap()
};
let base = UnitState {
@@ -2621,7 +2658,7 @@ mod tests {
/// decodes, proving the trigger is the raw dword and not the two bools next to it.
#[test]
fn player_flags_changed_fires_per_token_on_any_bit_and_never_on_first_sight() {
let fired = |prev: Option<UnitState>, cur: UnitState| -> Vec<String> {
let fired = |prev: Option<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
@@ -2632,12 +2669,14 @@ mod tests {
"#,
)
.unwrap();
fire_transitions(&mut s, "target", prev.as_ref(), &cur);
fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const THEM: u64 = 0x2a;
let base = UnitState {
exists: true,
has_object: true,
guid: THEM,
..Default::default()
};
let with = |flags: u32| UnitState {
@@ -2646,38 +2685,40 @@ mod tests {
ghost: flags & 0x10 != 0,
..base.clone()
};
let moved = FieldEdges::of(&[(THEM, benilla_protocol::field::FIELD_PLAYER_FLAGS)]);
let still = FieldEdges::default();
// The 1.12 consumer's own bit: `PLAYER_FLAGS_GROUP_LEADER 0x1`, `UnitIsPartyLeader`'s
// descriptor leg — leadership passing to the player you are targeting.
assert_eq!(
fired(Some(with(0)), with(0x1)),
fired(Some(with(0)), with(0x1), &moved),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()],
"arg1 is the unit token, and there is no arg2 (0x515e50 -> 0x703f50(id, \"%s\", token))"
);
// …and back down. The reference tests the XOR-diff, not the new value.
assert_eq!(
fired(Some(with(0x1)), with(0)),
fired(Some(with(0x1)), with(0), &moved),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()]
);
// **The control that proves the trigger is the RAW DWORD.** `PLAYER_FLAGS_HIDE_HELM 0x400`
// is a bit no `UnitState` field decodes — the fire at `0x5eea35` carries no bit test, so
// it announces this exactly as loudly as the leader bit. A version of this arm driven off
// `group_leader`/`ghost` passes every assertion above and fails here.
// it announces this exactly as loudly as the leader bit: the edge is the dword's, and the
// snapshot's decoded bools are never consulted.
assert_eq!(
fired(Some(with(0)), with(0x400)),
fired(Some(with(0)), with(0x400), &moved),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()],
"an undecoded bit still fires it — the handler tests no bit at all"
);
// First sight is the CREATE, and a CREATE runs no notify pass (1098 §4) — the same
// posture `UNIT_FLAGS` holds one field over.
// posture `UNIT_FLAGS` holds one field over: no edge, no event.
assert!(
fired(None, with(0x1)).is_empty(),
fired(None, with(0x1), &still).is_empty(),
"a unit's first snapshot is its create, not a transition"
);
// The control that stops all of the above passing by firing always.
assert!(fired(Some(with(0x1)), with(0x1)).is_empty());
assert!(fired(Some(with(0x1)), with(0x1), &still).is_empty());
}
/// **`UNIT_DYNAMIC_FLAGS` — an event benilla had never fired at all** (decision 2140).
@@ -2692,7 +2733,7 @@ mod tests {
/// assertion here and fails that one.
#[test]
fn unit_dynamic_flags_fires_per_token_on_any_bit_and_never_on_first_sight() {
let fired = |prev: Option<UnitState>, cur: UnitState| -> Vec<String> {
let fired = |prev: Option<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
@@ -2703,12 +2744,16 @@ mod tests {
"#,
)
.unwrap();
fire_transitions(&mut s, "target", prev.as_ref(), &cur);
fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const MOB: u64 = 0xF130_0000_0000_0007;
let moved = FieldEdges::of(&[(MOB, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS)]);
let still = FieldEdges::default();
let with = |dyn_flags: u32| UnitState {
exists: true,
has_object: true,
guid: MOB,
dynamic_flags: dyn_flags,
tapped: dyn_flags & 0x4 != 0,
tapped_by_player: dyn_flags & 0x8 != 0,
@@ -2718,20 +2763,20 @@ mod tests {
// The bit the corpus registers this event for: `0x4` TAPPED, the grey-bar state
// (`CT_UnitFrames/CT_TargetFrame.xml:200`, `TipBuddy/TipBuddy.lua:17`).
assert_eq!(
fired(Some(with(0)), with(0x4)),
fired(Some(with(0)), with(0x4), &moved),
vec!["UNIT_DYNAMIC_FLAGS:target".to_string()],
"arg1 is the unit token, and there is no arg2"
);
// The control that proves the trigger is the RAW DWORD: a bit this struct never decodes.
assert_eq!(
fired(Some(with(0)), with(0x2)),
fired(Some(with(0)), with(0x2), &moved),
vec!["UNIT_DYNAMIC_FLAGS:target".to_string()],
"an undecoded bit still fires it — the watch is a memcmp over the dword"
);
// The create block runs no notify pass (1098 §4), like both arms beside it.
assert!(fired(None, with(0x4)).is_empty());
assert!(fired(None, with(0x4), &still).is_empty());
// And the control that stops the rest passing by firing always.
assert!(fired(Some(with(0x4)), with(0x4)).is_empty());
assert!(fired(Some(with(0x4)), with(0x4), &still).is_empty());
}
/// **The two SELF-ONLY arms of the same handler, each on its own bits** — the half that was
@@ -2748,6 +2793,7 @@ mod tests {
const FIELD_PLAYER_FLAGS: u16 = 190;
let mut app = App::new();
app.add_message::<FieldChanged>();
app.init_resource::<Selection>()
.init_resource::<UnitFeedState>()
.init_resource::<NameCache>()
@@ -3353,6 +3399,7 @@ mod tests {
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::ui_guild::GuildState>()
.init_resource::<InteractNpc>();
app.add_message::<FieldChanged>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
+151
View File
@@ -0,0 +1,151 @@
//! The client's **one** `rand()` stream — the MSVC-CRT LCG at `0x7400e5`.
//!
//! **It lives down here, below both sides of decision 1160's line, because both sides draw from
//! it** — `trace`'s placement argument, verbatim, for the same reason. The engine's placed-doodad
//! host and the game's creature, GameObject and portrait-booth arms all roll a variation off this
//! sequence, and in the reference they roll it off the *same cell*: `rand` reads and rewrites
//! `[ptd+0x14]`, one per thread, and every animation arm in the client runs on the primary thread
//! (`_initterm` is pre-`WinMain`; the frame loop is inline, not spawned — wow-re
//! `net/scratch/crt-rand-stream-seeding.md` §4). A leaf with no dependencies can be reached by
//! everything; an owner at the top of the stack cannot be reached by the engine at all.
//!
//! **Why one stream and not one per consumer** (decision 0768, and 2301 which found the drift):
//! a shared sequence is what de-syncs a stand of identical props. Not a per-placement seed — just
//! consecutive draws. benilla had four independent streams for a while, three of them seeded 0
//! against the fourth's 1, while all four docstrings claimed to be this one; interleaving is the
//! whole mechanism, so splitting it is not a harmless refactor.
//!
//! **The seed is the reference's, and it is not 1.** `1` is real — the CRT's per-thread default at
//! `_initptd 0x40aca8`, which is what [`AnimRng::default`] returns — but the client overwrites it
//! during CRT static initialization, before `WinMain`: `_initterm` slot **3691** (`.data
//! 0x82a9ac` → the `_dynamic_initializer_for_*` trampoline `0x5b7ff0` → `0x5b8000`) calls
//! `0x5d1c70`, which calls `srand(GetTickCount())` at `0x5d1c8b`. So the reference's variation
//! sequence differs on every run, and [`AnimRng::seed_for_session`] is where benilla says the same
//! thing. Byte-VERIFIED by a wow-re §5 round; recorded in
//! `net/scratch/crt-rand-stream-seeding.md`.
//!
//! **Sequence parity with the reference is not achievable and is not the goal.** Where the stream
//! stands at any given arm depends on every intervening draw — the noise table `Model2::Initialize`
//! builds, the sky LUT, every `CParticleEmitter2` constructor, and FrameXML's own `random()` (bound
//! as a bare global by the embedded `compat.lua`, and called in ordinary play). What benilla
//! reproduces is the *structure*: one stream, wall-clock seeded, shared, non-repeating across runs.
use bevy::prelude::Resource;
/// The shared `rand()` state — see the module note. Seeded by [`Self::seed_for_session`] at
/// startup; [`Default`] is the CRT's own pre-`srand` value so a test or a headless build that
/// never seeds still walks a defined sequence.
#[derive(Resource)]
pub struct AnimRng(u32);
impl Default for AnimRng {
fn default() -> Self {
Self(1) // `_initptd 0x40aca8` — the CRT's per-thread default, before `srand` runs
}
}
impl AnimRng {
/// One `rand()` draw: `seed = seed·214013 + 2531011`, result `(seed >> 16) & 0x7fff` — the
/// closed range `[0, 32767]`, 32768 equiprobable outcomes, taken with no scale, mask, modulo
/// or divide (`0x7400ed`-`0x740101`).
pub fn draw(&mut self) -> u16 {
self.0 = self.0.wrapping_mul(214_013).wrapping_add(2_531_011);
((self.0 >> 16) & 0x7fff) as u16
}
/// The play-window's **replay count** `R = max(1, min + ((rand()·(max−min)) >> 15))` — the
/// reference's `windowHi = now + span·R` (`0x712692`-`0x7126cd`). `replay = (0, 0)`, the
/// overwhelming majority, always yields `R = 1`: one loop per window, so the variation
/// re-rolls every single pass.
///
/// The draw is taken **unconditionally**, even when it cannot change the answer: it is one
/// sub-expression of the reference's formula, so the shared stream has to advance the same way
/// whatever the span. A caller that skips it de-phases every later draw in the process.
pub fn replay_count(&mut self, replay: (u32, u32)) -> u32 {
let (lo, hi) = replay;
let r = lo + ((u64::from(self.draw()) * u64::from(hi.saturating_sub(lo))) >> 15) as u32;
r.max(1)
}
/// The reference's `srand(GetTickCount())` from CRT static init (module note) — a **wall-clock
/// seed, once per process**, so a fresh session does not replay the last one's variations.
///
/// `deterministic` is the capture carve-out and the only place benilla differs: a golden frame
/// has to be reproducible, so a capture run keeps the CRT's own pre-`srand` value. That is the
/// same trade every other sim in the harness makes, at the one site that knows a capture is
/// running rather than smeared through the lanes that draw.
pub fn seed_for_session(&mut self, deterministic: bool) {
if deterministic {
return;
}
// `GetTickCount` is milliseconds since boot; the wall clock's sub-second span is the same
// kind of number and is what a non-Windows host can offer.
self.0 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(1, |d| d.as_millis() as u32);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The LCG itself, against the bytes at `0x7400ed`-`0x740101` — and against the CRT's own
/// documented sequence from seed 1, which is the one value a reader can check anywhere.
#[test]
fn the_draw_is_the_crt_lcg() {
let mut rng = AnimRng::default();
let first: Vec<u16> = (0..6).map(|_| rng.draw()).collect();
assert_eq!(first, vec![41, 18_467, 6_334, 26_500, 19_169, 15_724]);
// Every draw is inside the closed range the `and eax,0x7fff` produces.
assert!(first.iter().all(|&r| r <= 0x7fff));
}
/// `(0, 0)` — the overwhelming majority of shipped sequences — is one pass, and it still costs
/// a draw. The second half is the part a "tidy" rewrite breaks: skipping the draw when it
/// cannot change the answer de-phases every later roll in the process.
#[test]
fn a_zero_replay_pair_is_one_pass_and_still_advances_the_stream() {
let mut rng = AnimRng::default();
assert_eq!(rng.replay_count((0, 0)), 1);
let mut bare = AnimRng::default();
bare.draw();
assert_eq!(
rng.draw(),
bare.draw(),
"the (0,0) window drew exactly once"
);
}
/// The window scales with the range, and never below one pass.
#[test]
fn a_replay_range_scales_the_window() {
let mut rng = AnimRng::default();
// roll 41 over a 0..8 range: 41·8 >> 15 = 0, so the floor still applies.
assert_eq!(rng.replay_count((0, 8)), 1);
// A `min` above zero is the floor, whatever the roll.
let mut rng = AnimRng::default();
assert!(rng.replay_count((3, 3)) == 3);
// An inverted pair cannot underflow the subtraction.
let mut rng = AnimRng::default();
assert_eq!(rng.replay_count((2, 1)), 2);
}
/// A seeded session does not replay the default sequence — the whole point of
/// `srand(GetTickCount())` — and a capture run does.
#[test]
fn a_capture_keeps_the_fixed_seed_and_a_session_does_not() {
let mut capture = AnimRng::default();
capture.seed_for_session(true);
let mut fixed = AnimRng::default();
assert_eq!(capture.draw(), fixed.draw());
let mut live = AnimRng::default();
live.seed_for_session(false);
assert_ne!(
live.0,
AnimRng::default().0,
"a live session seeds off the wall clock"
);
}
}
+3
View File
@@ -26,6 +26,9 @@ pub mod materials;
pub mod minimap_grid;
mod spatial_cache;
pub mod trace;
mod anim_rng;
pub use anim_rng::AnimRng;
pub use spatial_cache::SpatialCache;
mod world_assets;
pub use world_assets::*;
@@ -128,6 +128,63 @@ fn a_gameobjects_slots_bake_different_loops() {
);
}
/// **And slot 1 is REACHABLE — the other half of the same asset's story.** A per-sequence UV loop
/// only ever runs if the arm picks that sequence, so "slot 1 sweeps the sheet" is half a fact: the
/// bubbling is live exactly as often as the weighted variation walk lands on take 1.
///
/// The pool's two takes are one `Stand` (`AnimationData.dbc` id 0) chain — and a GameObject whose
/// model owns *none* of the door-family ids collapses its substate to Stand and re-arms it every
/// window, with a fresh `variationIdx = -1` roll each time (wow-re `gameobject-anim-arm.md`
/// §6c/§6d; `crate::go_anim`'s rest arm). So these two frequencies are the duty cycle of every
/// blood pool in the Plaguelands: `roll < 16384` takes the still sheet, `16384..=32766` takes the
/// bubbling one, and the single leftover draw (`32767`) exhausts the chain back to the head — the
/// authored convention, frequencies summing to 32767 against 32768 outcomes.
///
/// It is pinned here rather than left to arithmetic because the failure it guards is silent: an
/// asset re-read that dropped `frequency` to 0, or a walk that took the head, would leave this
/// file's neighbour above passing unchanged while every pool in the game went still.
#[test]
fn the_bubbling_take_is_half_the_pools_duty_cycle() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file("World\\Lordaeron\\Plagueland\\PassiveDoodads\\BloodOfHeroes\\BloodOfHeroes.m2")
.expect("in the chain");
let seqs = benilla_formats::parse_m2_animations(&bytes);
assert_eq!(
seqs.len(),
2,
"one two-take variation chain and nothing else"
);
assert!(
seqs.iter().all(|s| s.anim_id == 0),
"both takes are Stand — which is what the §2c collapse-to-0 leg arms"
);
let freqs: Vec<u16> = seqs.iter().map(|s| s.frequency).collect();
assert_eq!(
freqs,
vec![16_384, 16_383],
"the still sheet and the bubbling one, at even odds"
);
// The walk is `roll < freq` (strict, unsigned) node by node, else `roll -= freq` — so the
// counts below ARE the duty cycle, not an approximation of it.
let bubbling = (0u32..32_768)
.filter(|&roll| {
let mut roll = roll;
freqs.iter().position(|&f| {
let win = roll < u32::from(f);
roll = roll.saturating_sub(u32::from(f));
win
}) == Some(1)
})
.count();
assert_eq!(
bubbling, 16_383,
"the bubbling take wins 16383 of 32768 draws — 49.997 %, re-rolled every 3.3 s window"
);
}
/// **The same shape on the TINT channel**, and worse: four of the corpus's five animated entity
/// tints bake *nothing* in file slot 0, so `rgb_anim` is `None` for them and a shared material can
/// only ever seed white however faithfully it is ticked. `G_FreezingTrap` is the one with play
+1
View File
@@ -17,6 +17,7 @@ pub use events::{
decode, CharAction, EntityKind, LoginRefusal, LoginStage, MoveSpeeds, Poll, SessionEnd,
SessionEvent,
};
pub use messages::field;
pub use messages::{
AttackSwingError, CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo,
JumpInfo, MonsterMoveFacing, MoveMode, MoverState, ObjectFields, OwnerFallback, RelayVerb,
@@ -259,6 +259,7 @@ pub use trade::{
};
pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell};
pub use tutorial::{tutorial_flag, TutorialFlags};
pub use update_object::field;
pub use update_object::{
power_display_scale, quest_slot_state, CorpseLook, CreateSpline, MovementBlock, MoverState,
Object, ObjectFields, ObjectType, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot,
@@ -17,7 +17,7 @@ const FIELD_GAMEOBJECT_FLAGS: u16 = 9;
// spawn's local-rotation quaternion (x, y, z, w), 4 floats. The type-11 transport evaluator
// rotates its keyframe offsets by this (decision 0438 phase 3's second consumer).
const FIELD_GAMEOBJECT_ROTATION: u16 = 10;
const FIELD_GAMEOBJECT_STATE: u16 = 14;
pub const FIELD_GAMEOBJECT_STATE: u16 = 14;
const FIELD_GAMEOBJECT_POS_X: u16 = 15;
const FIELD_GAMEOBJECT_POS_Y: u16 = 16;
const FIELD_GAMEOBJECT_POS_Z: u16 = 17;
@@ -32,6 +32,11 @@ const FIELD_GAMEOBJECT_FACTION: u16 = 20;
const FIELD_GAMEOBJECT_TYPE_ID: u16 = 21;
// GAMEOBJECT_LEVEL = OBJECT_END(6) + 0x10 (vmangos UpdateFields_1_12_1.h:317).
const FIELD_GAMEOBJECT_LEVEL: u16 = 22;
/// `CORPSE_FIELD_DYNAMIC_FLAGS` = `OBJECT_END(6) + 0x1E` (vmangos `UpdateFields_1_12_1.h`) — bit 0
/// is the lootable-insignia flag [`ObjectFields::corpse_lootable`] reads and the loot-art arm
/// watches. Named because a watch keys on the raw index, and `36` is `UNIT_FIELD_BYTES_0` on a
/// unit: the same number means two things, which is why a field edge carries its object class.
pub const FIELD_CORPSE_DYNAMIC_FLAGS: u16 = 36;
// UNIT descriptor fields (verified against wow-5875-re object-layer node, build 5875; FLAGS /
// COMBATREACH / DYNAMIC_FLAGS / NPC_FLAGS additionally cross-checked against the cursor RE's client
// offsets `+0xa0`/`+0x1f0`/`+0x224`/`+0x234` and vmangos `UpdateFields_1_12_1.h`).
@@ -64,31 +69,31 @@ const FIELD_UNIT_CREATEDBY: u16 = 14;
/// caster itself). Public, so an **observer** renders another unit's channel loop from this field
/// pair, not from the self-only `MSG_CHANNEL_START`/`UPDATE` UI packets (decision 0099 phase 1).
const FIELD_UNIT_CHANNEL_OBJECT: u16 = 20;
const FIELD_UNIT_HEALTH: u16 = 22;
pub const FIELD_UNIT_HEALTH: u16 = 22;
/// `UNIT_FIELD_POWER1` — first of 5 consecutive power slots (mana, rage, focus, energy, happiness);
/// `MAXPOWER1..5` sit symmetrically after `MAXHEALTH` (vmangos `UpdateFields_1_12_1.h`).
const FIELD_UNIT_POWER1: u16 = 23;
const FIELD_UNIT_MAXHEALTH: u16 = 28;
pub const FIELD_UNIT_MAXHEALTH: u16 = 28;
const FIELD_UNIT_MAXPOWER1: u16 = 29;
const FIELD_UNIT_LEVEL: u16 = 34;
const FIELD_UNIT_FACTIONTEMPLATE: u16 = 35;
pub const FIELD_UNIT_LEVEL: u16 = 34;
pub const FIELD_UNIT_FACTIONTEMPLATE: u16 = 35;
const FIELD_UNIT_BYTES_0: u16 = 36;
const FIELD_UNIT_BYTES_1: u16 = 138;
const FIELD_UNIT_FLAGS: u16 = 46;
pub const FIELD_UNIT_BYTES_1: u16 = 138;
pub const FIELD_UNIT_FLAGS: u16 = 46;
// The aura block — four parallel arrays, all PUBLIC, so every unit we can see streams its full set
// (vmangos `UpdateFields_1_12_1.h:67-70`, `OBJECT_END(6) + 0x29/0x59/0x5F/0x6B`). wow-re places the
// same arrays in the client at `descriptor + 0xa4` / `+0x164` (= `0x29`/`0x59` × 4 — the same field
// indices, derived independently from the binary: `object-layer/scratch/w2d1.md`, VERIFIED).
// Duration and caster are NOT here and are not in any other field: duration reaches only the aura's
// own target, over `SMSG_UPDATE_AURA_DURATION`; a caster guid never reaches anyone (decision 0255).
const FIELD_UNIT_AURA: u16 = 47;
pub const FIELD_UNIT_AURA: u16 = 47;
/// Nibble-packed, 8 slots per `u32` (`SpellAuraHolder::SetAuraFlag`, `SpellAuras.cpp:7456-7462`).
const FIELD_UNIT_AURAFLAGS: u16 = 95;
pub const FIELD_UNIT_AURAFLAGS: u16 = 95;
/// Byte-packed, 4 slots per `u32` — the *caster's level* (`SetAuraLevel`, `SpellAuras.cpp:7484`).
const FIELD_UNIT_AURALEVELS: u16 = 101;
/// Byte-packed, 4 slots per `u32`, holding `stack - 1` ("field expect count-1 for proper amount
/// show" — `UpdateAuraApplication`, `SpellAuras.cpp:7500-7507`).
const FIELD_UNIT_AURAAPPLICATIONS: u16 = 113;
pub const FIELD_UNIT_AURAAPPLICATIONS: u16 = 113;
/// `UNIT_FIELD_AURASTATE` (`OBJECT_END(6) + 0x77` = 125 — vmangos `UpdateFields_1_12_1.h`) — the
/// aura-state bit set the usable walk's CasterAuraState/TargetAuraState legs test with
/// `1 << (state-1)` (wow-re §2a; the client reads its parsed-descriptor copy at
@@ -119,7 +124,7 @@ const FIELD_UNIT_NATIVEDISPLAYID: u16 = 132;
/// unit is riding, `0`/absent = not mounted. The server writes it on aura-78 apply and zeroes it
/// on every dismount path (`Unit::Mount`/`Unmount`) — this field, not the aura, IS the mounted
/// state (`Unit::IsMounted` reads it back; no flag or bytes field accompanies it). Decision 0441.
const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133;
pub const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133;
/// `UNIT_FIELD_PETNUMBER` (`OBJECT_END(6) + 0x85` = 139, PUBLIC — vmangos
/// `UpdateFields_1_12_1.h:84`; byte-verified client-side as `[unit+0x110]+0x214` = relative index
/// `0x85`, the second gate inside the rank getter `0x605620` — decision 0782). Non-zero ⇒ this
@@ -146,10 +151,10 @@ const FIELD_UNIT_PETNEXTLEVELEXP: u16 = 142;
/// `[unit+0x110]+0x23c` / `+0x23e`: `GetPetTrainingPoints 0x4be790` pushes the **high** word first
/// and the low word second, which `PetPaperDollFrame.lua` names `totalPoints, spent`.
const FIELD_UNIT_TRAINING_POINTS: u16 = 149;
const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143;
pub const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143;
/// `UNIT_CHANNEL_SPELL` (idx 144, `OBJECT_END(6) + 0x8A`, PUBLIC — VERIFIED vmangos
/// `UpdateFields_1_12_1.h:89`) — the spell id a unit is channeling (`0` = not channeling).
const FIELD_UNIT_CHANNEL_SPELL: u16 = 144;
pub const FIELD_UNIT_CHANNEL_SPELL: u16 = 144;
/// `UNIT_CREATED_BY_SPELL` (idx 146, `OBJECT_END(6) + 0x8C`, PUBLIC — vmangos
/// `UpdateFields_1_12_1.h`) — the spell that summoned this unit (`0` = not summoned by one). The
/// **first** of the three gates on the reference's feed-pet path `0x6ea1e0`: a unit with no
@@ -159,7 +164,7 @@ const FIELD_UNIT_CHANNEL_SPELL: u16 = 144;
/// already pins both ends of a contiguous run — `PETEXPERIENCE` 141, `PETNEXTLEVELEXP` 142,
/// `DYNAMIC_FLAGS` 143, `CHANNEL_SPELL` 144, `MOD_CAST_SPEED` 145, then this, then `NPC_FLAGS` 147.
const FIELD_UNIT_CREATED_BY_SPELL: u16 = 146;
const FIELD_UNIT_NPC_FLAGS: u16 = 147;
pub const FIELD_UNIT_NPC_FLAGS: u16 = 147;
/// `UNIT_NPC_EMOTESTATE` (`OBJECT_END + 0x8E`; PUBLIC — VERIFIED vmangos `UpdateFields_1_12_1.h:93`)
/// — the unit's looping **state emote** as an `Emotes.dbc` id (dance, NPC cooking/working flavor).
/// For a player it's the server echo of a state-class text emote (`HandleEmote`,
@@ -288,7 +293,23 @@ const FIELD_PLAYER_BYTES_3: u16 = 195;
// (MAX_QUEST_LOG_SIZE, vmangos `QuestDef.h:34`) × 3 fields (MAX_QUEST_OFFSET, `Player.h:439-444`):
// +0 quest id, +1 packed objective counters + state byte, +2 timer. The `_1` id field is GROUP_ONLY
// and the `_2` pair PRIVATE — both always stream for our own player, the exact consumer (the log).
const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198;
pub const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198;
/// The descriptor indices a field watch names (decision 2297) — the very constants the accessors
/// above read through, re-exported under one roof so a watch and the accessor it mirrors cannot
/// disagree on a number. Anything watched lives here; anything only read stays private.
pub mod field {
/// The one bit a watcher tests on a raw `UNIT_DYNAMIC_FLAGS` edge (the feign-death set edge).
pub use super::unit::UNIT_DYNFLAG_DEAD;
pub use super::{
FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_STATE, FIELD_PLAYER_FIELD_COINAGE,
FIELD_PLAYER_FLAGS, FIELD_PLAYER_INV_SLOT_HEAD, FIELD_PLAYER_QUEST_LOG_1_1,
FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_AURA, FIELD_UNIT_AURAAPPLICATIONS,
FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BYTES_1, FIELD_UNIT_CHANNEL_SPELL,
FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH,
FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH, FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS,
};
}
/// The number of `PLAYER_QUEST_LOG` slots (vmangos `MAX_QUEST_LOG_SIZE`, `QuestDef.h:34`).
pub const PLAYER_QUEST_LOG_SLOTS: u8 = 20;
@@ -330,7 +351,7 @@ const FIELD_CONTAINER_SLOT_1: u16 = 50; // ITEM_END + 0x2; 36 slots × 2
// descriptor dump: with these bases One's shirt/legs/feet/mainhand/offhand/bags land on exactly
// the slots the character visibly wears (visible-item cross-check below).
const FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR: u16 = 258; // UNIT_END + 0x46; 12 fields per slot
const FIELD_PLAYER_INV_SLOT_HEAD: u16 = 486; // 23 slots × 2 (equipment 0–18, bag bags 19–22)
pub const FIELD_PLAYER_INV_SLOT_HEAD: u16 = 486; // 23 slots × 2 (equipment 0–18, bag bags 19–22)
const FIELD_PLAYER_PACK_SLOT_1: u16 = 532; // 16 slots × 2 (the backpack)
const FIELD_PLAYER_BANK_SLOT_1: u16 = 564; // 24 slots × 2
const FIELD_PLAYER_BANK_BAG_SLOT_1: u16 = 612; // 564 + 24×2; 6 bag slots × 2 (item guids)
@@ -360,7 +381,7 @@ const FIELD_PLAYER_BUYBACK_TIMESTAMP_1: u16 = 1238; // 12 × u32 — the client'
// stale hex comment reads 0x492 = 1170 (decision 0081 quoted "field 1170" from it — 6 low).
// Cross-checked against the corrected neighbours: 486 (INV_SLOT_HEAD) + (0x3DC − 0x12A = 690) = 1176;
// 564 (BANK_SLOT_1) + (0x3DC − 0x178 = 612) = 1176. Both agree.
const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176;
pub const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176;
// PLAYER_XP / PLAYER_NEXT_LEVEL_XP (INT, PRIVATE — sent only for our own player, the exact consumer:
// the MainMenuBar XP bar). Derived by the same server enum arithmetic as COINAGE above:
// PLAYER_XP = UNIT_END(0xBC = 188) + 0x210(528) = 716; PLAYER_NEXT_LEVEL_XP = 717. Cross-checked
@@ -472,7 +493,7 @@ const FIELD_PLAYER_SELF_RES_SPELL: u16 = 1224;
// PLAYER_FLAGS = UNIT_END(188) + 0x2 (VERIFIED vmangos `UpdateFields_1_12_1.h:120`; the duel
// arbiter guid takes +0x0/+0x1). Bit 0x10 = PLAYER_FLAGS_GHOST (`Player.h:319`), set/cleared by
// the ghost aura 8326's HandleAuraGhost at release/resurrect (decision 0308 §1).
const FIELD_PLAYER_FLAGS: u16 = 190;
pub const FIELD_PLAYER_FLAGS: u16 = 190;
// PLAYER_DUEL_ARBITER = UNIT_END(188) + 0x0 (GUID, PUBLIC), PLAYER_DUEL_TEAM = UNIT_END + 0x8
// (INT, PUBLIC) — VERIFIED vmangos `UpdateFields_1_12_1.h:119,126`. The arbiter opens the player
// block, which is why the real client caches a pointer to that base (`[player+0xe68]`) and indexes
@@ -825,7 +846,7 @@ impl ObjectFields {
/// handler) and the `CMSG_LOOT` route off the corpse's interact slot (wow-re
/// `loot-anim-leg.md`).
pub fn corpse_lootable(&self) -> bool {
self.get_u32(36).unwrap_or(0) & 0x01 != 0
self.get_u32(FIELD_CORPSE_DYNAMIC_FLAGS).unwrap_or(0) & 0x01 != 0
}
/// `CORPSE_FLAG_LOOTABLE` (`0x20`, [`Self::corpse_flags`] bit 5) — the flag that makes a body's
@@ -958,15 +979,82 @@ impl ObjectFields {
/// dropped to zero since the last stream (an aura that expired out of view, durability that
/// broke) — exactly the absent-vs-zero bug the `created` flag exists to kill.
pub fn merge(&mut self, delta: ObjectFields) {
self.merge_diff(delta, |_, _, _| {});
}
/// [`Self::merge`] that also reports every dword the fold MOVED — the reference's
/// `CMirrorHandler` notify pass (`0x465330`: memcmp live-vs-shadow over each registered span,
/// fire only on a difference, the callback handed the OLD value — wow-re `object-layer.md`),
/// done in the one write that already holds both sides (decision 2297).
///
/// `changed(index, old, new)` runs once per moved index, ascending, and only for a real
/// difference: a field re-sent with the value it already had reports nothing, exactly as the
/// reference's memcmp fires nothing. `old` reads an absent field as `0`, the reference's
/// zero-initialised array. A re-CREATE snapshot reports every index the replace moved, on
/// either side's mask — the reference's in-place refresh of an already-live guid goes down
/// its values-only path and notifies too — while a FIRST create is never merged at all (the
/// caller seeds the store with it), which is where the reference's create-time
/// notify-suppress flag lives here.
pub fn merge_diff(&mut self, delta: ObjectFields, mut changed: impl FnMut(u16, u32, u32)) {
if delta.descriptor_end != 0 {
let words = self.present.len().max(delta.present.len());
for word in 0..words {
let mask = self.present.get(word).copied().unwrap_or(0)
| delta.present.get(word).copied().unwrap_or(0);
if mask == 0 {
continue;
}
for bit in 0..32u16 {
if mask & (1u32 << bit) == 0 {
continue;
}
let index = word as u16 * 32 + bit;
let (old, new) = (
self.get_raw(index).unwrap_or(0),
delta.get_raw(index).unwrap_or(0),
);
if old != new {
changed(index, old, new);
}
}
}
*self = delta;
} else {
for (index, value) in delta.raw_fields() {
let old = self.get_raw(index).unwrap_or(0);
if old != value {
changed(index, old, value);
}
self.insert(index, value);
}
}
}
/// The object class this descriptor was CREATED as, read back off its own length ([`descriptor_len`]
/// is injective over the eight classes) — `None` for a bare `Values` delta or a hand-built
/// fixture that never went through [`Self::into_created`]. Distinct from
/// [`Self::object_type`], the typemask read, which cannot tell a corpse from an item: a field
/// edge carries THIS because a raw index means different things per class (`36`: a unit's
/// `BYTES_0`, a corpse's `DYNAMIC_FLAGS`).
pub fn created_as(&self) -> Option<ObjectType> {
const ALL: [ObjectType; 8] = [
ObjectType::Object,
ObjectType::Item,
ObjectType::Container,
ObjectType::Unit,
ObjectType::Player,
ObjectType::GameObject,
ObjectType::DynamicObject,
ObjectType::Corpse,
];
(self.descriptor_end != 0)
.then(|| {
ALL.into_iter()
.find(|&t| descriptor_len(t) == self.descriptor_end)
})
.flatten()
}
/// Whether this carried no fields — the codec skips emitting an empty `Values` delta.
pub fn is_empty(&self) -> bool {
self.present.iter().all(|&w| w == 0)
@@ -735,6 +735,72 @@ fn merge_overlays_deltas_and_replaces_on_recreate() {
);
}
/// The field edges a merge reports (decision 2297) — the reference's per-field notifier, whose
/// callback fires on a memcmp DIFFERENCE and receives the old value: only the dwords that moved,
/// ascending, absent reading as `0` on either side, and a value re-sent unchanged reporting
/// nothing at all.
#[test]
fn merge_diff_reports_each_moved_dword_once_with_its_old_value() {
let mut store =
ObjectFields::from_pairs(&[(22, 100), (28, 100), (34, 9)]).into_created(ObjectType::Unit);
let mut edges = Vec::new();
// Health drops, level rises, max-health re-sent as it was, a never-seen field appears.
store.merge_diff(
ObjectFields::from_pairs(&[(34, 10), (22, 40), (28, 100), (46, 8)]),
|i, old, new| edges.push((i, old, new)),
);
assert_eq!(
edges,
vec![(22, 100, 40), (34, 9, 10), (46, 0, 8)],
"moved dwords only, ascending; absent-before reads 0; an unchanged resend is silent"
);
assert_eq!(store.unit_health(), Some(40), "the delta still lands");
assert_eq!(store.unit_flags(), 8);
}
/// A re-CREATE is the reference's in-place refresh of a live guid — it notifies too, and here it
/// notifies over BOTH masks: a field the fresh snapshot omits dropped to zero, and that is an edge
/// the store's replace makes visible (`merge_overlays_deltas_and_replaces_on_recreate` above).
#[test]
fn merge_diff_on_a_recreate_reports_the_replace_on_both_masks() {
let mut store =
ObjectFields::from_pairs(&[(3, 2264), (46, 30), (47, 40)]).into_created(ObjectType::Item);
let mut edges = Vec::new();
store.merge_diff(
ObjectFields::from_pairs(&[(3, 2264), (47, 40), (12, 7)]).into_created(ObjectType::Item),
|i, old, new| edges.push((i, old, new)),
);
assert_eq!(
edges,
vec![(12, 0, 7), (46, 30, 0)],
"the omitted durability is a 30 → 0 edge; the identical entry and max are silent"
);
assert_eq!(store.item_durability(), Some(0));
}
/// The class a store was created as reads back off its length — every class round-trips, and a
/// bare delta (no create seen) answers nothing, which is what keeps a field edge honest about
/// which block its index belongs to.
#[test]
fn created_as_reads_back_off_the_created_length() {
for t in [
ObjectType::Object,
ObjectType::Item,
ObjectType::Container,
ObjectType::Unit,
ObjectType::Player,
ObjectType::GameObject,
ObjectType::DynamicObject,
ObjectType::Corpse,
] {
assert_eq!(
ObjectFields::default().into_created(t).created_as(),
Some(t)
);
}
assert_eq!(ObjectFields::from_pairs(&[(22, 1)]).created_as(), None);
}
/// The `DYNAMICOBJECT_*` accessors against the exact live capture (vmangos, 2026-07-30, the
/// B132 follow-up's `--groundfx 10` run): Blizzard's dynobj create — caster guid 26, BYTES 1
/// (area spell), SPELLID 10, RADIUS 8.0, the cast point in POS, FACING never sent.
@@ -8,7 +8,7 @@ use super::*;
/// reads it as "this body is a corpse" wherever it reads health for display; the server sets it for
/// **feign death** and for `CREATURE_FLAG_EXTRA_APPEAR_DEAD` spawns, in both cases with health left
/// intact. Every consumer goes through [`ObjectFields::unit_reads_dead`] (decision 1022).
const UNIT_DYNFLAG_DEAD: u32 = 0x20;
pub const UNIT_DYNFLAG_DEAD: u32 = 0x20;
/// `UNIT_STAND_STATE_DEAD` (vmangos `UnitDefines.h:109`) — the third leg of the client's
/// reads-dead predicate `0x605f90`. vmangos never writes it, so it is inert against our server.
@@ -12,6 +12,7 @@ use crate::wire::{capacity_hint, read_packed_guid, read_u32_le, read_u8};
mod fields;
mod movement;
pub use fields::field;
pub use fields::{
power_display_scale, quest_slot_state, CorpseLook, ObjectFields, OwnerFallback,
PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK,
+12 -68
View File
@@ -46,44 +46,6 @@ pub use mat_anim::{
};
pub(crate) use mat_anim::{register_uv, UvLoop};
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
/// (wow-re `doodad-anim-host.md` §5, decision 0768). Every doodad's variation roll draws from **one**
/// shared stream, which is what de-syncs a stand of identical props: not a per-placement seed, just
/// consecutive draws off one sequence.
///
/// This replaced a position-derived hash. The hash de-synced instances correctly but was *permanent* —
/// the same placement rolled the same variation on every re-stream and every run — which is exactly
/// how the Blasted Lands lightning ended up striking from one fixed spot for ever instead of wandering
/// the Tainted Scar (bug B63's residual). Captures are unaffected: [`spawn_anim_host`] returns `None`
/// under a capture scenario, so no doodad animates in a golden frame and determinism is untouched.
#[derive(Resource)]
pub(crate) struct AnimRng(u32);
impl Default for AnimRng {
fn default() -> Self {
Self(1) // the CRT's own initial seed
}
}
impl AnimRng {
/// One `rand()` draw: `seed = seed·214013 + 2531011`, result `(seed >> 16) & 0x7fff`.
fn draw(&mut self) -> u16 {
self.0 = self.0.wrapping_mul(214_013).wrapping_add(2_531_011);
((self.0 >> 16) & 0x7fff) as u16
}
/// The play-window's replay count `R = max(1, min + ((rand()·(max−min)) >> 15))` — the reference's
/// `windowHi = now + span·R` (wow-re §5). `replay = (0, 0)`, the overwhelming majority and the
/// lightning's own value, always yields `R = 1`: one loop per window, so the variation re-rolls
/// every single pass. The draw is taken unconditionally — it is one sub-expression of the
/// reference's formula, so the shared stream advances the same way whatever the span.
fn replay_count(&mut self, replay: (u32, u32)) -> u32 {
let (lo, hi) = replay;
let r = lo + ((u32::from(self.draw()) * hi.saturating_sub(lo)) >> 15);
r.max(1)
}
}
/// What a placed doodad model animates — decision 0130's content gate, decided per model at spawn.
pub enum DoodadAnimTier<'a> {
/// No animated bone channel: today's static path, untouched (no joints, no player — the ~90%
@@ -439,7 +401,7 @@ impl DoodadAnimHost {
/// window rolled.
fn reroll_doodad_variation(
time: Res<Time>,
mut rng: ResMut<AnimRng>,
mut rng: ResMut<benilla_assets::AnimRng>,
mut hosts: Query<(
&mut DoodadAnimHost,
&ModelAnimations,
@@ -654,7 +616,16 @@ impl Plugin for DoodadAnimPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UvAnimMaterials>();
app.init_resource::<TintAnimMaterials>();
app.init_resource::<AnimRng>();
// The client's ONE `rand()` stream (`benilla_assets::AnimRng`), seeded here because this
// is where the engine boots and where `deterministic_run` is knowable — the reference's
// own `srand(GetTickCount())` runs once per process from CRT static init, pre-`WinMain`
// (wow-re `net/scratch/crt-rand-stream-seeding.md`; decision 2301). A capture keeps the
// CRT's pre-`srand` value, so golden frames stay reproducible.
app.init_resource::<benilla_assets::AnimRng>();
let deterministic = crate::dev_state::deterministic_run();
app.world_mut()
.resource_mut::<benilla_assets::AnimRng>()
.seed_for_session(deterministic);
// The re-roll runs BEFORE the draw gate: a window that expires this frame must arm its new
// clip before the gate decides what to resume, or a host re-appearing on the same frame
// resumes the previous window's variation for one frame.
@@ -910,33 +881,6 @@ mod tests {
));
}
/// The client's `rand()` is the MSVC LCG, and its seed-1 stream is the textbook one. Pinning the
/// first draws keeps the weighted roll on the reference's actual sequence rather than on "some
/// uniform generator" (wow-re `_rand 0x7400e5`, decision 0768).
#[test]
fn the_anim_rng_is_the_reference_msvc_stream() {
let mut rng = AnimRng::default();
let first: Vec<u16> = (0..6).map(|_| rng.draw()).collect();
assert_eq!(first, vec![41, 18467, 6334, 26500, 19169, 15724]);
assert!(first.iter().all(|&v| v <= 0x7fff), "range is [0, 32767]");
}
/// `R = max(1, min + ((rand()·(max−min)) >> 15))`. `(0, 0)` — the overwhelming majority, and the
/// lightning's own value — pins to 1, so the window is exactly one loop and the variation
/// re-rolls every pass.
#[test]
fn the_replay_window_is_one_loop_for_the_common_case() {
let mut rng = AnimRng::default();
for _ in 0..32 {
assert_eq!(rng.replay_count((0, 0)), 1);
}
// A real range stays inside it, and never degenerates to 0 windows.
for _ in 0..64 {
let r = rng.replay_count((2, 5));
assert!((2..=5).contains(&r), "R = {r} outside [2, 5]");
}
}
/// The lightning's own chain, read off the bytes (`benilla-extract m2seq` on
/// `World\Generic\PassiveDoodads\ParticleEmitters\BlastedLandsLightningbolt01.M2`): two
/// variations of animation id 0, weights 31129 / 1638 — so slot 1, which is where all four
@@ -955,7 +899,7 @@ mod tests {
// would clobber the manual advance these tests need to step whole play-windows.
let mut app = App::new();
app.init_resource::<Time>();
app.init_resource::<AnimRng>();
app.init_resource::<benilla_assets::AnimRng>();
app.add_systems(Update, reroll_doodad_variation);
app
}