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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>()
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<'_, '_> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user