an item on the cursor is a sale, and an animation's sound has a place

- right-clicking a vendor with an item on the cursor sells it: `0x5df5d0` asks `GetCursorItem` before anything else and a non-zero answer takes the arm to `CMSG_SELL_ITEM` instead of `CMSG_LIST_INVENTORY`, where benilla sent the list unconditionally — the whole stack, to the clicked vendor, with no merchant-window gate anywhere in the arm; the clear is `SetCursorItem(0, …)` rather than `ClearCursor`, which fires `CURSOR_UPDATE` and leaves the source slot greyed until the server's inventory update (1914); and the NPC you are talking to is not clickable: `0x5f0130` refuses a right-click on the NPC whose window is already open, against a global with exactly two writers — the interaction setter, whose fourteen callers are every one a window opener, and its clear — so the first click always gets through and no double-click is eaten, where benilla re-sent the arm's opcode on every click (1866)
- a Hated or Hostile questgiver's `!` now disappears, every `!` blinks off and back on when you resurrect, and the minimap no longer dots a corpse, your own pet or an untrackable unit: the sweep tears down on HOSTILITY, not on the flag — `0x5eb134` is a convergent block reached from two branches, so three incompatible readings had each seen one path — and an escort giver's `!` clearing with its flag is the field watch instead, which B257 now rests on; the dots gain `0x4eaa90`'s three preconditions (1906); the quest log's rebuild is all-or-nothing — the reference makes every rebuild a no-op until its template misses are answered, so a cold log shows empty and then lands in one step, never our `"..."` row per in-flight quest — sorted level then title, case-insensitively, with the `id == 0` group first under the reference's own "Missing header! (quest designers)"; and `GetQuestLogTitle`'s `isHeader`/`isCollapsed` answer 1/nil, not booleans
- an animation event has a place, not just a time: the M2 event kernel (`0x719370`) places each fired key through its bone, and benilla carried the when and the what and never the where, so every animation-event sound played at the model root — 149 of 244 `$DSL` records sit off their model's origin, out to 67.6 yd on `Maraudon_Waterfall01.m2` — and the point belongs to the record, not the tag, since 75 models author one tag twice; doodad, GameObject and shake sounds now sound where the key is (1904); and the unit dispatcher's arms do not agree about the point they are handed — footsteps and emotes at the event point (the footstep's material foley stays at the origin), `$TRD` at the origin, `$CSD` at attachment 17 rather than its head-mounted records, `$CSS` at the event point on a hit and attachment 1 on a miss — with `0x623b90`'s attachment-else-bias resolver transcribed table and all (1915)
- `GetSkillLineInfo` out of range answers the reference's thirteen-value tuple, numbers in the rank slots, not a single nil — the stock skill window adds two of those slots unguarded straight off `SetSelectedSkill(0)`, so one nil raises the moment it drives us; one shape serves every out-of-range case because the bounds test at `0x4d3675` is a single unsigned compare, and our source had asserted the opposite (1919); the outbound wire is traced by name (`out`, mirroring `in`), a line pushed only after a successful write — a 5-second visit to the Goldshire poster reads as 87 lines (1901); the binding shape gate seats a player and checks the `Unit*` verbs it had been skipping, coverage 220 → 249, all agreeing with the reference (1911); and a UI capture scenario opts the UI in itself instead of photographing a UI-less world and exiting 0, the opt-in answering `false` in a player build that has no harness

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:51:17 +07:00
co-authored by Claude Fable 5.1
parent f0d1bacf0a
commit ef7140c294
52 changed files with 2747 additions and 300 deletions
+10 -34
View File
@@ -19,7 +19,7 @@
//! - **`SpellVisualKit` field 14** (`kit+0x38`, the `ShakeID` column) → once per kit play, via
//! [`SpellKitShake`] and [`fire_kit_shakes`], which carries the mechanism and the one deviation.
//! - **the `$SHK` animation event** → its payload *is* a group id, fired ungated at the event's own
//! bone-transformed point ([`event_point`]).
//! bone-transformed point — the fired key's own, carried on the event (decision 1904).
//!
//! **`$SHK` is decoded by exactly two handlers**, hanging off the GameObject (typemask `0x20`,
//! `0x5f3e20`) and DynamicObject (`0x40`, `0x5d58c0`) trampolines — the dword `0x4b485324` occurs
@@ -106,7 +106,7 @@ use benilla_formats::{CameraShake, CameraShakeCatalog, SpellShakeGroup};
use crate::creature_anim::{
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState, SpellKitShake,
};
use crate::entities::{BoneAttach, Creatures};
use crate::entities::Creatures;
use crate::net::{Embodied, NetEntity, ObjectStore, Spline};
use crate::player::ViewSubject;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
@@ -288,19 +288,13 @@ struct Shakes(CameraShakeCatalog);
fn fire_shakes(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&benilla_world::rig_anim::RigPose>,
)>,
units: Query<(&NetEntity, &GlobalTransform)>,
parents: Query<&ChildOf>,
roots: Query<(
Option<&ObjectStore>,
Option<&MovementState>,
Option<&NetEntity>,
)>,
joints: Query<&GlobalTransform>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
creatures: Option<Res<Creatures>>,
shakes: Option<Res<Shakes>>,
@@ -322,7 +316,7 @@ fn fire_shakes(
if !thud && !shk && footfall_side(&ev.ident).is_none() {
continue; // `$FSD` is the sound handler's; only the VISUAL channel shakes
}
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
let Ok((net, transform)) = units.get(ev.entity) else {
continue;
};
if shk {
@@ -341,8 +335,7 @@ fn fire_shakes(
// **No gates at all** — no distance, visibility, CVar or state test (the sibling
// `$DSL` arm and the footstep path both have them; this one has none). The position is
// the event's own bone-transformed point, not the object's origin.
let at = event_point(attach, pose, &joints, &ev.ident)
.unwrap_or_else(|| transform.translation());
let at = ev.pos.unwrap_or_else(|| transform.translation());
live.add_group(group, &shakes.0, at, now);
continue;
}
@@ -383,10 +376,11 @@ fn fire_shakes(
continue;
}
}
// The planted foot: the event's own marker through the live joint, exactly as the decal
// derives it. No marker/joint = the unit origin.
let foot = event_point(attach, pose, &joints, &ev.ident)
.unwrap_or_else(|| transform.translation());
// The planted foot: the FIRED key's own point, resolved once by the scanner exactly as
// the kernel snapshots it (decision 1904), not a by-4CC re-find of the marker table. The
// difference is real — 75 shipped models author some 4CC more than once at different
// points — and it is the same quantity the decal derives.
let foot = ev.pos.unwrap_or_else(|| transform.translation());
if footfall_culls(eye, foot) {
continue;
}
@@ -394,24 +388,6 @@ fn fire_shakes(
}
}
/// The world point an animation-event marker names on a live model — the planted foot for a
/// footfall tag, the authored shake point for `$SHK`. `None` when the model carries no marker for
/// the tag or has no live rig, and every caller falls back to the object's own origin.
///
/// This is the reference's own quantity: `placementMatrix · (boneMatrix[event.bone] ·
/// event.position)`, computed once by the M2 event kernel (`0x719370`) and carried by value in the
/// queue record it drains. The decal path derives it the same way, which is why the two share this.
fn event_point(
attach: Option<&BoneAttach>,
pose: Option<&benilla_world::rig_anim::RigPose>,
joints: &Query<&GlobalTransform>,
ident: &[u8; 4],
) -> Option<Vec3> {
let (a, p) = attach.zip(pose)?;
let (bone, offset) = a.markers.get(ident).copied()?;
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
}
/// Fire the camera shake a spell-visual kit's field 14 names — the **spell-side producer**
/// (decision 1849), the counterpart of `crate::sound::spell`'s kit-sound route.
///
+22 -1
View File
@@ -51,9 +51,10 @@ pub(super) fn seed_ui_fixture(
mut selection: ResMut<crate::target::Selection>,
mut player: ResMut<crate::player::Player>,
// Bundled: Bevy systems cap at 16 top-level params — a nested tuple is one param.
(mut actions, mut bank): (
(mut actions, mut bank, mut exit): (
ResMut<crate::ui_action::PlayerActions>,
ResMut<crate::ui_bank::BankOpen>,
MessageWriter<AppExit>,
),
) {
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
@@ -68,6 +69,22 @@ pub(super) fn seed_ui_fixture(
}
ctx.ui_seeded = true;
// **A UI capture with no script VM is not a capture — refuse it.** Every seed below opens its
// window by calling into the in-game UI, so with no VM they all fail the same way: a nil
// global, one `warn!` in a log full of pipeline chatter, a valid-looking PNG of a UI-less
// world, and exit 0. That is the false-negative shape `method.md` §6 exists to prevent, and it
// burned a session. `scenario_wants_ui` removed the cause (a `ui:` scenario no longer needs
// `WOW_CAPTURE_UI=1`); this is the tripwire for whatever else could leave the VM absent, and
// it exits non-zero the way the window-size refusal does (`video::warn_if_window_mismatch`).
if script.is_none() {
error!(
"capture: REFUSING this capture — scenario {:?} declares a UI fixture but no script VM exists, so its window cannot be opened and the shot would be a UI-less world wearing the scenario's name.",
scenario.name
);
exit.write(AppExit::error());
return;
}
// A creature guid whose entry bits (24–47) carry 90001 — the NameCache resolves vendor/NPC
// names by that entry, so inserting the name by entry makes the title path run for real.
const NPC_ENTRY: u32 = 90_001;
@@ -135,6 +152,10 @@ pub(super) fn seed_ui_fixture(
};
match fixture {
// Nothing to open — the UI being loaded (and `demo_unit_feed`'s synthetic player/target)
// IS the fixture. The `script.is_none()` refusal above still guards it: these scenarios
// photograph the player UI, so a run without a VM is as wrong for them as for any other.
UiFixture::Bare => {}
UiFixture::Merchant => {
names.insert_creature(
NPC_ENTRY,
+7 -3
View File
@@ -45,7 +45,7 @@
//! ## Running one capture by hand
//! **Run through Cargo — never the built binary directly:**
//! ```text
//! WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes \
//! WOW_CAPTURE=ui-unitframes \
//! WOW_CAPTURE_OUT=/tmp/shot.png cargo run -q -p benilla
//! ```
//! (`WOW_DATA` is only needed for a non-standard install — the client finds one in the project
@@ -60,8 +60,11 @@
//! compile time, which fixed the capture and left a binary that worked only on the machine that
//! built it. 1175 deleted the path instead: every shader is compiled into the binary and addressed
//! `embedded://<crate>/shaders/…`, so there is no asset root left to resolve wrongly.
//! `WOW_CAPTURE_UI=1` opts the player UI into the shot (off
//! by default so world baselines stay UI-free; omit it for world-only scenes). `WOW_CAPTURE=list`
//! A **`ui-*` scenario opts the player UI in on its own** — it declares a `ui:` fixture, which is
//! this table saying the window is the subject ([`scenarios::ui_opted_in`], and read its doc
//! for why that is a correctness fix and not a convenience). `WOW_CAPTURE_UI=1` remains, for what
//! it was always actually for: painting the UI over a **world** scenario's shot. World baselines
//! stay UI-free by default either way. `WOW_CAPTURE=list`
//! prints the scenario names. `scripts/visual.sh` wraps all of this.
use std::path::Path;
@@ -140,6 +143,7 @@ pub(crate) use probes::{
ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin,
SchedCensusPlugin, StallPlugin, TrailCensusPlugin, UnitVisualsPlugin,
};
pub(crate) use scenarios::ui_opted_in;
use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
@@ -18,8 +18,11 @@
//! That makes this probe's job the three-way distinction a unit test cannot make at the real
//! object: **the query went out**, **the answer came back**, and **we refused it** — where "never
//! asked" and "asked and correctly refused" both read as an empty status map. The refusal readout
//! ([`QuestGiver::refused`]) is what separates them, and an arriving status can only exist if our
//! query did.
//! ([`QuestGiver::refused_for`]) is what separates them, and an arriving status can only exist if
//! our query did. It is keyed by guid on purpose: Goldshire has several quest objects in view and
//! the server answers all of them in one drain, so a single last-writer slot reads as whichever
//! object archetype order happened to put last (it read as a regression exactly once, on a run
//! where nothing about the behaviour had changed).
//!
//! Four readings, in order, and the last two are the controls that make it a regression test:
//!
@@ -333,10 +336,8 @@ fn goquest_probe(
}
}
Phase::Read { level: at, since } => {
let (refused, count) = quest.refused();
let mine = refused
.filter(|(g, _)| Some(*g) == probe.poster)
.map(|(_, s)| s);
let count = quest.refused_count();
let mine = probe.poster.and_then(|p| quest.refused_for(p));
// The HIGH window must see a *different* answer than LOW, or one stale refusal would
// satisfy both.
let settled = if at == LOW_LEVEL {
@@ -418,7 +419,7 @@ fn goquest_probe(
probe.poster.unwrap_or_default(),
probe.low,
probe.high,
quest.refused().1,
quest.refused_count(),
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
+377 -8
View File
@@ -63,7 +63,11 @@ use crate::player::Player;
use crate::target::click::{service_action, service_arm, ServiceAction, ServiceArm};
use crate::target::cursor_mode::npc_flags as f;
use crate::ui_gossip::GossipState;
use benilla_ui::script::UiScript;
use crate::ui_merchant::MerchantOpen;
use crate::ui_quest::QuestGiver;
use crate::ui_session::InteractNpc;
use crate::ui_trainer::TrainerOpen;
/// How wide to look around a `.go` landing for the leg's NPC — **and no wider than the reference's
@@ -84,6 +88,30 @@ const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window after the opcode goes out. Generous for the same reason the
/// binder probe's is: the observer is starved during a terrain load, never the wire.
const WINDOW_TIMEOUT_SECS: f64 = 20.0;
/// How long the re-click gate's latch may lag the window it belongs to.
///
/// It is allowed to lag **at all** because `feed_interact_npc` and the net apply are both in
/// `WorldStage::Net` and deliberately unordered against each other (`ui_session`, decision 1741's
/// note: "whether a window's first frame is seen now or next frame is invisible"), so the token can
/// arm one frame after the window opens. A human's second right-click is a hundred milliseconds and
/// many frames away, so one frame is invisible — but "one frame" and "never" are the same reading
/// if you sample once, which is exactly what the first run of this assert did: it failed the quest
/// and trainer legs, whose windows open on the packet, and passed the gossip legs only because the
/// gossip frame holds shut for several frames waiting on its greeting query (B292). So the probe
/// polls, and prints how long it actually took.
const GATE_TIMEOUT_SECS: f64 = 3.0;
/// **The vendor fork's leg** (decision 1914) — Brother Danil, `creature_template.entry = 152`,
/// `npc_flags = 4` (VENDOR and nothing else, so the ladder cannot reach him by any other arm),
/// spawned in Northshire at his own `creature.position_*` (live-DB verified this session).
const VENDOR_ENTRY: u32 = 152;
const VENDOR_AT: [f32; 3] = [-8901.59, -112.716, 82.0314];
const VENDOR_MAP: u32 = 0;
/// Tough Jerky — a 1-copper vendor trash item, `.additem`'d so the sell leg has something of its
/// own to sell and never touches whatever the probe character was already carrying.
const SELL_ITEM_ENTRY: u32 = 117;
/// How long to wait for `.additem` to land in a bag, and for the sold item to leave it.
const BAG_TIMEOUT_SECS: f64 = 20.0;
/// Which NPC a leg is looking for.
enum Ident {
@@ -183,6 +211,13 @@ struct ServiceProbe {
passes: u32,
fails: u32,
skips: u32,
/// How many frames the current scan actually got to poll in. A starved observer and a missing
/// NPC look identical in a SKIP line otherwise — and on a loaded machine the first is far more
/// likely: at load 61 this probe logged `frame hitch: ~1010 ms` on repeat (the ~1 fps regime
/// decisions 0713/0777/1355 name) and its 20-second scan window bought about twenty samples.
/// `leg.sh` answers the same problem with a load guard (1157); a probe that does one thing and
/// exits is better served by reporting what it actually got.
polls: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
@@ -204,9 +239,56 @@ enum Phase {
since: f64,
guid: u64,
},
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC
/// (decision 1905). Its own phase because the latch is allowed to lag the window by a frame —
/// see [`GATE_TIMEOUT_SECS`].
Gate {
i: usize,
since: f64,
guid: u64,
},
/// The vendor fork's own chain (decision 1914) — `.go` to the pure vendor.
VendorHop {
sent_at: f64,
},
/// The EMPTY-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
VendorOpen {
since: f64,
guid: u64,
},
/// `.additem` sent, waiting for the jerky to appear in a bag so it can be picked up.
VendorArm {
since: f64,
guid: u64,
},
/// The HELD-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
VendorSell {
since: f64,
bag: i64,
slot: u32,
},
Done,
}
/// The bag slot holding [`SELL_ITEM_ENTRY`], as `"bag,slot"`, or `""` — asked of the live VM
/// through the same bindings a player's bag UI uses, so the probe never needs its own view of the
/// container fields.
fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
let found = script
.eval::<String>(&format!(
"for b = 0, 4 do \
local n = GetContainerNumSlots(b) or 0 \
for s = 1, n do \
local l = GetContainerItemLink(b, s) \
if l and string.find(l, \"Hitem:{SELL_ITEM_ENTRY}\") then return b .. \",\" .. s end \
end \
end return \"\""
))
.unwrap_or_default();
let (b, s) = found.split_once(',')?;
Some((b.parse().ok()?, s.parse().ok()?))
}
/// A flag word as the log wants it — the hex plus the bit names, so a reader never has to decode
/// `0x13` by hand.
fn flag_names(flags: u32) -> String {
@@ -265,6 +347,17 @@ fn service_probe(
mut gossip: ResMut<GossipState>,
mut giver: ResMut<QuestGiver>,
mut trainer: ResMut<TrainerOpen>,
// `[0xb4e2d0]`'s mirror — what the re-click gate reads (decision 1905). The probe asserts it
// live because the gate's risk is never the `==`, it is whether this really is armed by a
// window and only by a window.
interact: Res<InteractNpc>,
// The vendor fork's leg (decision 1914): the window the empty-cursor leg must open, the VM the
// held-cursor leg picks an item up in, and the two the app needs to turn a (bag, slot) into
// the guid the wire addresses.
mut merchant: ResMut<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
items: Res<crate::items::Items>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
@@ -284,6 +377,7 @@ fn service_probe(
if now - sent_at < SETTLE_SECS {
return;
}
probe.polls += 1;
let leg = &LEGS[i];
let me = player.pos;
let found = units.iter().find(|(guid, net_e, store, tf)| {
@@ -318,15 +412,28 @@ fn service_probe(
hop(&mut probe, &net, i, h + 1, now);
return;
}
let polls = probe.polls;
let fps = f64::from(polls) / (now - sent_at).max(0.001);
warn!(
"PROBE_SERVICE: SKIP ({}) — nothing matching streamed inside the \
{:.4}yd service reach of any of this leg's {} spawn point(s) in \
{SCAN_TIMEOUT_SECS}s each (the `.go` may have been refused, the tile may \
not have streamed, or — for the questgiver leg — this character has \
nothing left to pick up in the valley). Environmental.",
{SCAN_TIMEOUT_SECS}s each. Environmental. Scanned {polls} frame(s) \
({fps:.1} fps){}",
leg.name,
SCAN_RANGE_SQ.sqrt(),
leg.at.len()
leg.at.len(),
match fps < 5.0 {
// The reading is about the OBSERVER, not the world: below a handful of
// frames a second this leg never really looked (0713/0777/1355).
true =>
" — THE OBSERVER WAS STARVED, so this SKIP says nothing about \
the NPC. Re-run on an idle machine (`uptime`; leg.sh's guard \
is load < 3).",
false =>
" — the machine was keeping up, so the `.go` was refused, the \
tile never streamed, or this leg's NPC genuinely was not \
there.",
}
);
probe.skips += 1;
next(&mut probe, &net, i, now);
@@ -382,7 +489,26 @@ fn service_probe(
giver.clear();
trainer.clear();
let sent = match service_action(arm, guid, false) {
// **The first click must always get through** (decision 1905). Nothing is open here —
// this leg has just cleared, and the reference arms `[0xb4e2d0]` from window openers
// only — so the re-click gate must read disarmed at the moment of the send. If it ever
// reads armed here, the gate would be eating first clicks and this probe would still
// pass every other leg, which is why the assert is at the send and not after it.
if interact.1.is_some() {
error!(
"PROBE_SERVICE: FAIL ({}) — the npc token is armed on {:#x} BEFORE the send; \
the re-click gate would eat first clicks{note}",
leg.name,
interact.1.unwrap_or(0)
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
}
// `None` = an empty cursor: no leg here holds an item, and the vendor arm's fork is
// the merchant probe's business, not this one's (decision 1914).
let sent = match service_action(arm, guid, false, None) {
ServiceAction::Send(cmd) => {
let named = format!("{cmd:?}");
let _ = net.0.send(cmd);
@@ -394,7 +520,9 @@ fn service_probe(
ServiceAction::AskBinder => "CONFIRM_BINDER, no packet",
ServiceAction::AskSpiritHealer => "CONFIRM_XP_LOSS, no packet",
ServiceAction::Silent(w) => w,
ServiceAction::Send(_) => unreachable!(),
ServiceAction::Send(_) | ServiceAction::SellFromCursor(_) => {
unreachable!()
}
};
warn!(
"PROBE_SERVICE: SKIP ({}) — arm {arm:?} opens no window from a packet \
@@ -442,7 +570,11 @@ fn service_probe(
}
);
probe.passes += 1;
next(&mut probe, &net, i, now);
probe.phase = Phase::Gate {
i,
since: now,
guid,
};
}
Some(open) => {
error!(
@@ -478,6 +610,229 @@ fn service_probe(
None => {}
}
}
Phase::Gate { i, since, guid } => {
let leg = &LEGS[i];
if interact.1 == Some(guid) {
info!(
"PROBE_SERVICE: PASS ({}) — re-click gate armed on {guid:#x} {:.0} ms after \
the window: a second right-click sends nothing (`0x5f0251`)",
leg.name,
(now - since) * 1000.0
);
probe.passes += 1;
next(&mut probe, &net, i, now);
} else if now - since > GATE_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL ({}) — the window has been open on {guid:#x} for \
{GATE_TIMEOUT_SECS}s and the npc token still reads {:?}; the re-click gate \
cannot fire and every re-click re-sends",
leg.name, interact.1
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
}
// ── The vendor fork (decision 1914) ──────────────────────────────────────────────────
Phase::VendorHop { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let found = units.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::Unit
&& store.0.object_entry() == Some(VENDOR_ENTRY)
&& tf.translation.distance_squared(me) <= SCAN_RANGE_SQ
});
let Some((guid, _, store, _)) = found else {
if now - sent_at > SCAN_TIMEOUT_SECS {
warn!(
"PROBE_SERVICE: SKIP (vendor fork) — entry {VENDOR_ENTRY} never streamed \
inside the service reach. Environmental."
);
probe.skips += 1;
probe.phase = Phase::Done;
}
return;
};
let guid = guid.0;
let flags = store.0.unit_npc_flags();
gossip.clear();
giver.clear();
trainer.clear();
merchant.clear();
// The EMPTY-cursor leg. The arm is asserted, not assumed: a vendor who had grown a
// GOSSIP bit would take the gossip arm and this leg would be measuring nothing.
let arm = service_arm(flags, None);
if arm != Some(ServiceArm::Vendor) {
warn!(
"PROBE_SERVICE: SKIP (vendor fork) — entry {VENDOR_ENTRY} reads {} and takes \
{arm:?}, not the vendor arm. Environmental (a world-DB change).",
flag_names(flags)
);
probe.skips += 1;
probe.phase = Phase::Done;
return;
}
match service_action(ServiceArm::Vendor, guid, false, None) {
ServiceAction::Send(cmd) => {
info!(
"PROBE_SERVICE: (vendor fork, empty cursor) {guid:#x} flags {} → {cmd:?}",
flag_names(flags)
);
let _ = net.0.send(cmd);
probe.phase = Phase::VendorOpen { since: now, guid };
}
_ => {
error!(
"PROBE_SERVICE: FAIL (vendor fork, empty cursor) — an EMPTY cursor took \
the sale leg; `0x5df5e0 je` must fall to CMSG_LIST_INVENTORY"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
}
Phase::VendorOpen { since, guid } => {
if merchant.vendor == Some(guid) {
info!(
"PROBE_SERVICE: PASS (vendor fork, empty cursor) — merchant window open on \
{guid:#x}: the zero leg is still the plain list open"
);
probe.passes += 1;
// The window would arm the re-click gate and eat the next click, so close it the
// way its own close button does (no packet) before the held-cursor leg.
merchant.clear();
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".additem {SELL_ITEM_ENTRY} 1"),
});
probe.phase = Phase::VendorArm { since: now, guid };
} else if now - since > WINDOW_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL (vendor fork, empty cursor) — no merchant window on \
{guid:#x} within {WINDOW_TIMEOUT_SECS}s"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::VendorArm { since, guid } => {
let Some(mut script) = script else {
warn!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — no UI VM in this build");
probe.skips += 1;
probe.phase = Phase::Done;
return;
};
let Some((bag, slot)) = find_item_slot(&script) else {
if now - since > BAG_TIMEOUT_SECS {
warn!(
"PROBE_SERVICE: SKIP (vendor fork, held cursor) — item {SELL_ITEM_ENTRY} \
never reached a bag within {BAG_TIMEOUT_SECS}s of `.additem` \
(a full bag, or the command was refused). Environmental."
);
probe.skips += 1;
probe.phase = Phase::Done;
}
return;
};
// Pick it up exactly as a player does — through the binding, not by writing the model.
if let Err(e) = script.run(&format!("PickupContainerItem({bag}, {slot})")) {
error!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — PickupContainerItem: {e}");
probe.skips += 1;
probe.phase = Phase::Done;
return;
}
let Some(held) = script.cursor_item() else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — PickupContainerItem({bag}, \
{slot}) left nothing on the cursor, so the fork has no input"
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
let slot0 = u8::try_from(held.slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) = self_store
.single()
.ok()
.and_then(|s| crate::ui_items::slot_guid(&s.0, held.bag, slot0, &items))
else {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {} slot {} is on the \
cursor but resolves to no item guid, so the arm cannot address the packet",
held.bag, held.slot
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
match service_action(ServiceArm::Vendor, guid, false, Some(item_guid)) {
ServiceAction::SellFromCursor(cmd) => {
info!(
"PROBE_SERVICE: (vendor fork, held cursor) bag {bag} slot {slot} = item \
{item_guid:#x} → {cmd:?}"
);
let _ = net.0.send(cmd);
script.take_cursor_item_for_sale();
if script.cursor_payload().is_some() {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — the sell clear left \
the item on the cursor"
);
probe.fails += 1;
}
probe.phase = Phase::VendorSell {
since: now,
bag,
slot,
};
}
other => {
let what = match other {
ServiceAction::Send(cmd) => format!("{cmd:?}"),
_ => "a non-send arm".to_string(),
};
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — an item is on the cursor \
and the vendor arm still answered {what}; `0x5df5e0` must take the sale"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
}
Phase::VendorSell { since, bag, slot } => {
let Some(script) = script else {
probe.phase = Phase::Done;
return;
};
// The server's own verdict: the sold stack leaves the bag. Nothing client-side can
// fake this — it is the inventory update answering the packet.
let gone = script
.eval::<bool>(&format!(
"return GetContainerItemLink({bag}, {slot}) == nil"
))
.unwrap_or(false);
if gone {
info!(
"PROBE_SERVICE: PASS (vendor fork, held cursor) — the server took the sale: \
bag {bag} slot {slot} is empty {:.0} ms after CMSG_SELL_ITEM",
(now - since) * 1000.0
);
probe.passes += 1;
probe.phase = Phase::Done;
} else if now - since > BAG_TIMEOUT_SECS {
error!(
"PROBE_SERVICE: FAIL (vendor fork, held cursor) — bag {bag} slot {slot} still \
holds the item {BAG_TIMEOUT_SECS}s after the sale went out; the server \
refused it (wrong vendor guid, wrong item guid, or out of range)"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
@@ -528,6 +883,7 @@ fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64
target: None,
text: format!(".go xyz {x} {y} {z} {}", leg.map),
});
probe.polls = 0;
probe.phase = Phase::Hop {
i,
hop: h,
@@ -539,6 +895,19 @@ fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64
fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
match i + 1 < LEGS.len() {
true => hop(probe, net, i + 1, 0, now),
false => probe.phase = Phase::Done,
// The ladder legs are done; the vendor fork's own chain runs after them.
false => {
let [x, y, z] = VENDOR_AT;
info!(
"PROBE_SERVICE: hopping to the pure vendor (entry {VENDOR_ENTRY}) at \
({x}, {y}, {z}) map {VENDOR_MAP}"
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"),
});
probe.phase = Phase::VendorHop { sent_at: now };
}
}
}
+152 -25
View File
@@ -33,6 +33,15 @@ pub(super) struct Scenario {
/// uses), so the capture exercises the real feed → VM → extract → render chain end to end.
#[derive(Clone, Copy, PartialEq)]
pub(super) enum UiFixture {
/// **The player UI, with no window opened.** `ui-unitframes`, `ui-combopoints` and
/// `ui-actionbar` photograph chrome that is simply *there* once the UI loads and the synthetic
/// unit snapshot lands (`ui_script::demo_unit_feed`) — there is nothing to seed and nothing to
/// click. They used to say `ui: None`, which made the field mean two things at once ("seed
/// this window" *and* "does this capture include the UI at all") and left the second
/// unstateable: they were UI captures that the table said were world captures, kept working
/// only by `WOW_CAPTURE_UI=1` on the command line. One field, one meaning — so
/// [`ui_opted_in`] can just ask `ui.is_some()`.
Bare,
Merchant,
Gossip,
Quest,
@@ -875,7 +884,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Bag),
},
// The GameTooltip forced open over a seeded bag slot (a green-quality item). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip`.
// `WOW_CAPTURE=ui-tooltip`.
Scenario {
name: "ui-tooltip",
map: Some(MAP_AZEROTH),
@@ -885,7 +894,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Tooltip),
},
// The world-mouseover tooltip at the DEFAULT corner (screen bottom-right, −13/+70) over a
// seeded hostile wolf. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-tooltip-world`.
// seeded hostile wolf. Run with `WOW_CAPTURE=ui-tooltip-world`.
Scenario {
name: "ui-tooltip-world",
map: Some(MAP_AZEROTH),
@@ -895,7 +904,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::TooltipWorld),
},
// The character window's paper doll over a fully-seeded synthetic self player. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-char`.
// `WOW_CAPTURE=ui-char`.
Scenario {
name: "ui-char",
map: Some(MAP_AZEROTH),
@@ -906,29 +915,29 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
// The player + target unit frames (no window fixture — the frames come from `demo_unit_feed`,
// which seeds synthetic "player"/"target" snapshots whenever WOW_CAPTURE_UI=1). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes`.
// `WOW_CAPTURE=ui-unitframes`.
Scenario {
name: "ui-unitframes",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
ui: Some(UiFixture::Bare),
},
// The combo-point dots at the target frame's top-right (decisions 0869/0875). `demo_unit_feed`
// seeds a ROGUE with four points banked on the selected wolf for this scenario only — the demo
// player is a warrior everywhere else, and a warrior authentically lights no dot. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-combopoints`.
// `WOW_CAPTURE=ui-combopoints`.
Scenario {
name: "ui-combopoints",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
ui: Some(UiFixture::Bare),
},
// The group-invite dialog over open world — the shared StaticPopup plate's only look-pass.
// Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-partyinvite`.
// Run with `WOW_CAPTURE=ui-partyinvite`.
Scenario {
name: "ui-partyinvite",
map: Some(MAP_AZEROTH),
@@ -941,14 +950,14 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// WOW_CAPTURE_UI=1, and `demo_unit_feed` seeds the action slots + player XP). The bar is 1024
// wide + 128px end caps, so this fixture takes a WIDER, shorter window (see main.rs's per-capture
// sizing) — the default 640px UI window would crop it. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-actionbar`.
// `WOW_CAPTURE=ui-actionbar`.
Scenario {
name: "ui-actionbar",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
ui: Some(UiFixture::Bare),
},
// The V-key nameplate over a synthetic Timber Wolf, framed like the reference screenshot
// (an eye-height look at a wolf ~8 yd off, Northshire ground). Plates draw through the
@@ -964,7 +973,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
// The fullscreen world map (decision 0203 phase 2), forced open at the world sheet. The frame's
// 1024×768 chrome needs the taller window main.rs gives the 1:1 gx captures. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-worldmap`.
// `WOW_CAPTURE=ui-worldmap`.
Scenario {
name: "ui-worldmap",
map: Some(MAP_AZEROTH),
@@ -973,7 +982,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::WorldMap),
},
// The spellbook over a seeded mage book. Run with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-spellbook`.
// The spellbook over a seeded mage book. Run with `WOW_CAPTURE=ui-spellbook`.
Scenario {
name: "ui-spellbook",
map: Some(MAP_AZEROTH),
@@ -983,7 +992,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::SpellBook),
},
// The macro window over a seeded macro set. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-macro`.
// `WOW_CAPTURE=ui-macro`.
Scenario {
name: "ui-macro",
map: Some(MAP_AZEROTH),
@@ -993,7 +1002,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Macro),
},
// The same window with the name/icon popup open. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-macro-popup`.
// `WOW_CAPTURE=ui-macro-popup`.
Scenario {
name: "ui-macro-popup",
map: Some(MAP_AZEROTH),
@@ -1003,7 +1012,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::MacroPopup),
},
// The chat edit box open with a typed draft over seeded lines. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chatedit`.
// `WOW_CAPTURE=ui-chatedit`.
Scenario {
name: "ui-chatedit",
map: Some(MAP_AZEROTH),
@@ -1014,7 +1023,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
// The hovered chat dock — the tab plate + its ADD highlight over the world, which is the
// pair the UI-over-world composite space decides. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-chat-tabhover`.
// `WOW_CAPTURE=ui-chat-tabhover`.
Scenario {
name: "ui-chat-tabhover",
map: Some(MAP_AZEROTH),
@@ -1024,7 +1033,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::ChatTabHover),
},
// The social pane, for the stray-dropdown regression (B264, decision 1298). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-social`.
// `WOW_CAPTURE=ui-social`.
Scenario {
name: "ui-social",
map: Some(MAP_AZEROTH),
@@ -1034,7 +1043,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Social),
},
// The era Options window over the ground scene. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options`.
// `WOW_CAPTURE=ui-options`.
Scenario {
name: "ui-options",
map: Some(MAP_AZEROTH),
@@ -1044,7 +1053,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Options),
},
// The same window on the AUDIO page (0957). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-audio`.
// `WOW_CAPTURE=ui-options-audio`.
Scenario {
name: "ui-options-audio",
map: Some(MAP_AZEROTH),
@@ -1054,7 +1063,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsAudio),
},
// The same window on the GRAPHICS page (0959). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-graphics`.
// `WOW_CAPTURE=ui-options-graphics`.
Scenario {
name: "ui-options-graphics",
map: Some(MAP_AZEROTH),
@@ -1064,7 +1073,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsGraphics),
},
// The same window on the CHAT page (1589). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-chat`.
// `WOW_CAPTURE=ui-options-chat`.
Scenario {
name: "ui-options-chat",
map: Some(MAP_AZEROTH),
@@ -1074,7 +1083,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsChat),
},
// The colour picker, wheel and all (1592). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-color-picker`.
// `WOW_CAPTURE=ui-color-picker`.
Scenario {
name: "ui-color-picker",
map: Some(MAP_AZEROTH),
@@ -1084,7 +1093,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::ColorPicker),
},
// The Controls page with the Camera Following Style dropdown OPEN (0992, re-seated 1649). Run
// with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-dropdown`.
// with `WOW_CAPTURE=ui-options-dropdown`.
Scenario {
name: "ui-options-dropdown",
map: Some(MAP_AZEROTH),
@@ -1094,7 +1103,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::OptionsDropdownList),
},
// The options window's Keybindings page, Movement expanded (1008). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-keybindings`.
// `WOW_CAPTURE=ui-keybindings`.
Scenario {
name: "ui-keybindings",
map: Some(MAP_AZEROTH),
@@ -1104,7 +1113,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::KeyBindings),
},
// The same window MID-SEARCH (0984): the "volume" results view. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-search`.
// `WOW_CAPTURE=ui-options-search`.
Scenario {
name: "ui-options-search",
map: Some(MAP_AZEROTH),
@@ -1140,3 +1149,121 @@ pub(super) const TRAM_LOOK: [f32; 3] = [-2.44, -1400.0, -118.0];
/// are sky.
pub(super) const SCAR_EYE: [f32; 3] = [-11892.7, -2647.1, 20.0];
pub(super) const SCAR_LOOK: [f32; 3] = [-11792.7, -2647.1, 66.6];
/// **Does the scenario named by `$WOW_CAPTURE` declare a UI fixture?** — i.e. is this a capture
/// whose *subject* is a window.
///
/// `WOW_CAPTURE_UI=1` exists so that world baselines stay UI-free: a capture regression-tests the
/// world render, and the player UI painted over it would make every diff a UI diff. That default
/// is right. What was wrong is that it also gated the scenarios that ARE the UI — a `ui-*`
/// scenario declares `ui: Some(..)` in the table above, which is the harness's own statement that
/// the window is the point, and then needed the env var repeated on the command line to mean it.
///
/// **Forgetting it did not fail.** The run brought up the world with no in-game UI, the fixture's
/// seed hit a nil global (`ToggleQuestLog`), logged ONE `warn!` into ~100 lines of pipeline
/// chatter, wrote a perfectly valid PNG of a UI-less world, and exited **0**. That is an
/// instrument that manufactures a confident false negative — the shape `method.md` §6 spends a
/// paragraph on — and it cost a session a wasted build and nearly a wrong conclusion about a fix
/// that was in fact working.
///
/// So a `ui:` fixture now opts the UI in on its own account. World scenarios (`ui: None`) are
/// untouched and stay pristine; `WOW_CAPTURE_UI=1` still works and still means what it meant, for
/// the case it was actually for — opting the UI into a WORLD scenario's shot.
/// Find a scenario by name across **both** tables — the blessed sweep [`SCENARIOS`] and
/// [`ON_DEMAND`], exactly as the harness's own `WOW_CAPTURE=` resolution does (decision 0632: only
/// the sweep is narrowed, every viewpoint stays capturable by name). Every `ui-*` fixture lives in
/// `ON_DEMAND`, so a lookup that searched only `SCENARIOS` would answer "not a UI scenario" for
/// precisely the scenarios this predicate exists to serve — which is what the test below caught
/// on the first run of it.
pub(super) fn by_name(name: &str) -> Option<&'static Scenario> {
SCENARIOS
.iter()
.chain(ON_DEMAND.iter())
.find(|s| s.name == name)
}
fn scenario_declares_ui() -> bool {
std::env::var("WOW_CAPTURE")
.ok()
.and_then(|name| by_name(&name))
.is_some_and(|s| s.ui.is_some())
}
/// **The one predicate**: is the player UI opted into this capture? Either the scenario declares a
/// `ui:` fixture, or `WOW_CAPTURE_UI=1` asks for it over a world scene.
///
/// It is one function because it has three consumers that MUST agree, and the cost of them
/// disagreeing is a capture that looks plausible and isn't: the UI load itself
/// (`ui_script::lifecycle::ui_wanted`), the synthetic `"player"`/`"target"` snapshot that
/// populates the frames in a server-less run (`ui_script::capture_ui_active` →
/// `demo_unit_feed`), and the window SIZE the scenario is denominated in (`lib.rs`). Load the UI
/// without the demo feed and `ui-unitframes` photographs empty frames; load it without the size
/// and every UI baseline is denominated in the world window. Neither failure looks like a failure.
pub(crate) fn ui_opted_in() -> bool {
std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") || scenario_declares_ui()
}
#[cfg(test)]
mod ui_opt_in_tests {
use super::*;
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
/// The footgun this predicate exists to remove: a scenario whose subject is a window opts the
/// UI in **without** `WOW_CAPTURE_UI=1`, while a world scenario still does not. Before this,
/// `WOW_CAPTURE=ui-questlog` alone brought up a UI-less world, failed its seed on a nil
/// global, wrote a valid PNG and exited 0.
#[test]
fn a_ui_scenario_opts_the_ui_in_and_a_world_scenario_does_not() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ui = EnvGuard::unset("WOW_CAPTURE_UI");
let _c = EnvGuard::set("WOW_CAPTURE", "ui-questlog");
assert!(
ui_opted_in(),
"a `ui:` scenario is a UI capture by construction"
);
// The one that made the concept honest: it opens no window, so it used to say
// `ui: None` and was a UI capture the table called a world capture.
let _c = EnvGuard::set("WOW_CAPTURE", "ui-unitframes");
assert!(ui_opted_in(), "…including the fixtureless `Bare` ones");
// A world scenario's baseline regression-tests the WORLD render and must stay UI-free.
let world = SCENARIOS
.iter()
.chain(ON_DEMAND.iter())
.find(|s| s.ui.is_none())
.expect("the tables have world scenarios");
let _c = EnvGuard::set("WOW_CAPTURE", world.name);
assert!(
!ui_opted_in(),
"world scenario {} must stay pristine",
world.name
);
// …unless the env var asks for it over a world scene, which is what it was always for.
let _on = EnvGuard::set("WOW_CAPTURE_UI", "1");
assert!(ui_opted_in(), "the env var still opts a world scene in");
}
/// Every `ui-*`-named scenario really does declare a fixture — otherwise it would silently be
/// a world capture wearing a UI name, and the predicate above would not save it.
#[test]
fn every_ui_named_scenario_declares_a_fixture() {
for s in SCENARIOS
.iter()
.chain(ON_DEMAND.iter())
.filter(|s| s.name.starts_with("ui-"))
{
assert!(
s.ui.is_some(),
"scenario {} is named ui-* but declares no `ui:` fixture — it would be treated as \
a world capture and photographed with no player UI. If it opens no window, that \
is what `UiFixture::Bare` is for.",
s.name
);
}
}
}
+4 -1
View File
@@ -786,7 +786,7 @@ mod events;
use events::fire_anim_events;
pub(crate) use events::{
advance_track, footfall_culls, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent,
TrackMemory,
EventFrame, TrackMemory,
};
/// The `$BTH` breath puffs — a unit's visible cold vapour in a snow zone (B233, decision 1149).
@@ -1306,6 +1306,7 @@ mod nock_latch_tests {
entity: unit,
ident: *b"$BWP",
data: 0,
pos: None,
});
app.update();
assert!(
@@ -1316,6 +1317,7 @@ mod nock_latch_tests {
entity: unit,
ident: *b"$BWR",
data: 0,
pos: None,
});
app.update();
assert!(
@@ -1329,6 +1331,7 @@ mod nock_latch_tests {
entity: bare,
ident: *b"$BWP",
data: 0,
pos: None,
});
app.update();
assert!(
+140 -7
View File
@@ -27,6 +27,42 @@ pub(crate) struct AnimSoundEvent {
pub(crate) ident: [u8; 4],
/// The tag payload (a SoundEntries id for `$SND`/`$DSL`/`$DSO`; 0 otherwise).
pub(crate) data: u32,
/// **Where the key fired** — the reference's own `placementMatrix · (boneMatrix[event.bone] ·
/// event.position)`, resolved here at the fire and carried by value exactly as the M2 event
/// kernel `0x719370` snapshots it into the deferred callback record that every dispatcher
/// (`0x5ffbd0` units, `0x5f3e20` GameObjects, `0x6951e0` placed models) then reads (decision
/// 1904; wow-re `spell/scratch/camera-shake-producers.md` §5).
///
/// It is emphatically not the object's origin: corpus-wide 149 of 244 `$DSL` records sit off
/// it, out to **67.6 yd** on `Maraudon_Waterfall01.m2`, and the six `$CSD` records every
/// player model authors ride the head. `None` only when the model carries no rig *and* no
/// world frame to place it in — the consumer then falls back to the object's own transform,
/// which is what all of them used to do unconditionally.
pub(crate) pos: Option<Vec3>,
}
/// The frame a scanner resolves a fired key's world point in — the two exact cases, named.
///
/// A **rigged** model composes the record's bone-local offset through that bone's live joint
/// global, which is the kernel's `boneMatrix[bone] · position` and then the placement. A model
/// with **no rig** has no bone matrices at all, and with no keys every bone composes to the
/// identity — so `placement · position` is not an approximation of the kernel's quantity, it *is*
/// it. That second case is the whole placed-doodad population: 0 of 244 `$DSL` records ride a bone
/// any sequence keys (`benilla-extract eventmarkerscan`).
pub(crate) struct EventFrame<'a> {
/// The model's own world frame — the reference's placement matrix.
pub(crate) world: &'a GlobalTransform,
/// The composed pose and its joints root, for a model that has a rig.
pub(crate) rig: Option<(&'a benilla_world::rig_anim::RigPose, &'a GlobalTransform)>,
}
impl EventFrame<'_> {
/// The world point for one baked key.
fn point(&self, e: &benilla_assets::ClipEvent) -> Vec3 {
self.rig
.and_then(|(pose, root)| pose.posed_point(root, e.bone, e.offset))
.unwrap_or_else(|| self.world.transform_point(e.point))
}
}
/// A footfall is **two independent channels**, and a tag belongs to exactly one of them — the
@@ -147,7 +183,12 @@ pub(super) fn fire_anim_events(
&AnimDriver,
Has<benilla_world::rig_anim::AnimParked>,
Option<&crate::net::Guid>,
&GlobalTransform,
Option<&benilla_world::rig_anim::RigPose>,
)>,
// The joint roots the composed poses hang off — read, never spawned (decision 1355's pure
// position read).
globals: Query<&GlobalTransform>,
mut last: Local<TrackMemory>,
// The **masked overlay** track's own memory (decision 0087): a swing/emote routed to the
// SpineLow overlay plays *beside* the base, so its events (a swing's `$CSS`, an emote's `$CSD`)
@@ -158,7 +199,7 @@ pub(super) fn fire_anim_events(
names: Res<crate::names::NameCache>,
) {
let catalog = anim_data.as_deref().map(|d| &d.0);
for (entity, anims, player, drv, parked, guid) in &units {
for (entity, anims, player, drv, parked, guid, world, pose) in &units {
// The election's TICK half (decision 1482): a parked unit's event tracks are not
// scanned — the reference's pass-2 walk never inserts the model into the tick worklist
// (`0x683dd0` walk 2 skips `0x710b90`) — unless its cached template carries
@@ -185,6 +226,10 @@ pub(super) fn fire_anim_events(
// variation clips, each its own node with its own event track). During a same-id cross-fade
// (swing variation A fading under fresh variation B) the newest play — the smallest seek —
// is the track; the node-keyed memory then treats the switch as a clip change.
let frame = EventFrame {
world,
rig: pose.and_then(|p| Some((p, globals.get(p.joints_root).ok()?))),
};
if let Some(id) = drv.resolved_anim(anims, catalog) {
let playing = anims
.clips
@@ -194,7 +239,7 @@ pub(super) fn fire_anim_events(
.min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((clip, cur)) = playing {
if let Some(prev) = advance_track(&mut last, entity, clip.node, cur) {
scan_events(clip, entity, prev, cur, &mut out);
scan_events(clip, entity, prev, cur, &frame, &mut out);
}
}
}
@@ -214,7 +259,7 @@ pub(super) fn fire_anim_events(
if let Some(active) = player.animation(ov.node) {
let cur = active.seek_time();
if let Some(prev) = advance_track(&mut last_overlay, entity, ov.node, cur) {
scan_events(clip, entity, prev, cur, &mut out);
scan_events(clip, entity, prev, cur, &frame, &mut out);
}
}
}
@@ -282,6 +327,7 @@ pub(crate) fn scan_events(
entity: Entity,
prev: f32,
cur: f32,
frame: &EventFrame<'_>,
out: &mut MessageWriter<AnimSoundEvent>,
) {
if clip.events.is_empty() || cur == prev {
@@ -291,17 +337,28 @@ pub(crate) fn scan_events(
let mut fire = |lo: f32, hi: f32| {
for e in clip.events.iter() {
if e.time > lo && e.time <= hi {
let pos = frame.point(e);
if traced {
benilla_assets::trace::line(
"aev",
&format!(
"{} unit={entity} anim={} key={:.3}s data={} clip={:.3}s{}",
"{} unit={entity} anim={} key={:.3}s data={} clip={:.3}s{} \
bone={} at=[{:.2},{:.2},{:.2}] off={:.2}",
String::from_utf8_lossy(&e.ident),
clip.anim_id,
e.time,
e.data,
clip.duration,
if clip.looping { " loop" } else { "" },
e.bone,
pos.x,
pos.y,
pos.z,
// How far the key fired from the model's own origin — the whole
// question this line was extended to answer (decision 1904). A
// non-zero `off` is the marker being honoured; all-zero across a run
// is the model-root fallback, which is what it used to be everywhere.
pos.distance(frame.world.translation()),
),
);
}
@@ -309,6 +366,7 @@ pub(crate) fn scan_events(
entity,
ident: e.ident,
data: e.data,
pos: Some(pos),
});
}
}
@@ -506,15 +564,21 @@ mod tests {
bounds_min: bevy::prelude::Vec3::ZERO,
bounds_max: bevy::prelude::Vec3::ZERO,
events: vec![
benilla_formats::AnimEvent {
benilla_assets::ClipEvent {
time: 0.0,
ident: *b"$SL0",
data: 0,
bone: 0,
offset: bevy::prelude::Vec3::ZERO,
point: bevy::prelude::Vec3::ZERO,
},
benilla_formats::AnimEvent {
benilla_assets::ClipEvent {
time: 0.0,
ident: *b"$SR0",
data: 0,
bone: 0,
offset: bevy::prelude::Vec3::ZERO,
point: bevy::prelude::Vec3::ZERO,
},
]
.into(),
@@ -538,11 +602,80 @@ mod tests {
.run_system_once(
|win: bevy::prelude::Res<Window>, mut out: MessageWriter<_>| {
let unit = Entity::from_raw_u32(1).expect("valid entity id");
scan_events(&win.0, unit, win.1, win.2, &mut out);
// Identity placement, no rig: the fired point is the key's own model-space
// one, which is what this test's zero-offset keys make `Vec3::ZERO`.
let world = GlobalTransform::IDENTITY;
let frame = EventFrame {
world: &world,
rig: None,
};
scan_events(&win.0, unit, win.1, win.2, &frame, &mut out);
},
)
.expect("run_system_once");
let mut msgs = world.resource_mut::<bevy::ecs::message::Messages<AnimSoundEvent>>();
msgs.drain().map(|m| m.ident).collect()
}
/// **A rig-less model's event point is the placement times the key's own model-space point** —
/// and that is not an approximation of the kernel's `placementMatrix · (boneMatrix[bone] ·
/// position)`: with no keys every bone matrix composes to the identity, so the two are equal.
///
/// This is the whole placed-doodad population, where the offsets are largest (149 of 244
/// shipped `$DSL` records sit off their origin, out to 67.6 yd) and **none** rides a bone any
/// sequence keys. A model *placed* somewhere and *scaled* must carry its marker with it, which
/// is what the transform below checks and what a bare `translation()` read could not do.
#[test]
fn a_rigless_models_key_fires_at_its_placed_and_scaled_point() {
use bevy::ecs::system::RunSystemOnce;
// A key 10 yd out along +x in model space, on a bone the model never animates.
let mut clip = shuffle_clip();
clip.events = vec![benilla_assets::ClipEvent {
time: 0.5,
ident: *b"$DSL",
data: 1,
bone: 3,
offset: Vec3::new(1.0, 2.0, 3.0), // ignored on this leg — there is no rig to compose
point: Vec3::new(10.0, 0.0, 0.0),
}]
.into();
// Placed 100 yd north, turned a quarter turn, and at half scale.
let placement = GlobalTransform::from(
Transform::from_xyz(0.0, 0.0, 100.0)
.with_rotation(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2))
.with_scale(Vec3::splat(0.5)),
);
#[derive(bevy::prelude::Resource)]
struct Placed(AnimClip, GlobalTransform);
let mut world = bevy::prelude::World::new();
world.init_resource::<bevy::ecs::message::Messages<AnimSoundEvent>>();
world.insert_resource(Placed(clip, placement));
world
.run_system_once(|p: bevy::prelude::Res<Placed>, mut out: MessageWriter<_>| {
let e = Entity::from_raw_u32(1).expect("valid entity id");
let frame = EventFrame {
world: &p.1,
rig: None,
};
scan_events(&p.0, e, 0.0, 1.0, &frame, &mut out);
})
.expect("run_system_once");
let mut msgs = world.resource_mut::<bevy::ecs::message::Messages<AnimSoundEvent>>();
let fired: Vec<_> = msgs.drain().collect();
assert_eq!(fired.len(), 1, "one key in the window");
let at = fired[0]
.pos
.expect("a rig-less model still resolves a point");
// Quarter turn about y takes +x to −z, halved by the scale, then translated.
let want = placement.transform_point(Vec3::new(10.0, 0.0, 0.0));
assert!(
at.distance(want) < 1e-4,
"fired at {at:?}, want {want:?} — the placement is not being applied"
);
assert!(
at.distance(placement.translation()) > 1.0,
"the point collapsed onto the model origin, which is the bug this exists to catch"
);
}
}
@@ -53,6 +53,11 @@ pub(crate) struct SwingImpact {
/// 0525). `None` for `$CAH` (character attack-hit), the receive-time unresolved-attacker
/// fallback, and flushes.
pub(crate) natural: Option<u8>,
/// **The world point of the tag that fired this dispatch** — the reference's `edi =
/// [ebx+0x10]`, carried from `0x624862` through both weapon-sound legs and pushed at
/// `0x6248ef`/`0x624950` (wow-re `anim-event-position-law.md` §3). `None` for a flush and for
/// the receive-time fallback, where no tag fired at all and the reference has only the unit.
pub(crate) pos: Option<Vec3>,
}
/// A flush signal for an attacker's pending swing record — `SMSG_ATTACKSTOP`'s `0x624e40`
@@ -147,6 +152,7 @@ pub(super) fn route_swing_impacts(
swing: old.swing,
text_only: true,
natural: None,
pos: None, // a flush: no tag fired, so there is no point to carry
});
}
}
@@ -174,6 +180,7 @@ pub(super) fn route_swing_impacts(
swing: p.swing,
text_only: false,
natural,
pos: ev.pos,
});
} else if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
@@ -218,6 +225,7 @@ pub(super) fn route_swing_impacts(
swing: p.swing,
text_only: true,
natural: None,
pos: None,
});
}
}
@@ -274,6 +282,7 @@ mod tests {
entity,
ident,
data: 0,
pos: None,
});
app.update();
}
+4 -1
View File
@@ -282,10 +282,13 @@ mod tests {
bounds_radius: 0.0,
bounds_min: Vec3::ZERO,
bounds_max: Vec3::ZERO,
events: vec![benilla_formats::AnimEvent {
events: vec![benilla_assets::ClipEvent {
time: DUR * 0.5,
ident: *b"$TST",
data: 0,
bone: 0,
offset: Vec3::ZERO,
point: Vec3::ZERO,
}]
.into(),
arm_nodes: None,
+21 -3
View File
@@ -32,6 +32,16 @@ use benilla_world::schedule::WorldStage;
use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent, TrackMemory};
use benilla_assets::ModelAnimations;
/// What the placed-doodad event scanner reads per host: its clock and the frame its fired keys
/// resolve in ([`crate::creature_anim::EventFrame`]).
type ScannedDoodad = (
Entity,
&'static DoodadAnimHost,
&'static ModelAnimations,
&'static GlobalTransform,
Option<&'static benilla_world::rig_anim::RigPose>,
);
/// Fire the event keyframes each placed doodad's armed clip crossed this frame.
///
/// The arming rules are the shared [`advance_track`]/[`scan_events`] ones the other two scanners
@@ -41,12 +51,13 @@ use benilla_assets::ModelAnimations;
/// [`advance_track`] sees a fresh arm and never scans across the seam.
fn fire_doodad_anim_events(
time: Res<Time>,
hosts: Query<(Entity, &DoodadAnimHost, &ModelAnimations)>,
hosts: Query<ScannedDoodad>,
globals: Query<&GlobalTransform>,
mut last: Local<TrackMemory>,
mut out: MessageWriter<AnimSoundEvent>,
) {
let now = time.elapsed_secs();
for (entity, host, anims) in &hosts {
for (entity, host, anims, world, pose) in &hosts {
let Some((node, cur)) = host.arm_clock(now) else {
continue; // gseq-only host with no sound arm: nothing is armed, so nothing fires
};
@@ -54,7 +65,14 @@ fn fire_doodad_anim_events(
continue;
};
if let Some(prev) = advance_track(&mut last, entity, node, cur) {
scan_events(clip, entity, prev, cur, &mut out);
// The clock-only tier carries no rig at all, which is not a shortfall here: with no
// bone matrices the kernel's product is the placement alone, and the corpus says no
// `$DSL` record rides an animated bone. The lamp's hum lands on the lamp.
let frame = crate::creature_anim::EventFrame {
world,
rig: pose.and_then(|p| Some((p, globals.get(p.joints_root).ok()?))),
};
scan_events(clip, entity, prev, cur, &frame, &mut out);
}
}
// Drop memory for hosts that no longer exist. The other two scanners leave their `Local` to
+75
View File
@@ -219,6 +219,58 @@ pub(crate) struct OverheadFallback(pub(crate) f32);
/// parents a `NetEntity` (the `ChildOf` sites are the portrait booth, the pipe-warm menagerie and
/// the UI glue), so its global IS its local. A `PostUpdate` caller ([`crate::nameplates`], which
/// moved there for this very lag) is unaffected: after propagation the two are the same value.
/// The client's **per-attachment z-bias** fallback table, `[0x862708]` — 37 floats, attachment
/// ids `0..=0x24`, read as `[0x862708 + 4·id]` under a `0 ≤ id < 0x25` guard (wow-re
/// `object-layer/scratch/anim-event-position-law.md` §4, §5-cross-checked). It is consulted only
/// when the model does **not** carry the attachment the caller asked for: the sound then plays at
/// the unit's own position raised by this much, which is how a headless or attachment-less model
/// still puts a mouth sound somewhere plausible instead of at its feet.
const ATTACH_Z_BIAS: [f32; 37] = [
1.0, 1.0, 1.0, 1.5, 1.5, 1.8, 1.8, 0.5, 0.5, 1.0, 1.0, 2.0, 1.5, 1.0, 1.0, 1.0, 1.0, 2.0, 2.5,
0.0, 2.0, 1.5, 1.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 3.0, 1.5, 1.5, 1.0, 1.0, 1.5, 1.0, 1.0,
];
/// **`0x623b90(attachId)` — where a unit-attached sound is born**, as a [`SystemParam`] because
/// two different routers need the same three pure reads.
///
/// [`SystemParam`]: bevy::ecs::system::SystemParam
///
/// `AttachPoints::point` returns The attachment's live world
/// point when the model carries it (`0x712cb0` finds the record, `0x712d50` transforms it), else
/// the unit's own `GetPosition` raised by [`ATTACH_Z_BIAS`]`[attachId]` (`0x623be2 fadd
/// [4·ebx + 0x862708]`).
///
/// Two anim-event arms reach it and they are the reason this exists as a named function rather
/// than inline at either: the emote voice `$CSD` asks for **17** (`0x623c3a push 0x11`), and a
/// **whiffed** melee swing's `$CSS` asks for **1** (`0x624bdd`). Both are emphatically *not* the
/// fired event's own point, which is what makes them the exceptions in
/// `anim-event-position-law.md` §3's table — a player model's six head-mounted `$CSD` records do
/// not decide where that sound plays.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct AttachPoints<'w, 's> {
anchors: Query<'w, 's, &'static BoneAttach>,
poses: Query<'w, 's, &'static benilla_world::rig_anim::RigPose>,
globals: Query<'w, 's, &'static GlobalTransform>,
}
impl AttachPoints<'_, '_> {
/// The world point, with `fallback` standing in for the reference's `GetPosition` (the caller
/// already holds the unit's transform, and passing it keeps this a pure read).
pub(crate) fn point(&self, entity: Entity, attach: u16, fallback: Vec3) -> Vec3 {
self.anchors
.get(entity)
.ok()
.and_then(|a| {
let &(bone, offset) = a.points.get(&attach)?;
let pose = self.poses.get(entity).ok()?;
pose.posed_point(self.globals.get(pose.joints_root).ok()?, bone, offset)
})
.unwrap_or_else(|| {
fallback + Vec3::Y * ATTACH_Z_BIAS.get(attach as usize).copied().unwrap_or(0.0)
})
}
}
pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
entity: Entity,
tf: &Transform,
@@ -1620,3 +1672,26 @@ mod overhead_slot_tests {
assert_eq!(overhead_slot(&body(&[]), true), None, "never parented");
}
}
#[cfg(test)]
mod attach_bias_tests {
use super::*;
/// The z-bias fallback table `[0x862708]`, verbatim (wow-re
/// `object-layer/scratch/anim-event-position-law.md` §4). It is 37 f32 for attachment ids
/// `0..=0x24`, and the two ids that actually reach it through an animation event are the
/// emote voice's **17** and a whiffed swing's **1** — so those two are what a drift here
/// would move, on exactly the models that lack the attachment.
#[test]
fn the_z_bias_table_is_the_reference_row_for_ever_id() {
assert_eq!(ATTACH_Z_BIAS.len(), 0x25, "ids 0..=0x24");
assert_eq!(ATTACH_Z_BIAS[17], 2.0, "$CSD's fallback lift");
assert_eq!(ATTACH_Z_BIAS[1], 1.0, "a whiffed $CSS's");
// The two that are not 1.0-or-1.5, which is what makes a transcription slip visible.
assert_eq!(ATTACH_Z_BIAS[19], 0.0);
assert_eq!(ATTACH_Z_BIAS[29], 3.0);
assert_eq!(ATTACH_Z_BIAS[18], 2.5);
// Out of range reads as no lift, matching the reference's `0 ≤ id < 0x25` guard.
assert_eq!(ATTACH_Z_BIAS.get(0x25), None);
}
}
@@ -805,6 +805,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
pos: None,
});
step(&mut app, 0.05);
let launched = missiles(&mut app);
@@ -837,6 +838,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
pos: None,
});
step(&mut app, 0.016);
assert!(missiles(&mut app).is_empty(), "no flight entity");
@@ -922,6 +924,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
pos: None,
});
step(&mut app, 0.016);
assert!(missiles(&mut app).is_empty(), "no flight entity");
@@ -987,6 +990,7 @@ mod tests {
entity: caster,
ident: *b"$CSL",
data: 0,
pos: None,
});
step(&mut app, 0.016);
assert!(
@@ -975,9 +975,14 @@ pub(super) fn attach_spell_fx(
/// the world position; an instance root's own is joint-local). Unlike a streamed creature, an
/// instance is born under our eyes at t = 0, so first sight fires the head window `[0, cur]` —
/// the level-up pillar's `$SND(888)` sits at 0.033 s and depends on it.
#[allow(clippy::too_many_arguments)] // the scan's own params plus the frame's two reads
pub(super) fn fire_fx_anim_events(
units: Query<(Entity, &FxAttached)>,
players: Query<&AnimationPlayer>,
// The instance root's own world frame and rig — the effect MODEL's placement, which is the
// frame its event records are authored in. Decoupled from the emit entity below on purpose.
globals: Query<&GlobalTransform>,
poses: Query<&benilla_world::rig_anim::RigPose>,
fx: Option<Res<SpellFx>>,
mut last: Local<EntityHashMap<f32>>,
mut seen: Local<Vec<Entity>>,
@@ -1007,8 +1012,21 @@ pub(super) fn fire_fx_anim_events(
seen.push(root);
let prev = last.insert(root, cur).unwrap_or(-1.0);
// The emit entity is decoupled from the scanned track's owner by design — the fx
// scan fires at the unit, not the joint-local instance root.
scan_events(clip, unit, prev, cur, &mut out);
// scan fires at the unit, not the joint-local instance root. **The POINT is not**:
// the record's `position` is authored in the effect model's own space, so it composes
// through the instance root's world frame. `Spells\ArcaneShot_Area.m2` puts its
// `$SND` 30.5 yd off that model's origin — the largest `$SND` offset in the corpus.
let Ok(root_world) = globals.get(root) else {
continue;
};
let frame = crate::creature_anim::EventFrame {
world: root_world,
rig: poses
.get(root)
.ok()
.and_then(|p| Some((p, globals.get(p.joints_root).ok()?))),
};
scan_events(clip, unit, prev, cur, &frame, &mut out);
}
}
// Roots despawn on reap/expiry — drop their seek memory with them.
+8 -18
View File
@@ -54,7 +54,7 @@ use bevy::prelude::*;
use crate::creature_anim::{
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState,
};
use crate::entities::{BoneAttach, Creatures};
use crate::entities::Creatures;
use crate::net::{Embodied, NetEntity, ObjectStore};
use crate::sound::footsteps::Footsteps;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
@@ -165,18 +165,12 @@ fn spawn_footprints(
time: Res<Time>,
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&benilla_world::rig_anim::RigPose>,
)>,
units: Query<(&NetEntity, &GlobalTransform)>,
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
parents: Query<&ChildOf>,
roots: Query<RootState>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
joints: Query<&GlobalTransform>,
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
ink: Option<Res<FootprintInk>>,
@@ -203,7 +197,7 @@ fn spawn_footprints(
let Some(side) = footfall_side(&ev.ident) else {
continue;
};
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
let Ok((net, transform)) = units.get(ev.entity) else {
continue;
};
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
@@ -231,15 +225,11 @@ fn spawn_footprints(
continue;
}
}
// The planted foot: the event's own marker (bone + offset) through the live joint —
// exactly the shape the missile launch points use. No marker/joint = the unit origin.
let foot = attach
.zip(pose)
.and_then(|(a, p)| {
let (bone, offset) = a.markers.get(&ev.ident).copied()?;
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
})
.unwrap_or_else(|| transform.translation());
// The planted foot: the FIRED key's own point, resolved once by the scanner as the kernel
// snapshots it (decision 1904). It used to be a by-4CC re-find of the marker table, which
// is the *launch-point* mechanism (`0x7130e0`, first match) and not this one — a model that
// authors a side tag twice would have printed both feet at the first record's bone.
let foot = ev.pos.unwrap_or_else(|| transform.translation());
// The handler's own 50 yd radius about the CAMERA EYE — every unit's feet, the
// player's included ([`footfall_culls`]).
if footfall_culls(eye, foot) {
+19 -11
View File
@@ -963,6 +963,17 @@ fn ghost_passable(kind: benilla_protocol::EntityKind, type_id: i32) -> bool {
kind == benilla_protocol::EntityKind::GameObject && type_id == GO_TYPE_DOOR
}
/// What the GameObject event scanner reads per object: its clock, its park state, and the frame
/// its fired keys resolve in ([`crate::creature_anim::EventFrame`]).
type ScannedGo = (
Entity,
&'static ModelAnimations,
&'static AnimationPlayer,
Has<benilla_world::rig_anim::AnimParked>,
&'static GlobalTransform,
Option<&'static benilla_world::rig_anim::RigPose>,
);
/// Fire the event keyframes an animated GameObject's playing clip crossed this frame — the GO
/// half of the M2 event-kernel surface (wow-re `go-display-sound-events.md`, the 1086 fold-back
/// record): the reference registers an event callback per **family-A** GO at create
@@ -982,19 +993,12 @@ fn ghost_passable(kind: benilla_protocol::EntityKind, type_id: i32) -> bool {
/// head, a loop wrap fires tail-then-head — and a frozen rate-0 leg never advances, so it never
/// fires.
fn fire_go_anim_events(
gos: Query<
(
Entity,
&ModelAnimations,
&AnimationPlayer,
Has<benilla_world::rig_anim::AnimParked>,
),
With<GoAnim>,
>,
gos: Query<ScannedGo, With<GoAnim>>,
globals: Query<&GlobalTransform>,
mut last: Local<crate::creature_anim::TrackMemory>,
mut out: MessageWriter<AnimSoundEvent>,
) {
for (entity, anims, player, parked) in &gos {
for (entity, anims, player, parked, world, pose) in &gos {
// The election's tick half, GO twin (decision 1482): a parked GO's event track is not
// scanned — and there is no `MORE_AUDIBLE` exception here, because the flag lives on
// CREATURE templates only (the reference's re-link arm reads the creature query cache).
@@ -1010,7 +1014,11 @@ fn fire_go_anim_events(
.min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((clip, cur)) = playing {
if let Some(prev) = advance_track(&mut last, entity, clip.node, cur) {
scan_events(clip, entity, prev, cur, &mut out);
let frame = crate::creature_anim::EventFrame {
world,
rig: pose.and_then(|p| Some((p, globals.get(p.joints_root).ok()?))),
};
scan_events(clip, entity, prev, cur, &frame, &mut out);
}
}
}
+5 -1
View File
@@ -404,7 +404,11 @@ pub fn run(build: BuildId) -> AppExit {
// border texture's native 128×32 — directly diffable against the decoded BLP. Sized
// per-capture off WOW_CAPTURE.
resolution: video::at_requested_dpi(
if capturing && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
// Same opt-in as the UI load itself (`ui_script::lifecycle::ui_wanted`): a scenario
// that declares a `ui:` fixture sizes its window for the window it photographs, with
// or without the env var. The two must agree — a UI loaded into a world-sized window
// is a capture of the right content at the wrong size.
if capturing && crate::run_mode::capture_ui_opted_in() {
// `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI scenarios (a
// scale-dependent text bug looks fine at the scenario's default size and truncates at
// fullscreen heights).
+138 -10
View File
@@ -15,7 +15,7 @@ use benilla_protocol::EntityKind;
use crate::go_templates::GameObjectTemplates;
use crate::names::NameCache;
use crate::net::{GuidIndex, NetEntity};
use crate::net::{GuidIndex, NetEntity, ObjectStore};
use crate::ui_pass::{UiQuad, UiQuads, UvRect};
use super::{party_member_pos, BlipCtx, MinimapBlipHover, TrackedCandidates, BLIP_BASIS_PX};
@@ -49,31 +49,71 @@ fn quest_dot_cell(status: u32) -> Option<[f32; 4]> {
(status == 7).then_some([0.75, 1.0, 0.0, 0.25])
}
/// The three preconditions `0x4eaa90` applies to a **UNIT or PLAYER** before either dot category
/// is even chosen — byte-pinned in wow-re `questgiver-marker.md` §W15 Q2, decision 1906. They sit
/// upstream of the `cmp [edi+0xcb8],7` at `0x4eac31`, whose only predecessor is the fall-through,
/// so they gate the gold **quest** dot (cell 3) and the red **tracking** dot (cell 1) alike. They
/// do **not** touch the GameObject leg (cell 0) or the party dots (cell 4), which are reached by
/// other paths entirely.
///
/// A candidate with no descriptor yet fails, which is the same answer the reference's own read of
/// an un-streamed unit would give (a zeroed health field is `<= 0`) — and it lasts one drain, since
/// `net/apply` seeds the store at the tail of the drain that spawned the entity.
fn unit_dot_eligible(store: Option<&ObjectStore>, me: Option<u64>) -> bool {
let Some(f) = store.map(|s| &s.0) else {
return false;
};
// 1. `0x4eac19 mov ecx,[eax+0x40]; test ecx,ecx; 0x4eac1e 0f 8e jle` — a **signed** `<= 0` on
// `UNIT_FIELD_HEALTH` (`0f 8e`, not `0f 86`). The dead get no dot of any kind.
if f.unit_health().unwrap_or(0) as i32 <= 0 {
return false;
}
// 2. `UNIT_FIELD_CHARMEDBY` when non-zero, else `UNIT_FIELD_SUMMONEDBY`, both halves compared
// against the active player's guid (`0x468550`) — and **equality is the reject**: your own
// pet, minion or charmed victim is never a blip. (This is not the classifier's self-GUID
// check; there are two other, separate ones.)
let owner = match f.unit_charmed_by() {
Some(g) if g != 0 => g,
_ => f.unit_summoned_by().unwrap_or(0),
};
if owner != 0 && Some(owner) == me {
return false;
}
// 3. `byte [eax+0x213] & 4` — `UNIT_FIELD_BYTES_1` byte 3 bit 2, the only `& 4` site on that
// byte image-wide. The bit is verified; its *name* is vmangos's.
!f.unit_is_untrackable()
}
/// Draw a gold dot per quest-giver at status 7, at the unit's live position, hard-culled at
/// the view radius in 3-D world distance (`range² < dx²+dy²+dz²` skips — no rim ride);
/// records a hover hit with the guid. Called AFTER the player arrow: dots draw last, on top.
///
/// Walks the candidate set rather than the status map — the classifier's own shape (it is a
/// per-object callback, not a per-status one), and the only way to reach each object's descriptor,
/// which [`unit_dot_eligible`] needs.
#[allow(clippy::too_many_arguments)]
pub(in crate::minimap) fn emit_quest_dots(
ctx: &BlipCtx,
statuses: &HashMap<u64, u32>,
guids: &GuidIndex,
unit_pos: &Query<&GlobalTransform, With<NetEntity>>,
candidates: &TrackedCandidates,
self_guid: Option<u64>,
icons: &Handle<Image>,
player_indoors: bool,
unit_indoors: impl Fn(Vec3) -> bool,
quads: &mut UiQuads,
hover: &mut MinimapBlipHover,
) {
for (&npc, &status) in statuses {
let Some(cell) = quest_dot_cell(status) else {
for (guid, net, tf, store) in candidates.iter() {
let npc = guid.0;
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue; // the GameObject leg never reaches the `== 7` compare (§W14.9)
}
let Some(cell) = statuses.get(&npc).copied().and_then(quest_dot_cell) else {
continue;
};
let Some(&entity) = guids.0.get(&npc) else {
continue; // despawned / out of range — no live position to mark
};
let Ok(tf) = unit_pos.get(entity) else {
if !unit_dot_eligible(store, self_guid) {
continue;
};
}
let w = bevy_to_wow(tf.translation());
let d3 =
((w[0] - ctx.wx).powi(2) + (w[1] - ctx.wy).powi(2) + (w[2] - ctx.wz).powi(2)).sqrt();
@@ -199,6 +239,7 @@ pub(in crate::minimap) fn emit_tracking_dots(
tracking: SelfTracking,
candidates: &TrackedCandidates,
statuses: &HashMap<u64, u32>,
self_guid: Option<u64>,
names: &NameCache,
templates: &GameObjectTemplates,
locks: Option<&LockCatalog>,
@@ -273,6 +314,11 @@ pub(in crate::minimap) fn emit_tracking_dots(
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
// The same three preconditions the quest dot passes — they are upstream of the branch
// that chooses between the two categories, so neither category outruns them (§W15 Q2b).
if !unit_dot_eligible(store, self_guid) {
continue;
}
if statuses.get(&guid.0).copied() == Some(7) {
continue; // the ==7 branch already drew the quest dot
}
@@ -343,6 +389,88 @@ pub(in crate::minimap) fn emit_party_dots(
mod tests {
use super::*;
/// **The three preconditions upstream of BOTH dot categories** (decision 1906, wow-re
/// `questgiver-marker.md` §W15 Q2). They live in `0x4eaa90` between the type gate and the
/// `cmp [edi+0xcb8],7`, whose only predecessor is the fall-through — so a unit that fails any
/// of them draws neither the gold quest dot nor the red tracking dot. benilla had none of them.
///
/// The control is the first row: an ordinary live creature still passes, which is what would
/// catch a predicate written one bit too wide and blanked the minimap.
#[test]
fn the_dead_our_own_minions_and_the_untrackable_draw_no_dot_of_either_kind() {
use crate::net::ObjectStore;
use benilla_protocol::ObjectFields;
const FIELD_HEALTH: u16 = 22; // UNIT_FIELD_HEALTH
const FIELD_SUMMONEDBY: u16 = 12; // UNIT_FIELD_SUMMONEDBY (2 dwords)
const FIELD_CHARMEDBY: u16 = 10; // UNIT_FIELD_CHARMEDBY (2 dwords)
const FIELD_BYTES_1: u16 = 138;
/// `UNIT_FIELD_BYTES_1` byte 3 bit 2 — the `& 4` the classifier tests.
const UNTRACKABLE: u32 = 0x4 << 24;
const ME: u64 = 0x0000_0000_0000_0007;
const SOMEONE_ELSE: u64 = 0x0000_0000_0000_0042;
let store = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs));
let alive = [(FIELD_HEALTH, 100u32)];
let with = |extra: &[(u16, u32)]| {
let mut v = alive.to_vec();
v.extend_from_slice(extra);
store(&v)
};
let lo = |g: u64| (g & 0xffff_ffff) as u32;
let hi = |g: u64| (g >> 32) as u32;
assert!(
unit_dot_eligible(Some(&with(&[])), Some(ME)),
"the control: an ordinary live creature still takes a dot"
);
assert!(
!unit_dot_eligible(Some(&store(&[(FIELD_HEALTH, 0)])), Some(ME)),
"the dead draw nothing — a SIGNED `<= 0` at 0x4eac1e"
);
assert!(
!unit_dot_eligible(None, Some(ME)),
"…and so does a candidate whose descriptor has not landed"
);
assert!(
!unit_dot_eligible(
Some(&with(&[
(FIELD_SUMMONEDBY, lo(ME)),
(FIELD_SUMMONEDBY + 1, hi(ME)),
])),
Some(ME)
),
"our own minion is not a blip — equality on the owner guid is the REJECT"
);
assert!(
unit_dot_eligible(
Some(&with(&[
(FIELD_SUMMONEDBY, lo(SOMEONE_ELSE)),
(FIELD_SUMMONEDBY + 1, hi(SOMEONE_ELSE)),
])),
Some(ME)
),
"…but somebody ELSE's minion still is"
);
assert!(
!unit_dot_eligible(
Some(&with(&[
(FIELD_CHARMEDBY, lo(ME)),
(FIELD_CHARMEDBY + 1, hi(ME)),
// A non-zero CHARMEDBY wins outright — SUMMONEDBY is only the fallback.
(FIELD_SUMMONEDBY, lo(SOMEONE_ELSE)),
(FIELD_SUMMONEDBY + 1, hi(SOMEONE_ELSE)),
])),
Some(ME)
),
"a unit WE charmed is not a blip, and charm outranks summon"
);
assert!(
!unit_dot_eligible(Some(&with(&[(FIELD_BYTES_1, UNTRACKABLE)])), Some(ME)),
"and the untrackable bit blanks it outright"
);
}
/// Only status 7 dots (the gold cell 3). Status 6 — despite the vmangos "red dot"
/// comment — draws nothing on the 1.12 client (byte-verified `==7` at 0x4eac31).
#[test]
+1 -1
View File
@@ -267,7 +267,7 @@ pub(super) type BlipInputs<'w, 's> = (
Res<'w, crate::ui_script::UiScaleCvar>,
Res<'w, crate::ui_party::GroupState>,
TrackedCandidates<'w, 's>,
Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
Query<'w, 's, (&'static ObjectStore, &'static Guid), With<SelfPlayer>>,
Res<'w, NameCache>,
Res<'w, GameObjectTemplates>,
Option<Res<'w, crate::go_templates::Locks>>,
+9 -6
View File
@@ -751,10 +751,12 @@ fn emit_minimap(
// draw under the player arrow; the quest dots draw LAST — above it (the client's own draw
// order). Hover lands in [`blips::MinimapBlipHover`] for the tooltip drive.
// Our own descriptor's tracking state (PRIVATE fields — only ever on the self entity).
let tracking = self_store
.iter()
.next()
.map(|s| blips::SelfTracking {
let me = self_store.iter().next();
// Our own guid — the classifier compares a candidate's charm/summon owner against it, so our
// own pet and minions never take a dot (§W15 Q2d).
let self_guid = me.map(|(_, g)| g.0);
let tracking = me
.map(|(s, _)| blips::SelfTracking {
creatures: s.0.player_track_creatures(),
resources: s.0.player_track_resources(),
stealthed: s.0.player_track_stealthed(),
@@ -872,6 +874,7 @@ fn emit_minimap(
tracking,
&tracked,
quest.statuses(),
self_guid,
&names,
&go_templates,
locks.as_deref().map(|l| &l.0),
@@ -885,8 +888,8 @@ fn emit_minimap(
blips::emit_quest_dots(
ctx,
quest.statuses(),
&guids,
&unit_pos,
&tracked,
self_guid,
icons,
player_indoors,
// A dot NPC's own containment — the same faces-only down-ray the entity light
@@ -156,6 +156,9 @@ pub(super) fn attacker_state(
swing,
text_only: false,
natural: None,
// The receive-time arm: no tag fired, so the reference has no event point either —
// the consumer falls back to the victim, the only anchor the packet leaves us.
pos: None,
});
}
}
+30 -1
View File
@@ -693,6 +693,21 @@ fn run(
}
}
/// Drain the writer's sent-packet log into the trace as `out` lines — one per packet that reached
/// the socket, by opcode name and body length. A no-op unless the `out` tag armed the log when the
/// connection was handed over.
fn trace_sends(w: &mut WorldWriter) {
w.drain_sent(|opcode, len| {
benilla_assets::trace::line(
"out",
&format!(
"{opcode:#06x} {} len={len}",
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?")
),
);
});
}
/// The single write thread: `select!` between app commands, writer swaps from the read thread, and
/// the 30 s keepalive tick ([`PING_INTERVAL`] — the real client's ping cadence). While disconnected
/// (no writer yet, or the socket died under the current one), commands drop with a capped warn and
@@ -716,7 +731,13 @@ fn writer_loop(
loop {
crossbeam_channel::select! {
recv(writer_rx) -> w => match w {
Ok(w) => {
Ok(mut w) => {
// Arm the outbound opcode trace for this connection (tag `out`). Armed here
// rather than at construction because the sink is an app-side concern and a
// writer outlives none of them; a fresh socket starts a fresh log.
if benilla_assets::trace::enabled_for("out") {
w.watch_sends();
}
writer = Some(w);
warned = 0;
// A fresh connection restarts the keepalive from scratch, like the real
@@ -767,6 +788,7 @@ fn writer_loop(
warned += 1;
}
}
trace_sends(w);
}
},
recv(cmd_rx) -> cmd => {
@@ -1343,6 +1365,13 @@ fn writer_loop(
warned += 1;
}
}
// **What actually reached the socket, by name** (tag `out`) — the outbound twin of
// the `in` line above. One command can be more than one packet, so this drains
// rather than naming the command: the log is the writer's own, recorded after each
// successful write, so a line here is a transmission and never an intention. It is
// what `wire`'s "a silent failure log beside a busy `snd` log means every packet
// went out" was standing in for, said directly.
trace_sends(w);
},
}
}
+331 -30
View File
@@ -85,10 +85,35 @@ const GO_FLAG_INTERACT_COND: u32 = 0x4;
/// an [`unit_ask_key`] analogue. Nothing visible turns on any of this: the answer is dropped
/// ([`crate::net::apply`]'s own typemask gate), which is decision 1872's whole point.
///
/// **The teardown leg** — an NPC whose questgiver bit goes off loses its cached status, so the
/// marker goes with it (`0x5eb0a0`'s own branch), and is re-asked if the bit returns. There is no
/// GameObject counterpart: a GameObject has no status to invalidate (`+0xcb8` is `0x9a8` bytes past
/// the end of a `0x310`-byte `CGGameObject_C`), and all 8 of `0x6073f0`'s call sites are unit-side.
/// **The teardown legs — and there are two of them, neither the one this file used to claim**
/// (decision 1906, wow-re §W15). `0x5eb0a0` is a **2×2 on (questgiver bit × reaction)**, and its
/// `0x5eb134` is a *convergent* block — reached both when the bit is clear (`0x5eb125 je`) and when
/// the bit is set but the reaction failed (`0x5eb132 jg` not taken). That convergence is why three
/// incompatible readings of this callback were live at once. The table:
///
/// | `UNIT_NPC_FLAGS & 0x2` | reaction **> 1** | reaction **≤ 1** |
/// |---|---|---|
/// | **set** | sends `0x182` (`0x5eb159`) | **tears down** (`0x5eb143`) |
/// | **clear** | **nothing** (`0x5eb15e`) | **tears down** (`0x5eb143`) |
///
/// So the sweep tears down **iff the reaction is Hated/Hostile**, whether or not the unit is a
/// questgiver — and it *never* tears down on the flag alone, which is what decision 0647 recorded
/// and what this file did until now. A flag that goes off is torn down by the **`UNIT_NPC_FLAGS`
/// field watch** instead (`0x6043c5` registers `0x604a20` → `0x60b420`, which XORs old against new
/// and calls `0x607380` on *any* change to bit `0x2`) — and `0x607380`'s **first instruction after
/// its prologue** is `0x607384 call 0x6073f0`, an unconditional teardown no branch can skip, with
/// the reaction gate (`0x6073b2`) sitting *after* it and guarding only the re-issued queries. Ours
/// is [`unit_ask_key`] moving, which is the same event observed from outside.
///
/// **The two sweeps therefore disagree, deliberately.** The light sweep (`0x5eb070`, 11 callers)
/// runs the table above. The full re-query sweep (`0x5eb3c0`, 2 callers — **your revive**, and a
/// change to your own record's reaction inputs) runs `0x5eb3f0`, whose creature arm is an
/// unconditional `0x607380`: every creature's marker is torn down and then re-asked for. `full`
/// below is that distinction.
///
/// There is no GameObject counterpart to any of it: a GameObject has no status to invalidate
/// (`+0xcb8` is `0x9a8` bytes past the end of a `0x310`-byte `CGGameObject_C`), and all 8 of
/// `0x6073f0`'s call sites are unit-side.
///
/// **Still not covered**, and why: the reference also sweeps when an *item* moves between
/// containers (an `ITEM` typeid watch, `0x5d9375` — quest availability can depend on carried
@@ -97,12 +122,13 @@ const GO_FLAG_INTERACT_COND: u32 = 0x4;
/// equipping, unequipping and swapping a bag but not moving a stack inside one. Per unit, the
/// reference's reaction refresh also keys on charm/persuade/duel-team/`PLAYER_BYTES_3`; we key on
/// the faction template, and catch a standing change through the reputation sweep instead.
#[allow(clippy::type_complexity)] // a Bevy system: each param is one resource, the app's convention
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // a Bevy system: one param per resource
pub(super) fn query_statuses(
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
objects: Query<(&crate::net::Guid, &NetEntity, &ObjectStore), Without<SelfPlayer>>,
index: Res<GuidIndex>,
factions: Option<Res<Factions>>,
reputations: Res<crate::net::Reputations>,
mut quest: ResMut<QuestGiver>,
commands: Res<NetCommands>,
mut state: Local<QueryState>,
@@ -117,18 +143,36 @@ pub(super) fn query_statuses(
state.fields = self_generation(&store.0);
}
let generation = state.fields ^ (u64::from(quest.reask_epoch()) << 32);
// **This frame IS the sweep.** Both of the reference's object sweeps (`0x5eb070` and the full
// re-query `0x5eb3c0`) walk every object in the manager once, synchronously, when one of the
// 13 local-player state changes fires them; a changed generation is that moment, here. The
// GameObject leg below fires only on it, because that is the only way a GameObject GUID ever
// reaches the wire (§W14.8).
let swept = state.generation != generation;
if swept {
state.generation = generation;
state.asked.clear();
// **The FULL re-query sweep's own two triggers** (`0x5eb3c0`, both self-gated on
// `IsLocalPlayer`): your **revive** — the `≤0 → >0` crossing that `0x6046f0` sends down the
// sibling branch of §W13's death edge — and a change to **your own record's** reaction inputs
// (`0x606e20` → `0x606eef`). Everything else in the trigger set reaches the *light* sweep
// `0x5eb070`, which does not tear anything down. Tracked as edges rather than folded into the
// generation because the direction is the whole point: the death edge is a light sweep and the
// revive edge is a full one, and the folded alive/dead bit cannot tell them apart.
let alive = store.0.unit_health().unwrap_or(1) != 0;
// Compared only between two frames that both *saw* the field: our template arrives with the
// create block, and treating "absent → present" as a change would fire one spurious full sweep
// at every login.
let my_faction = store.0.unit_faction_template();
let full = (alive && state.was_dead)
|| (my_faction.is_some() && state.my_faction.is_some() && state.my_faction != my_faction);
state.was_dead = !alive;
if my_faction.is_some() {
state.my_faction = my_faction;
}
// **This frame IS a sweep.** A sweep walks every object in the manager once, synchronously,
// when one of the reference's local-player state changes fires it; a changed generation is that
// moment. The GameObject leg below fires only on it, because that is the only way a GameObject
// GUID ever reaches the wire (§W14.8) — and a full sweep is a sweep too, even when it arrives
// through an edge the generation does not carry.
let swept = state.generation != generation || full;
state.generation = generation;
// Object lifetime is the other half of the cache key: a guid that left the world drops both
// its "already asked" mark and its cached status, so re-entering view re-asks from scratch.
// The map is NOT cleared by a sweep — it holds each unit's last ask key, and a sweep re-asking
// everyone must stay distinguishable from that unit's own key actually moving, because only
// the second is a `0x607380` and only a `0x607380` tears the marker down.
state.asked.retain(|guid, _| index.0.contains_key(guid));
quest.retain_statuses(|npc| index.0.contains_key(&npc));
for (guid, net, obj) in &objects {
@@ -136,26 +180,46 @@ pub(super) fn query_statuses(
// `0x5eb0a0` @ `0x5eb0d3`: `cmp eax,9; je` — typemask EXACTLY `OBJECT|UNIT`, a plain
// creature. A player is `0x19` and falls out of the sweep here, before any flag test.
EntityKind::Unit => {
if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 {
// No longer a questgiver: drop the stale answer (and the marker with it) —
// **unconditionally**, never "only if we had asked". The reference's sweep
// callback (`0x5eb0a0`) tears the marker down on the flag test alone, with no
// prior-asked precondition (decision 0647), and the gate we used to put here
// was disarmed by the very sweep that arrives with it: an escort's giver drops
// `UNIT_NPC_FLAGS` in the same server tick as the quest-log write (vmangos
// `FollowerAI::StartFollow`, `ScriptedEscortAI`'s "disable npcflags"), so the
// quest-log change bumps the generation, `asked` is cleared above, `remove`
// finds nothing, and the cached AVAILABLE status — with its `!` — was frozen
// over the NPC for the whole escort, with no way back: the flag stays off, so
// this arm never queries either (B257).
// `0x6061e0(ecx = the swept unit, arg = the player)` — the unit's reaction toward
// **us**, which is the direction both `call` sites in the callback use
// (`0x5eb12a`/`0x5eb137`, both `ecx = esi`; §W15 Q1b) and the direction
// [`ring_reaction`] resolves. `<= 1` is Hated/Hostile and nothing else: Unfriendly
// and Neutral both pass. It reads Neutral when anything is missing, so a cold
// catalog can never blank a marker.
let reaction = crate::target::ring_reaction(
factions.as_deref(),
&reputations,
Some(obj),
Some(&store),
);
if reaction <= 1 {
// The sweep's ONE teardown (`0x5eb143`), and it is convergent: it fires for a
// hostile creature whether or not it is a questgiver. Dropping the ask key too
// means the marker comes back through a `0x607380` when the reaction recovers.
state.asked.remove(&guid.0);
quest.clear_status(guid.0);
continue;
}
// Re-ask when this unit's own key moves — its service bits or its faction template.
// The reference does the same per-unit query off those field watches.
let key = unit_ask_key(&obj.0);
if state.asked.insert(guid.0, key) != Some(key) {
let key_moved = state.asked.insert(guid.0, key) != Some(key);
// **`0x607380` — teardown first, unconditionally, then re-gate.** Reached from this
// unit's own field watches (`UNIT_NPC_FLAGS` via `0x60b420`'s XOR, so *either*
// direction; the flightmaster bit; its reaction inputs), from its create/init
// hooks, and from the full sweep's callback. `0x607384 call 0x6073f0` is the first
// instruction after the prologue and no branch can skip it.
//
// This is where **B257** actually lives, and it used to hang off the wrong
// mechanism: an escort's giver drops `UNIT_NPC_FLAGS` in the same server tick as
// the quest-log write (vmangos `FollowerAI::StartFollow`, `ScriptedEscortAI`'s
// "disable npcflags"), and its cached AVAILABLE status — with its `!` — stayed
// frozen over the NPC for the whole escort. The flag is half of [`unit_ask_key`],
// so the drop moves the key, and the key moving is a `0x607380`.
if key_moved || full {
quest.clear_status(guid.0);
}
// …and re-gate: the questgiver bit, on this unit's own key change and on every
// sweep. (Reaction is already `> 1` — the arm above returned otherwise.)
if (key_moved || swept) && obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER != 0 {
let _ = commands
.0
.send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 });
@@ -267,8 +331,13 @@ pub(super) struct QueryState {
fields: u64,
/// `fields` combined with the packet epoch; a change here is the sweep.
generation: u64,
/// Which guids we've asked about, and the [`unit_ask_key`] we asked at.
/// Which guids we've asked about, and the [`unit_ask_key`] we asked at. Pruned by object
/// lifetime only — never by a sweep, so "this unit's own key moved" stays a distinct event.
asked: HashMap<u64, u64>,
/// Our own alive/dead and faction-template from last frame — the two edges that make a sweep a
/// **full** one. `None`/`false` initially, so the first frame in a world is never a full sweep.
was_dead: bool,
my_faction: Option<u32>,
}
#[cfg(test)]
@@ -300,6 +369,7 @@ mod tests {
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.add_systems(Update, query_statuses);
let me = app
@@ -412,6 +482,7 @@ mod tests {
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.add_systems(Update, query_statuses);
let me = app
@@ -486,6 +557,234 @@ mod tests {
);
}
/// **The light sweep re-asks; only the FULL sweep tears down first** (decision 1906,
/// wow-re §W15 Q1d). The two sweeps are not interchangeable and this is the difference:
/// `0x5eb0a0` (11 callers — level, money, the quest log, a skill, `PLAYER_FLAGS`, an item, your
/// **death**, reputation, the group roster, the quest packets) sends and never tears down,
/// while `0x5eb3f0` (2 callers — your **revive**, and your own record's reaction inputs) goes
/// through `0x607380`, whose first post-prologue instruction is an unconditional teardown.
///
/// The death and revive edges are the sharp end: they are the same folded alive/dead bit, one
/// each way, and they land on *different sweeps*. A model that cannot tell them apart either
/// blinks every marker on a damage-to-zero or fails to blink them on the way back.
#[test]
fn a_light_sweep_re_asks_but_only_a_revive_tears_the_marker_down_first() {
use crate::net::{Guid, NetEntity};
use benilla_protocol::{EntityKind, ObjectFields};
const NPC: u64 = 0x7777;
const FIELD_LEVEL: u16 = 34;
const FIELD_HEALTH: u16 = 22;
const FIELD_FACTION: u16 = 35; // UNIT_FIELD_FACTIONTEMPLATE
const FIELD_NPC_FLAGS: u16 = 147;
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.add_systems(Update, query_statuses);
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(1),
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_LEVEL, 5),
(FIELD_HEALTH, 100),
(FIELD_FACTION, 1),
])),
))
.id();
let npc = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Guid(NPC),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0x2)])),
))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(NPC, npc);
let asked = |app: &mut App| -> usize {
app.update();
rx.try_iter()
.filter(
|c| matches!(c, ClientCommand::QuestgiverStatusQuery { npc } if *npc == NPC),
)
.count()
};
let set_self = |app: &mut App, pairs: &[(u16, u32)]| {
*app.world_mut()
.entity_mut(me)
.get_mut::<ObjectStore>()
.unwrap() = ObjectStore(ObjectFields::from_pairs(pairs));
};
let restore = |app: &mut App| {
app.world_mut()
.resource_mut::<QuestGiver>()
.set_status(NPC, 5);
};
let held = |app: &App| app.world().resource::<QuestGiver>().status(NPC);
assert_eq!(asked(&mut app), 1, "the create-path query");
restore(&mut app);
// The packet half of the LIGHT sweep — a quest turn-in, a reputation change, a roster
// change. Re-asks; the `!` stays up until the fresh answer lands.
app.world_mut().resource_mut::<QuestGiver>().bump_reask();
assert_eq!(asked(&mut app), 1, "a light sweep re-asks");
assert_eq!(held(&app), Some(5), "…and does NOT tear the marker down");
// Your DEATH — the `>0 → ≤0` edge, which §W13 pins to the light sweep. Same rule.
set_self(
&mut app,
&[(FIELD_LEVEL, 5), (FIELD_HEALTH, 0), (FIELD_FACTION, 1)],
);
assert_eq!(asked(&mut app), 1, "death re-asks");
assert_eq!(held(&app), Some(5), "…and is still a light sweep");
// Your REVIVE — the `≤0 → >0` edge, the sibling branch, and the FULL sweep.
set_self(
&mut app,
&[(FIELD_LEVEL, 5), (FIELD_HEALTH, 100), (FIELD_FACTION, 1)],
);
assert_eq!(asked(&mut app), 1, "revive re-asks");
assert_eq!(
held(&app),
None,
"…and tears every creature's marker down first (0x5eb3f0 -> 0x607380)"
);
// Your own record's reaction inputs moving — the full sweep's other caller. It is NOT in
// the folded generation at all, so this also pins that `full` can raise a sweep by itself.
restore(&mut app);
set_self(
&mut app,
&[(FIELD_LEVEL, 5), (FIELD_HEALTH, 100), (FIELD_FACTION, 2)],
);
assert_eq!(asked(&mut app), 1, "our own faction change sweeps");
assert_eq!(held(&app), None, "…fully");
}
/// **The sweep tears down on HOSTILITY, never on the questgiver flag** (decision 1906, wow-re
/// §W15 Q1a). `0x5eb0a0`'s `0x5eb134` is a convergent block, so the reaction test runs for
/// every creature and `0x5eb143` is the callback's only teardown; the flag decides the *send*
/// alone. On the real `FactionTemplate.dbc`, because a reaction gate that never resolves a real
/// faction is not a gate.
#[test]
fn a_hostile_questgiver_is_torn_down_and_never_asked_about() {
use crate::net::{Guid, NetEntity};
use benilla_protocol::{EntityKind, ObjectFields};
const FRIENDLY: u64 = 0x1001;
const HOSTILE: u64 = 0x1002;
const FIELD_LEVEL: u16 = 34;
const FIELD_FACTION: u16 = 35;
const FIELD_NPC_FLAGS: u16 = 147;
/// `FactionTemplate.dbc` 35 = "friendly to players", 14 = the monster template that is
/// hostile to everything — the pair the ring's own tests use.
const TPL_FRIENDLY: u32 = 35;
const TPL_MONSTER: u32 = 14;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("dbc");
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.insert_resource(crate::target::Factions::from_catalog(catalog))
.add_systems(Update, query_statuses);
app.world_mut().spawn((
SelfPlayer,
Guid(1),
// Us: faction template 1 (PLAYER, Human).
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_LEVEL, 5),
(FIELD_FACTION, 1),
])),
));
for (guid, tpl) in [(FRIENDLY, TPL_FRIENDLY), (HOSTILE, TPL_MONSTER)] {
let e = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Guid(guid),
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_NPC_FLAGS, 0x2),
(FIELD_FACTION, tpl),
])),
))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(guid, e);
}
let drain = |rx: &crossbeam_channel::Receiver<ClientCommand>| -> Vec<u64> {
rx.try_iter()
.filter_map(|c| match c {
ClientCommand::QuestgiverStatusQuery { npc } => Some(npc),
_ => None,
})
.collect()
};
// Bring both into view first. That frame is a `0x607380` for each of them (the create/init
// hook: an unconditional teardown, then the re-gate), so a status seeded *before* it would
// be torn down for a reason that has nothing to do with hostility — and it is not a state a
// client can be in anyway, since a status cannot exist for a unit never seen.
app.update();
assert_eq!(
drain(&rx),
vec![FRIENDLY],
"on sight, only the non-hostile questgiver is asked about"
);
// Now both wear a marker, and a sweep runs.
for g in [FRIENDLY, HOSTILE] {
app.world_mut()
.resource_mut::<QuestGiver>()
.set_status(g, 5);
}
app.world_mut().resource_mut::<QuestGiver>().bump_reask();
app.update();
let asked = drain(&rx);
let quest = app.world().resource::<QuestGiver>();
assert_eq!(
asked,
vec![FRIENDLY],
"and the sweep asks about the same one — {asked:x?}"
);
assert_eq!(
quest.status(FRIENDLY),
Some(5),
"the control: a friendly giver keeps its marker"
);
assert_eq!(
quest.status(HOSTILE),
None,
"and a Hated/Hostile one is torn down (0x5eb143), questgiver flag or not"
);
}
/// **A GameObject is asked about on a SWEEP, and only on a sweep** (decision 1872, wow-re
/// `questgiver-marker.md` §W14.6/§W14.8). Three things are being pinned here, and the third is
/// the one a client "improves" without noticing:
@@ -520,6 +819,7 @@ mod tests {
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.add_systems(Update, query_statuses);
let spawn = |app: &mut App, guid: u64, kind: EntityKind, fields: &[(u16, u32)]| {
@@ -653,6 +953,7 @@ mod tests {
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.add_systems(Update, query_statuses);
// Our own store starts with every watched field present, so each leg below is a CHANGE to
+18
View File
@@ -51,6 +51,24 @@ pub(crate) fn scenario_active() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
/// **Is the player UI opted into this capture?** — the harness's [`crate::capture::ui_opted_in`],
/// forwarded from here for the same reason [`scenario_active`] lives here: three consumers outside
/// the harness need the answer (the UI load, the synthetic unit feed, the window size), and **all
/// of them must still compile in a build with no harness in it**. `mod capture` is
/// `#[cfg(feature = "dev")]`; this module is not.
///
/// `false` in a player build is the correct answer and not a stub: that binary has no scenario
/// table and no `$WOW_CAPTURE` handling, so there is no capture for the UI to be opted into.
#[cfg(feature = "dev")]
pub(crate) fn capture_ui_opted_in() -> bool {
crate::capture::ui_opted_in()
}
#[cfg(not(feature = "dev"))]
pub(crate) fn capture_ui_opted_in() -> bool {
false
}
/// **Do the credentials come from the environment?** (`$WOW_USER` / `$WOW_PASS` / `$WOW_CHAR` —
/// the login screen's env fast path.)
///
+87 -23
View File
@@ -23,10 +23,18 @@
//! (a chopwood camp worker's state 234 → 3202; state 233 carries a second mining kit 3782,
//! which no vmangos path ever sets for a player — verified at its source).
//!
//! `$CST`/`$CSL`/`$CSR` are deliberately NOT routed: the client's handler (`0x60c940`) only
//! 3D-**repositions** the already-playing cast sound handle — it contains no play call — and
//! benilla's kit player already tracks looping cast sounds to their caster
//! (`sound/spell.rs`), so the reposition role is covered.
//! `$CST`/`$CSL`/`$CSR` are still NOT routed, but **the reason we gave was wrong** and is
//! corrected here (wow-re `anim-event-position-law.md` §5). The handler `0x60c940` does only
//! 3D-**reposition** the already-playing cast handle — that part held — but it repositions it to
//! the **event's own point** (`0x600143 mov edx,[ebx+0x10]` → `0x60c960`/`0x60c990` → `0x61ceb0`),
//! i.e. to the casting hand, not to the caster. benilla's kit player tracks a looping cast sound
//! to the caster's origin, so the role is *not* covered: we are a body-width out for the whole
//! cast. Left unbuilt rather than half-built, because what the reference does when the
//! GUID-tracked follow and this reposition disagree is not settled here — decision 1915's open.
//!
//! `$FD1..$FD9`/`$FDX` have no sound route at all yet (they are the CreatureSoundData fidget
//! family). When one lands it wants the **unit origin + 2.0 z** (`0x6232c0` → `0x6230a0`), not
//! the fired key's point — the same shape `$TRD` takes below.
//!
//! The footstep family and the CreatureSoundData-driven tags (`$FD*` fidgets, `$AH*` attacks,
//! `$CSS` swings) are routed by their own consumers as those land (slice-3 tasks); unrecognized
@@ -44,6 +52,14 @@ use super::emote::EmoteSounds;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// `$TRD`'s own z-bias — `0x62fb3f fadd [0x7ff9d8]`, the constant `1.0`. Distinct from the
/// footstep foley's `2.0` and from the per-attachment table, and it is added to the unit's own
/// position because this arm is handed no point at all.
const TRD_HEIGHT: f32 = 1.0;
/// The attachment the emote voice is born at — `0x623c3a push 0x11`.
const CSD_ATTACH: u16 = 17;
#[allow(clippy::too_many_arguments)] // the standard sound-route param set + the two resolvers
pub(super) fn route_anim_events(
mut events: MessageReader<AnimSoundEvent>,
@@ -55,6 +71,9 @@ pub(super) fn route_anim_events(
// [`crate::go_anim::GoAnim`] — the same population the reference registers `0x5f3e20` for —
// and that dispatcher's DS-family arms differ from the placed-M2 handler `0x6951e0`'s.
go_lane: Query<(), With<crate::go_anim::GoAnim>>,
// The attachment reads `$CSD` needs (`0x623b90`) — the same pure position read the overhead
// anchor makes, spawning nothing.
attach: crate::entities::AttachPoints,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
emotes: Option<Res<EmoteSounds>>,
@@ -84,9 +103,13 @@ pub(super) fn route_anim_events(
let ring = |kits: &mut SoundKits,
out: &mut SoundOutput,
kit: u32,
entity: Entity,
ev: &AnimSoundEvent,
complained: &mut std::collections::HashSet<u32>| {
let pos = transforms.get(entity).map(|t| t.translation()).ok();
// The fired key's own point where the arm is byte-proven to pass it (below), else the
// model root — which is what every arm here used unconditionally before 1904.
let pos = ev
.pos
.or_else(|| transforms.get(ev.entity).map(|t| t.translation()).ok());
if let Err(e) = play_kit(
kits,
&assets,
@@ -133,7 +156,18 @@ pub(super) fn route_anim_events(
// every cycle. Whether anything is audible, from where, and how many at once are the
// pool pump's questions, not this scanner's: see [`super::emitter_pool`].
b"$DSL" if ev.data != 0 => {
if let Ok(t) = transforms.get(ev.entity) {
// **The emitter's point is the marker's, not the model's** (decision 1904). Both
// handlers take the kernel's `eventWorldPos` as an argument and pass it straight
// into the pool: the placed-M2 lane `0x6951e0` is `fn(fourcc, data, &worldPos, …)`
// with `[ebp+0x10]` the `C3Vector*` it hands to `0x461d80`/`0x462000`, and the
// GameObject lane `0x5f3fe5` does the same with its own `p3`. It is not a detail
// here: 149 of the 244 shipped `$DSL` records sit off their model's origin, out to
// **67.6 yd** on `Maraudon_Waterfall01.m2` — a waterfall whose roar was landing at
// the model's pivot instead of the water.
if let Some(at) = ev
.pos
.or_else(|| transforms.get(ev.entity).ok().map(|t| t.translation()))
{
// **The two lanes' `$DSL` arms differ, and only here** (wow-re
// `doodad-sound-emitters.md` §13). The placed-M2 handler `0x6951e0` compares
// the id and swaps; the GameObject dispatcher's arm `0x5f3fe5` does **not
@@ -144,20 +178,10 @@ pub(super) fn route_anim_events(
// GameObject lane the Custom0 hum never displaces the Stand one.
if go_lane.contains(ev.entity) {
super::emitter_pool::register_keeping_first(
&mut pool,
ev.entity,
ev.data,
t.translation(),
listener,
&mut pool, ev.entity, ev.data, at, listener,
);
} else {
super::emitter_pool::register(
&mut pool,
ev.entity,
ev.data,
t.translation(),
listener,
);
super::emitter_pool::register(&mut pool, ev.entity, ev.data, at, listener);
}
}
}
@@ -178,8 +202,36 @@ pub(super) fn route_anim_events(
b"$DSE" if !go_lane.contains(ev.entity) => {
super::emitter_pool::release(&mut pool, ev.entity);
}
b"$SND" | b"$DSO" | b"$CSD" if ev.data != 0 => {
ring(&mut kits, &mut out, ev.data, ev.entity, &mut complained);
// `$SND`/`$DSO` positioned at the fired key: both proven lanes hand the arm the
// kernel's point and it reaches `0x458870(id, pos, -1, 1.0f)` unchanged — the placed-M2
// handler at `0x695205`, the GameObject one at `0x5f3fe0 → 0x5f3f60`.
//
// **`$CSD` is deliberately still at the model root.** It has no arm on either of those
// dispatchers: it is the CGUnit lane's (`0x623c10` → `0x459230`), and whether *that*
// dispatcher's arms take the event point or the unit's own is the one piece of this
// mechanism wow-re has not recorded — dispatched, not assumed. It matters: every player
// model authors six `$CSD` records, all on the head.
b"$SND" | b"$DSO" if ev.data != 0 => {
ring(&mut kits, &mut out, ev.data, ev, &mut complained);
}
// **`$CSD` is at ATTACHMENT 17, never the fired key's point.** The dispatcher hands
// `0x623c10` the *data* and no position (`0x5ffeed`); it asks `0x623b90` for
// attachment `0x11`, falling back to `GetPosition + zBias[17] = 2.0`. The corpus makes
// this the sharpest correction in the table: every player model authors **six** `$CSD`
// records, one per emote clip, all on the head — and not one of them decides where the
// voice plays. (The reference then GUID-binds the handle so it follows the unit,
// `0x7a57e0`/`[unit+0xb28]`; ours is a one-shot at the onset point, which is the same
// sound for a body that is not walking away mid-laugh — noted in 1915.)
b"$CSD" if ev.data != 0 => {
let root = transforms
.get(ev.entity)
.map_or(Vec3::ZERO, |t| t.translation());
let at = attach.point(ev.entity, CSD_ATTACH, root);
let voiced = AnimSoundEvent {
pos: Some(at),
..*ev
};
ring(&mut kits, &mut out, ev.data, &voiced, &mut complained);
}
b"$ESD" => {
let Some(emotes) = emotes.as_deref() else {
@@ -194,7 +246,10 @@ pub(super) fn route_anim_events(
.then(|| emotes.state_event_sound(state))
.flatten()
{
ring(&mut kits, &mut out, kit, ev.entity, &mut complained);
// **EVENT POINT** — `0x5fff5a` pushes the dispatcher's point and `0x623a1e`
// hands it straight to `0x458870`. (This also corrected wow-re's own
// `gather-sound-anim-events.md`, which had glossed that `edx` as the payload.)
ring(&mut kits, &mut out, kit, ev, &mut complained);
}
}
b"$TRD" => {
@@ -204,7 +259,16 @@ pub(super) fn route_anim_events(
let hold = units.get(ev.entity).ok().and_then(|(_, h)| h);
let kit = hold.and_then(|h| held_strike_sound(spells, &visuals.0, h.spell_id));
if let Some(kit) = kit {
ring(&mut kits, &mut out, kit, ev.entity, &mut complained);
// **UNIT ORIGIN + 1.0 z** — the dispatcher hands `0x62faa0` nothing at all
// (`0x5ffedb mov ecx,esi; call`), and it re-derives: `0x62fb39 call [edx+0x14]`
// GetPosition, `0x62fb3f fadd [0x7ff9d8]` = +1.0, then `0x458870`. The mining
// pick's clang comes from the miner, raised, not from the pick's own keyframe.
let at = transforms
.get(ev.entity)
.map(|t| t.translation() + Vec3::Y * TRD_HEIGHT)
.ok();
let unit_root = AnimSoundEvent { pos: at, ..*ev };
ring(&mut kits, &mut out, kit, &unit_root, &mut complained);
}
}
other => {
+23 -6
View File
@@ -440,6 +440,9 @@ type CombatUnit = (
/// parameter ceiling — and because they are one thing: the melee sound vocabulary. Each is
/// independently optional, like every DBC-backed resource here; absent, its own branch goes
/// quiet rather than the system failing.
/// The attachment a **whiffed** swing's whoosh is born at — `0x624bdd call 0x712cb0(1)`.
const MISS_ATTACH: u16 = 1;
#[derive(bevy::ecs::system::SystemParam)]
struct MeleeTables<'w> {
impacts: Option<Res<'w, WeaponImpacts>>,
@@ -460,6 +463,7 @@ fn combat_sounds(
mut last: Local<LastSwing>,
units: Query<CombatUnit>,
tables: MeleeTables,
attach: crate::entities::AttachPoints,
mut items: Option<ResMut<crate::items::Items>>,
net_commands: Res<crate::net::NetCommands>,
kits: Option<ResMut<SoundKits>>,
@@ -598,11 +602,16 @@ fn combat_sounds(
} else {
COMBAT_MISS_1H
};
// **ATTACHMENT 1, not the fired key** — the one place in the whole event-position
// table where a *whiff* and a *hit* of the same tag disagree (`0x624baa`:
// `0x624bdd call 0x712cb0(1)`, else `GetPosition`). The whoosh comes from the hand,
// the impact from where the weapon met something.
let at = attach.point(ev.entity, MISS_ATTACH, attacker_tr.translation);
play(
&mut kits,
&mut out,
kit,
attacker_tr.translation,
at,
PlayExtras {
bus: Bus::DEFAULT,
..default()
@@ -626,7 +635,10 @@ fn combat_sounds(
&mut kits,
&mut out,
kit,
attacker_tr.translation,
// **EVENT POINT** — `0x624c6e mov ecx,[ebx+0x10]` straight into `0x457f60`, which
// pushes it through to `0x458890`. 353 of the 363 shipped `$CSS` records sit off
// their model's origin, on a moving bone; Thunderaan's is 26.4 yd out.
ev.pos.unwrap_or(attacker_tr.translation),
PlayExtras {
bus: Bus::WEAPON_SWING,
// `0x457f74`/`0x457f7d`: half volume when the hit flags carry `HITINFO_MISS`,
@@ -652,10 +664,15 @@ fn combat_sounds(
let swing = &imp.swing;
let attacker = units.get(swing.attacker).ok();
let victim = swing.victim.and_then(|v| units.get(v).ok());
// Positioned at the attacker; the receive-time fallback (unresolved attacker) emits at
// the victim — the only anchor the packet leaves us.
let Some(pos) = attacker
.map(|(t, ..)| t.translation)
// **The fired tag's own point** (`edi = [ebx+0x10]`, pushed at `0x6248ef` for the
// CustomAttack column and `0x624950` for the generic weapon impact — wow-re
// `anim-event-position-law.md` §3). A big creature's `$AH1` sits 4.7 yd out on the jaw
// (`trex.m2`) and Thunderaan's `$CAH` 26.4 yd out; the attacker's origin was standing in
// for both. The receive-time fallback carries no point — no tag fired — so it keeps the
// attacker, then the victim, which is the only anchor that packet leaves us.
let Some(pos) = imp
.pos
.or_else(|| attacker.map(|(t, ..)| t.translation))
.or_else(|| victim.map(|(t, ..)| t.translation))
else {
continue;
+9 -1
View File
@@ -175,6 +175,8 @@ fn footstep_sounds(
.and_then(|d| creatures.as_deref()?.foley_material(d)),
};
if let Some(kit) = material.and_then(|m| materials.0.foley_kit(m)) {
// The unit's own origin, raised — NOT the fired key's point. Byte-confirmed at
// `0x45851d fadd [0x801628]` (see the terrain leg below for the split).
let mut at = transform.translation();
at.y += FOLEY_HEIGHT;
if let Err(e) = play_kit_ext(
@@ -243,7 +245,13 @@ fn footstep_sounds(
&config,
listener,
KitRef::Id(kit),
Some(transform.translation()),
// **The terrain footstep is at the EVENT's point, the foley above is not** — one arm,
// two sounds, two positions (wow-re `anim-event-position-law.md` §3, §5-verified).
// `0x623390` hands its `pos` argument to `0x458380`, where it is *both* the
// audibility-radius reference and the 3D play position (`0x458434`); the foley leg
// `0x6233d9 call [vt+0x8c]` is argument-less and re-derives `GetPosition + 2.0 z`
// itself. Playing both at the same place would over-move one of them.
Some(ev.pos.unwrap_or_else(|| transform.translation())),
SoundCategory::Sfx,
PlayExtras {
bus: Bus::FOOTSTEP,
+7 -1
View File
@@ -149,7 +149,13 @@ pub(super) fn go_display_sounds(
if kit == 0 {
continue;
}
let pos = transform.translation();
// **Where the key fired, not where the object stands** (decision 1904): `0x5f3e20`'s
// `[ebp+0x10]` is the kernel's `eventWorldPos` and both lanes below take it verbatim —
// `0x458870(id, pos, -1, 1.0f)` for the one-shot, `0x461d80(id, pos, 0)` for the pool. It
// is the difference between a portal's hum coming from the portal and from the model's
// pivot: 82 of the 135 shipped `$GC0` records sit off their origin, out to 63.4 yd on
// `orc_waterwheel.m2`, and 83 of 177 `$GO0`s do.
let pos = ev.pos.unwrap_or_else(|| transform.translation());
// The lane select. A looping kit is NOT a looping channel here: it is a *registration* in
// the shared emitter pool, which is what makes one hum follow you down a row of braziers
// instead of thirty channels stacking — and what makes a re-crossing of the marker (the
+273 -18
View File
@@ -152,6 +152,40 @@ pub(crate) struct ServiceArms<'w> {
/// The spirit healer's XP-loss question (bit 5): `0x5df730` does the same, firing
/// `CONFIRM_XP_LOSS`.
pub(crate) death: ResMut<'w, crate::death::DeathNet>,
/// `[0xb4e2d0]/[0xb4e2d4]`'s mirror — the NPC whose window is open, which the dispatcher
/// compares the clicked guid against *before* the ladder ([`interaction_already_open_on`]).
pub(crate) interact: Res<'w, crate::ui_session::InteractNpc>,
/// The cursor, for the vendor arm's fork (`0x5df5d7`) — peeked to choose the arm's leg, then
/// taken through the **sell** clear once the sale actually goes out. `Option` because a
/// headless net-only build mounts no VM.
pub(crate) script: Option<NonSendMut<'w, benilla_ui::script::UiScript>>,
}
/// **The re-click gate** — `0x5f0130`'s own, at `0x5f0251`/`0x5f025c`, sitting between the range
/// test and the ladder:
///
/// ```text
/// 5f024c 8b 46 08 mov edx,[esi+8] → [eax] ; the CLICKED unit's guid
/// 5f0251 3b 15 d0 e2 b4 00 cmp edx,[0xb4e2d0] ; the interaction NPC, low
/// 5f0257 75 0f jne 0x5f0268 ; different → on to the ladder
/// 5f025c 3b 05 d4 e2 b4 00 cmp eax,[0xb4e2d4] ; high
/// 5f0262 0f 84 62 03 00 00 je 0x5f05ca ; SAME → the silent return
/// ```
///
/// So **right-clicking the NPC whose window is already open does nothing at all**: no packet, no
/// error, and no talk gesture — the gesture lives at the tail of each arm, and no arm runs.
///
/// `[0xb4e2d0]` is the `"npc"` UnitID token (wow-re `ui/scratch/unit-token-grammar.md` §4.1 row 9),
/// and the timing is what makes this safe to transcribe: an image-wide census of the two write
/// sites — the arm at `0x493114` inside `SetInteractionNPC 0x4930d0`, and `0x49334b`'s clear inside
/// `0x493310` — finds **fourteen callers of the setter, none of them on the click path**. Every one
/// is a window *opener*, armed when the server's reply lands. The first click therefore always gets
/// through (nothing is open yet); only a re-click while the window stands is eaten. That is exactly
/// the set [`crate::ui_session::feed_interact_npc`] collapses, which is why this reads that
/// resource rather than growing a second latch — the mailbox and the item-text reader are the two
/// arming sites it leaves out, and neither is a unit a right-click can land on.
fn interaction_already_open_on(target: u64, interact: &crate::ui_session::InteractNpc) -> bool {
interact.1 == Some(target)
}
/// On a clean right-*click* (vanilla's context action — [`WorldRightClick`], never a turn-drag):
@@ -161,11 +195,12 @@ pub(crate) struct ServiceArms<'w> {
/// action-bar attack's path (decision 0073's verified attack-start: SETSHEATHED then ATTACKSWING).
/// - **Loot** (dead + `UNIT_DYNFLAG_LOOTABLE` — the state, not the Pickup cursor kind, which a
/// live vendor shares): open the corpse's loot (`CMSG_LOOT`), decision 0084.
/// - **Interact** on an in-range friendly service NPC: a vendor-only NPC opens the vendor list
/// directly (`CMSG_LIST_INVENTORY`); any other service NPC — gossip, and the out-of-scope
/// banker/trainer/innkeeper/flightmaster — opens via the universal `CMSG_GOSSIP_HELLO`, whose
/// returned menu the gossip window shows (their specialized windows are their own arcs). On the
/// send, our avatar plays EmoteTalk (id 60), which stows the weapon via the anim→sheath reconcile.
/// - **Interact** on an in-range friendly service NPC: the reference's own first-match-wins ladder
/// over `UNIT_NPC_FLAGS` ([`service_arm`] → [`service_action`], decision 1861) — bit 0 GOSSIP
/// first, so a trainer or questgiver that also carries the bit still opens a menu and only a
/// flagless one opens its own window (1865). A click that lands on the NPC whose window is
/// already open is eaten before any of it ([`interaction_already_open_on`], 1905). On a taken
/// arm our avatar plays EmoteTalk (id 60), which stows the weapon via the anim→sheath reconcile.
///
/// The cursor's own gate grays a service beyond `SERVICE_RANGE` (`unable`); we don't send then (no
/// auto-approach yet) — the selection still lands. Attack, by contrast, is never range-gated
@@ -760,10 +795,38 @@ pub(super) fn act_on_right_click(
// projection of the winning bit: eight arms collapse to Speak(6) and two to Buy(3),
// so a kind-keyed dispatch cannot tell a banker from an auctioneer, nor a trainer
// from an innkeeper from a spirit healer. It sent `CMSG_GOSSIP_HELLO` for all eight.
// `0x5f0251` — the click that lands on the NPC we are already talking to is eaten
// whole, before the ladder and before the gesture (decision 1905).
if interaction_already_open_on(guid, &service.interact) {
debug!(
"right-click interact: {guid:#x} — its window is already open, nothing sent"
);
return;
}
let npc_flags = stores
.get(entity)
.map(|(s, _)| s.0.unit_npc_flags())
.unwrap_or(0);
// The vendor fork's input, resolved before the arm is known because it is cheap and
// because `service_action` must stay pure. **Peeked, not taken**: the cursor is only
// cleared once the sale is actually chosen, so a click on a trainer while holding an
// item leaves the item where it is.
//
// The reference addresses the packet with the cursor's own stored guid; we carry
// `(bag, slot)` and resolve it, exactly as the merchant window's sell route does. A
// pickup LOCKS the source slot rather than emptying it, so the guid is there in every
// ordinary case; if it ever is not, the fork simply does not arm and the click opens
// the list instead — the benign side of a divergence we cannot avoid.
let cursor_sale = service
.script
.as_deref()
.and_then(|s| s.cursor_item())
.and_then(|item| {
let slot0 = u8::try_from(item.slot.saturating_sub(1)).unwrap_or(0);
self_store.and_then(|s| {
crate::ui_items::slot_guid(&s.0, item.bag, slot0, &go_inputs.items)
})
});
let Some(arm) = service_arm(npc_flags, service.quest.status(guid)) else {
// `0x5f05ca` — a unit that reaches the ladder and matches no consulted bit
// (repair-only, or a questgiver with nothing on offer) does nothing at all, and
@@ -771,11 +834,25 @@ pub(super) fn act_on_right_click(
debug!("right-click interact: {guid:#x} matches no service bit — nothing sent");
return;
};
match service_action(arm, guid, self_store.is_some_and(|s| s.0.player_is_ghost())) {
match service_action(
arm,
guid,
self_store.is_some_and(|s| s.0.player_is_ghost()),
cursor_sale,
) {
ServiceAction::Send(cmd) => {
debug!("right-click interact: {guid:#x} ({arm:?})");
let _ = seam.net.0.send(cmd);
}
ServiceAction::SellFromCursor(cmd) => {
debug!("right-click interact: {guid:#x} (vendor — selling the held item)");
let _ = seam.net.0.send(cmd);
// Now the cursor goes, through the SELL clear: `CURSOR_UPDATE` fires and the
// source slot stays greyed until the server's inventory update takes it away.
if let Some(script) = service.script.as_deref_mut() {
script.take_cursor_item_for_sale();
}
}
ServiceAction::AskBinder => {
debug!(
"right-click interact: {guid:#x} (innkeeper — CONFIRM_BINDER, no packet)"
@@ -1216,6 +1293,10 @@ pub(crate) fn service_arm(npc_flags: u32, quest_status: Option<u32>) -> Option<S
pub(crate) enum ServiceAction {
/// The arm's own opcode.
Send(ClientCommand),
/// The vendor arm's **cursor fork** (`0x5df5d7`): an item is on the cursor, so this click
/// sells it rather than opening the list — send, then take the cursor through the sell clear
/// ([`benilla_ui::script::UiScript::take_cursor_item_for_sale`]).
SellFromCursor(ClientCommand),
/// Raise `CONFIRM_BINDER` locally and send nothing (`0x5dfdc0`).
AskBinder,
/// Raise `CONFIRM_XP_LOSS` locally and send nothing (`0x5df730`).
@@ -1226,6 +1307,10 @@ pub(crate) enum ServiceAction {
/// One taken arm → what benilla does about it.
///
/// `cursor_sale` is the vendor fork's input: the guid of the **mode-1 item on the cursor**, or
/// `None` for an empty cursor or any other payload — benilla's resolution of the reference's
/// `GetCursorItem 0x494c60`, which returns `[0xb4e248]/[0xb4e24c]` only while `[0xb4d900] == 1`.
///
/// **The two "spirit" arms are ghost-gated at entry** (`0x5df74a` / `0x5df962`, the byte-identical
/// `PLAYER_FLAGS` bit 4 test): a LIVING player who right-clicks a spirit healer or spirit guide
/// carrying no gossip bit gets nothing at all — no packet, no event, no error. That is not an
@@ -1237,11 +1322,31 @@ pub(crate) enum ServiceAction {
/// vmangos still puts a usable menu on screen; sending the faithful opcode into a reply we drop
/// would trade a working affordance for a wire detail nobody can see. Each retires the moment its
/// window exists — that is the condition, written down (decision 1861).
pub(crate) fn service_action(arm: ServiceArm, guid: u64, ghost: bool) -> ServiceAction {
pub(crate) fn service_action(
arm: ServiceArm,
guid: u64,
ghost: bool,
cursor_sale: Option<u64>,
) -> ServiceAction {
match arm {
ServiceArm::Gossip => ServiceAction::Send(ClientCommand::GossipHello { guid }),
ServiceArm::Questgiver => ServiceAction::Send(ClientCommand::QuestgiverHello { npc: guid }),
ServiceArm::Vendor => ServiceAction::Send(ClientCommand::ListInventory { guid }),
// **The vendor arm is a fork, not a send** (`0x5df5d0`). `0x5df5d7 call 0x494c60` is
// `GetCursorItem` — the cursor's own payload guid, answered for **mode 1 only** — and a
// non-zero answer takes the arm to `CMSG_SELL_ITEM 0x1a0` instead of the list. Nothing on
// either leg tests the merchant window, so this sells to a vendor whose window was never
// opened; the re-click gate above is what keeps it from firing when it IS open.
ServiceArm::Vendor => match cursor_sale {
Some(item_guid) => ServiceAction::SellFromCursor(ClientCommand::SellItem {
vendor: guid,
item_guid,
// `xor ecx,ecx` at `0x5df5ee` — the body's `u8 count`, and the client only ever
// writes the zero here. Whole stack (vmangos `HandleSellItemOpcode` corroborates
// the meaning; the byte is the fact).
count: 0,
}),
None => ServiceAction::Send(ClientCommand::ListInventory { guid }),
},
ServiceArm::FlightMaster => ServiceAction::Send(ClientCommand::TaxiQueryNodes { guid }),
ServiceArm::Trainer => ServiceAction::Send(ClientCommand::TrainerList { trainer: guid }),
ServiceArm::SpiritHealer if ghost => ServiceAction::AskSpiritHealer,
@@ -1707,42 +1812,43 @@ mod tests {
/// The eight arms that used to collapse into `CMSG_GOSSIP_HELLO` are the point of this test.
#[test]
fn the_service_arms_send_what_the_reference_sends() {
let sent = |arm, ghost| match service_action(arm, 0x42, ghost) {
let sent = |arm, ghost| match service_action(arm, 0x42, ghost, None) {
ServiceAction::Send(cmd) => format!("{cmd:?}"),
ServiceAction::SellFromCursor(cmd) => format!("sell {cmd:?}"),
ServiceAction::AskBinder => "ask-binder".to_string(),
ServiceAction::AskSpiritHealer => "ask-spirit-healer".to_string(),
ServiceAction::Silent(_) => "silent".to_string(),
};
assert!(matches!(
service_action(ServiceArm::Questgiver, 0x42, false),
service_action(ServiceArm::Questgiver, 0x42, false, None),
ServiceAction::Send(ClientCommand::QuestgiverHello { npc: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Trainer, 0x42, false),
service_action(ServiceArm::Trainer, 0x42, false, None),
ServiceAction::Send(ClientCommand::TrainerList { trainer: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Petitioner, 0x42, false),
service_action(ServiceArm::Petitioner, 0x42, false, None),
ServiceAction::Send(ClientCommand::PetitionShowList { npc: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Vendor, 0x42, false),
service_action(ServiceArm::Vendor, 0x42, false, None),
ServiceAction::Send(ClientCommand::ListInventory { guid: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::FlightMaster, 0x42, false),
service_action(ServiceArm::FlightMaster, 0x42, false, None),
ServiceAction::Send(ClientCommand::TaxiQueryNodes { guid: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Banker, 0x42, false),
service_action(ServiceArm::Banker, 0x42, false, None),
ServiceAction::Send(ClientCommand::BankerActivate { guid: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Auctioneer, 0x42, false),
service_action(ServiceArm::Auctioneer, 0x42, false, None),
ServiceAction::Send(ClientCommand::AuctionHello { auctioneer: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::StableMaster, 0x42, false),
service_action(ServiceArm::StableMaster, 0x42, false, None),
ServiceAction::Send(ClientCommand::ListStabledPets { npc: 0x42 })
));
// The innkeeper asks, mounted or not, alive or not — and sends nothing.
@@ -1755,12 +1861,161 @@ mod tests {
// The two documented deviations keep the greeting until their windows exist.
for arm in [ServiceArm::TabardDesigner, ServiceArm::Battlemaster] {
assert!(matches!(
service_action(arm, 0x42, false),
service_action(arm, 0x42, false, None),
ServiceAction::Send(ClientCommand::GossipHello { guid: 0x42 })
));
}
}
/// **The vendor arm is a fork, and only the vendor arm** — decision 1914.
///
/// `0x5df5d0` asks `GetCursorItem 0x494c60` before anything else, and a mode-1 item on the
/// cursor sends `CMSG_SELL_ITEM` instead of `CMSG_LIST_INVENTORY`. What this pins is the
/// blast radius: the held item must change the **vendor** arm and **nothing else**, because
/// the fork lives inside that one handler and every other arm is reached without ever
/// consulting the cursor. A cursor read that leaked into the ladder would sell an item at a
/// trainer.
#[test]
fn only_the_vendor_arm_reads_the_cursor() {
const VENDOR: u64 = 0xF130_0000_0000_0042;
const ITEM: u64 = 0x4000_0000_0000_0099;
// Empty cursor: the plain list open, exactly as before.
assert!(matches!(
service_action(ServiceArm::Vendor, VENDOR, false, None),
ServiceAction::Send(ClientCommand::ListInventory { guid: VENDOR })
));
// An item held: the sale, addressed to the CLICKED npc, count 0 = whole stack — and the
// variant that carries the cursor clear with it.
assert!(matches!(
service_action(ServiceArm::Vendor, VENDOR, false, Some(ITEM)),
ServiceAction::SellFromCursor(ClientCommand::SellItem {
vendor: VENDOR,
item_guid: ITEM,
count: 0,
})
));
// Every other arm answers the same held or not. `SellFromCursor` may not appear anywhere
// but the vendor arm — that is the property, asserted over the whole enum rather than
// over the arms someone remembered to list.
for arm in [
ServiceArm::Gossip,
ServiceArm::Questgiver,
ServiceArm::FlightMaster,
ServiceArm::Trainer,
ServiceArm::SpiritHealer,
ServiceArm::SpiritGuide,
ServiceArm::Innkeeper,
ServiceArm::Banker,
ServiceArm::Petitioner,
ServiceArm::TabardDesigner,
ServiceArm::Battlemaster,
ServiceArm::Auctioneer,
ServiceArm::StableMaster,
] {
for ghost in [false, true] {
let describe = |a: ServiceAction| match a {
ServiceAction::Send(cmd) => format!("{cmd:?}"),
ServiceAction::SellFromCursor(cmd) => format!("SELL {cmd:?}"),
ServiceAction::AskBinder => "ask-binder".into(),
ServiceAction::AskSpiritHealer => "ask-xp-loss".into(),
ServiceAction::Silent(w) => format!("silent {w}"),
};
let empty = describe(service_action(arm, VENDOR, ghost, None));
let held = describe(service_action(arm, VENDOR, ghost, Some(ITEM)));
assert_eq!(
empty, held,
"{arm:?} (ghost={ghost}) changed its answer because an item was on the cursor"
);
assert!(
!held.starts_with("SELL"),
"{arm:?} (ghost={ghost}) reached the vendor arm's sale"
);
}
}
}
/// **The re-click gate, and the wiring it rests on** — decision 1905.
///
/// The comparison itself is one `==`; what could actually be wrong is the *latch*. The
/// reference arms `[0xb4e2d0]` from fourteen window openers and from nothing on the click
/// path, so the gate is only ever "a window is open on this NPC" — and benilla reads that
/// state off [`crate::ui_session::feed_interact_npc`]'s collapse rather than a latch of its
/// own. So this drives the real feed and asserts the property that makes the transcription
/// sound: **nothing arms it until a window opens, every NPC window arms it, and the arm names
/// the window's own NPC.**
///
/// The first click must always get through — that is the half a wrong latch would break, and
/// it is asserted first.
#[test]
fn the_reclick_gate_fires_only_while_that_npc_s_window_is_open() {
use crate::ui_session::{feed_interact_npc, InteractNpc};
use bevy::ecs::system::RunSystemOnce;
const NPC: u64 = 0xF130_0000_0000_0042;
const OTHER: u64 = 0xF130_0000_0000_0043;
let armed = |seed: &dyn Fn(&mut World)| {
let mut world = World::new();
world.init_resource::<InteractNpc>();
seed(&mut world);
world.run_system_once(feed_interact_npc).unwrap();
world.remove_resource::<InteractNpc>().unwrap()
};
// Nothing open: the FIRST click on any NPC reaches the ladder.
let idle = armed(&|_| {});
assert!(!interaction_already_open_on(NPC, &idle));
assert!(!interaction_already_open_on(OTHER, &idle));
// Each NPC window arms the token with its own NPC — one representative per opener family
// (a menu, a quest panel, a specialized window), which is the shape the gate depends on.
let with_gossip = armed(&|w| {
let mut g = crate::ui_gossip::GossipState::default();
g.npc = Some(NPC);
w.insert_resource(g);
});
let with_quest = armed(&|w| {
let mut q = crate::ui_quest::QuestGiver::default();
q.npc = Some(NPC);
w.insert_resource(q);
});
let with_trainer = armed(&|w| {
let mut t = crate::ui_trainer::TrainerOpen::default();
t.open(NPC, 0, Vec::new(), String::new());
w.insert_resource(t);
});
for (what, armed) in [
("gossip", &with_gossip),
("quest", &with_quest),
("trainer", &with_trainer),
] {
assert_eq!(armed.1, Some(NPC), "{what} did not arm the npc token");
// The re-click on THAT npc is eaten…
assert!(
interaction_already_open_on(NPC, armed),
"{what}: the re-click was not suppressed"
);
// …and a click on the NPC beside him is not. A gate that swallowed this would make
// every neighbouring vendor unclickable while a window stood.
assert!(
!interaction_already_open_on(OTHER, armed),
"{what}: the gate ate a click on a different NPC"
);
}
// Closing the window disarms it — the reference's `0x493310` zeroes the pair, and ours is
// a collapse, so a cleared session must read through as "clickable again".
let closed = armed(&|w| {
let mut t = crate::ui_trainer::TrainerOpen::default();
t.open(NPC, 0, Vec::new(), String::new());
t.clear();
w.insert_resource(t);
});
assert_eq!(closed.1, None);
assert!(!interaction_already_open_on(NPC, &closed));
}
/// **The selection queue, end to end** — Lua in, `Selection` out — over the four ways it is
/// reachable from a player macro. Every one of them is a shipped binding body: `AssistUnit`
/// is ASSISTTARGET's (default `F`), `TargetLastEnemy` is TARGETLASTHOSTILE's (default `G`).
+8
View File
@@ -124,6 +124,14 @@ impl Factions {
pub(crate) fn catalog(&self) -> &FactionCatalog {
&self.0
}
/// Wrap a catalog — **tests only**. A sibling module exercising a reaction gate needs a real
/// `Factions` in its world, and the field stays private otherwise: in a running client the
/// resource is built exactly once, from the DBC, by the loader below.
#[cfg(test)]
pub(crate) fn from_catalog(catalog: FactionCatalog) -> Self {
Self(catalog)
}
}
/// The colour `GetSelectionCircleColor` resolves — the pure classification half of the
+18 -7
View File
@@ -71,8 +71,8 @@ pub(crate) struct QuestGiver {
messages: Vec<UiError>,
/// The re-ask epoch — see [`Self::bump_reask`].
reask: u32,
/// The last `SMSG_QUESTGIVER_STATUS` we **refused**, and how many we have refused this session
/// — `(guid, status)`.
/// What each **refused** `SMSG_QUESTGIVER_STATUS` said, per guid, and how many we have refused
/// this session.
///
/// The only trace of a packet class benilla deliberately throws on the floor. benilla asks the
/// server about quest-flagged GameObjects because the reference does, and vmangos — unlike the
@@ -81,7 +81,11 @@ pub(crate) struct QuestGiver {
/// [`crate::net::apply`]'s `quest_giver_status`). Without this, the *whole* GameObject half of
/// the feature is invisible from inside the client: a correct drop and a query that was never
/// sent both read as "no status", which is exactly the pair a live probe has to tell apart.
refused: Option<(u64, u32)>,
/// Keyed by guid, and pruned with [`Self::retain_statuses`] — **not** a single last-writer
/// slot, which is what this was and why it read as a regression the first time several quest
/// objects were in view at once: every one of them is answered in the same packet drain, so
/// only the last survived to be looked at, and which object that was is archetype order.
refused: HashMap<u64, u32>,
refused_count: u32,
}
@@ -128,6 +132,8 @@ impl QuestGiver {
/// the fresh answer lands (decision 0647).
pub(crate) fn retain_statuses(&mut self, live: impl Fn(u64) -> bool) {
self.statuses.retain(|&npc, _| live(npc));
// The refusal readout follows the same lifetime, for the same reason and to stay bounded.
self.refused.retain(|&npc, _| live(npc));
}
/// Drop one guid's cached status — the NPC stopped being a questgiver, so its marker goes with
@@ -155,13 +161,18 @@ impl QuestGiver {
/// Record a dialog status the typemask gate refused — see [`Self::refused`].
pub(crate) fn refuse_status(&mut self, npc: u64, status: u32) {
self.refused = Some((npc, status));
self.refused.insert(npc, status);
self.refused_count = self.refused_count.saturating_add(1);
}
/// The last refused `(guid, status)` and the session's refusal count — see the field.
pub(crate) fn refused(&self) -> (Option<(u64, u32)>, u32) {
(self.refused, self.refused_count)
/// What the server last answered for `npc` before we refused it, if anything.
pub(crate) fn refused_for(&self, npc: u64) -> Option<u32> {
self.refused.get(&npc).copied()
}
/// How many answers we have refused this session — the summary half of [`Self::refused`].
pub(crate) fn refused_count(&self) -> u32 {
self.refused_count
}
/// The stored dialog status for `npc`, if any. The store-now half of DIALOG_STATUS
+215 -28
View File
@@ -410,6 +410,66 @@ fn feed_server_clock(
script.set_server_unix_time(now);
}
/// The header literal the reference paints for a header whose id is exactly `0` (`0x84b4ec`, its
/// only reference image-wide). It is a **designer-data tell, not a transient**: the only value that
/// can become a header id is a CACHED template's `zoneOrSort`, so it cannot appear while a template
/// is in flight — which is what makes it safe to render at all (wow-re
/// `ui/scratch/questlog-list-rebuild.md`, B5).
const MISSING_HEADER: &str = "Missing header! (quest designers)";
/// One row's grouping/ordering inputs — everything [`order_groups`] needs, and nothing else, so
/// the display order is testable without a Bevy world.
struct GroupRow {
zone_or_sort: i32,
/// The resolved header name for `zone_or_sort` — [`MISSING_HEADER`] for `0`, and the empty
/// string for an id neither `AreaTable` nor `QuestSort` names (the reference's lookup simply
/// finds no string there, and collates it as `""`).
header: String,
level: u32,
title: String,
slot: u8,
}
/// **The quest log's display order** — §5-verified (wow-re `ui/scratch/questlog-list-rebuild.md`;
/// rebuild `0x4de510`, group sort `0x4de751`/`0x4de8f0`, row comparator `0x4deac0`). Returns the
/// header groups in order, each with its rows' indices in order.
///
/// - **Groups**: one per distinct `ZoneOrSort`, and `id == 0` is forced **FIRST**, before any name
/// lookup (`0x4de913`). An id that names no row collates as `""` and lands next. The rest go by
/// resolved name through the client's collator `0x64a4c0`, which is **case-INSENSITIVE**
/// (`_strnicmp 0x414310`) — a byte-wise `cmp` puts `"Elwynn Forest"` and `"eastern kingdoms"` in
/// the wrong relative place.
/// - **Within a group**: **level ASC** (`0x4decd9`), then **title** through that same collator
/// (`0x4decfb`). Descriptor-slot order — what this used to do, and what benilla claimed — was
/// REFUTED: `0x4deac0` reads `entry+0x4` into a stack slot it never uses.
/// - The reference's `qsort` is **unstable**, so a full (level, title) tie is unspecified there.
/// We break it on slot, which is deterministic and so cannot contradict an unspecified order.
fn order_groups(rows: &[GroupRow]) -> Vec<(i32, String, Vec<usize>)> {
let mut groups: Vec<(i32, String, Vec<usize>)> = Vec::new();
for (i, r) in rows.iter().enumerate() {
match groups.iter_mut().find(|(z, _, _)| *z == r.zone_or_sort) {
Some((_, _, idxs)) => idxs.push(i),
None => groups.push((r.zone_or_sort, r.header.clone(), vec![i])),
}
}
// `id == 0` first (rank 0), then everything else by case-folded name — `""` for an
// unresolvable id, so it sorts immediately after.
groups.sort_by_key(|(zos, name, _)| {
if *zos == 0 {
(0u8, String::new())
} else {
(1, name.to_lowercase())
}
});
for (_, _, idxs) in &mut groups {
idxs.sort_by_key(|&i| {
let r = &rows[i];
(r.level, r.title.to_lowercase(), r.slot)
});
}
groups
}
/// Read the self player's `PLAYER_QUEST_LOG` descriptor slots each frame, resolve entries/detail,
/// and push a [`QuestLogState`] snapshot on change (diffed against a `Local`, the crate's standard
/// feed shape). Also refreshes [`QuestLog::active_quest_ids`]/`entry_slots` for the greeting split
@@ -469,35 +529,82 @@ fn feed_quest_log(
*prior_quest_ids = Some(current);
}
// ── Section headers (the ref's zone/sort groups) ────────────────────────────────────────────
// Group rows by their template's ZoneOrSort NAME (positive → AreaTable zone, negative →
// QuestSort sort — [`benilla_formats::QuestHeaderNames`]); an unknown/0/in-flight id falls
// into a shared "Quests" bucket until the template lands. Headers sort alphabetically and
// quests keep descriptor-slot order within their group — INTERIM: the real client's exact
// group/quest ordering (CQuestLog sort) is unpinned; a wow-re follow-up owns it.
const FALLBACK_HEADER: &str = "Quests";
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
for (i, r) in rows.iter().enumerate() {
let zos = quest_log
.template(r.quest_id, &commands)
.map(|t| t.zone_or_sort)
.unwrap_or(0);
let name = header_names
.as_ref()
.and_then(|h| h.0.resolve(zos))
.unwrap_or(FALLBACK_HEADER)
.to_string();
match groups.iter_mut().find(|(n, _)| *n == name) {
Some((_, idxs)) => idxs.push(i),
None => groups.push((name, vec![i])),
// ── The IN-FLIGHT GATE — all of the log, or none of it ──────────────────────────────────────
// The reference's rebuild (`0x4de510`) counts cache misses into `[0xbb7498]`, skips the row
// for each miss entirely (`0x4de66b`/`0x4de67e` — no row AND no header for its group), and
// then `0x4de545` makes any FURTHER rebuild a total no-op — not even `QUEST_LOG_UPDATE` —
// while the counter is non-zero. Only the arriving response decrements it, rebuilding once
// when it reaches 0. So a cold cache shows an EMPTY log (`GetNumQuestLogEntries()` → `0, 0`,
// and the stock UI paints `EmptyQuestLogFrame`), and then the whole thing appears in ONE step,
// complete. **It is never partially filled** (wow-re `ui/scratch/questlog-list-rebuild.md`,
// §5 trio + arbitration; our B3 claim — a placeholder row per in-flight quest — was REFUTED).
//
// The empty window is the COLD case only: the reference loads `WDB/questcache.wdb` into this
// same cache before world-enter (`0x554f9b`), so a returning character's rows are there
// immediately. We have no such disk cache yet, so ours is the cold path every login — which is
// exactly the reference's own behaviour on a fresh `WDB`, not a deviation.
//
// `template()` is still called for every row first, because the miss is what SENDS the query
// (the reference's rebuild sends `CMSG_QUEST_QUERY` too, once per id ever — our `pending` set
// is that same dedupe).
// Deliberately a loop and NOT `.all(..)`: `template()` is what SENDS the query, and the
// reference asks for every missing id, not just the first. Short-circuiting here would leave
// the rest of a cold log unrequested — and then nothing would ever arrive to un-gate it.
let mut all_cached = true;
for r in &rows {
if quest_log.template(r.quest_id, &commands).is_none() {
all_cached = false;
}
}
groups.sort_by(|(a, _), (b, _)| a.cmp(b));
let rows: Vec<Row> = if all_cached { rows } else { Vec::new() };
// ── Section headers (the ref's zone/sort groups) ────────────────────────────────────────────
// One header per distinct `ZoneOrSort` (positive → AreaTable zone, negative → QuestSort sort —
// [`benilla_formats::QuestHeaderNames`]), in first-appearance order, then sorted. Every row
// here has a cached template, so `zone_or_sort` is always the real one — the gate above is
// what makes that true.
//
// **The group order** (`0x4de751`, comparator `0x4de8f0`): `id == 0` is forced FIRST, before
// any name lookup (`0x4de913`); an id that names no row collates as `""` and lands next; the
// rest are ordered by resolved name through the client's collator `0x64a4c0`, which is
// **case-INSENSITIVE** (`_strnicmp 0x414310`) — not the byte `cmp` we had.
//
// **`id == 0` renders as the literal `"Missing header! (quest designers)"`** (`0x84b4ec`, its
// only reference image-wide). That string is a **designer-data tell, not a transient**: the
// only value that can become a header id is a CACHED template's `zoneOrSort`, so it cannot
// appear while anything is in flight. Which is precisely why it is safe to render — and why it
// could not have been, before the gate above existed.
let ordered = {
let keyed: Vec<GroupRow> = rows
.iter()
.map(|r| {
let t = quest_log.templates.get(&r.quest_id);
let zos = t.map(|t| t.zone_or_sort).unwrap_or(0);
GroupRow {
zone_or_sort: zos,
header: if zos == 0 {
MISSING_HEADER.to_string()
} else {
header_names
.as_ref()
.and_then(|h| h.0.resolve(zos))
.unwrap_or_default()
.to_string()
},
level: t.map(|t| t.level).unwrap_or(0),
title: t.map(|t| t.title.clone()).unwrap_or_default(),
slot: r.slot,
}
})
.collect();
order_groups(&keyed)
};
let groups = ordered;
let mut entries: Vec<QuestLogEntryView> = Vec::new();
let mut entry_slots: Vec<Option<u8>> = Vec::new();
let mut header_keys: Vec<Option<String>> = Vec::new();
for (name, row_idxs) in &groups {
for (_, name, row_idxs) in &groups {
let collapsed = quest_log.collapsed.contains(name);
entries.push(QuestLogEntryView {
quest_id: 0,
@@ -541,11 +648,10 @@ fn feed_quest_log(
t.flags & quest_flags::SHARABLE != 0,
build_objectives(t, &r.log_slot, &store.0, &mut items, &mut names, &commands),
),
// Title/level/objectives placeholder while the template is in flight — the
// QUEST_LOG_UPDATE refresh on landing fills the row in. `pushable` false with it:
// the reference reads the same cache, so its button is dark until the answer lands
// too.
None => ("...".to_string(), 0, None, false, Vec::new()),
// Unreachable: the in-flight gate above emptied `rows` unless EVERY template is
// cached, which is the reference's own all-or-nothing rebuild. This arm used to
// paint a `"..."` placeholder row — the behaviour the §5 refuted.
None => unreachable!("the in-flight gate leaves only cached rows"),
};
let complete = if r.log_slot.state & quest_slot_state::COMPLETE != 0 {
1
@@ -863,6 +969,87 @@ fn drain_quest_log_abandons(
mod tests {
use super::*;
// ── order_groups — the §5-verified display order ────────────────────────────────────────────
fn grow(zos: i32, header: &str, level: u32, title: &str, slot: u8) -> GroupRow {
GroupRow {
zone_or_sort: zos,
header: header.into(),
level,
title: title.into(),
slot,
}
}
/// Group order: `id == 0` first regardless of its name, then an unnamed id (`""`), then the
/// rest by CASE-INSENSITIVE name — the client's collator is `_strnicmp`, so a byte-wise
/// compare (which is what this used to do) puts an all-lowercase name in the wrong place.
#[test]
fn groups_put_id_zero_first_then_unnamed_then_case_folded_names() {
let rows = vec![
grow(12, "Elwynn Forest", 1, "a", 0),
grow(-3, "warlock", 1, "b", 1), // lowercase: byte-cmp would sort it after "Westfall"
grow(0, MISSING_HEADER, 1, "c", 2),
grow(40, "Westfall", 1, "d", 3),
grow(999, "", 1, "e", 4), // an id neither table names
];
let order: Vec<i32> = order_groups(&rows).into_iter().map(|(z, _, _)| z).collect();
// `warlock` before `Westfall` is the discriminating pair: case-folded, `wa` < `we`; by
// BYTES, `W`(0x57) < `w`(0x77) puts `Westfall` first. Everything else about this list is
// the same either way, which is why the old byte-wise sort looked fine.
assert_eq!(order, vec![0, 999, 12, -3, 40]);
}
/// The `id == 0` header carries the reference's own literal — a designer-data tell, and
/// reachable only because a row is never listed before its template is cached.
#[test]
fn the_zero_header_is_the_references_literal() {
let rows = vec![grow(0, MISSING_HEADER, 1, "a", 0)];
let groups = order_groups(&rows);
assert_eq!(groups[0].1, "Missing header! (quest designers)");
}
/// Within a group: level ASC, then title (case-insensitively) — NOT descriptor-slot order,
/// which is what benilla claimed and `0x4deac0` refuted.
#[test]
fn within_a_group_it_is_level_then_title_not_slot_order() {
// Deliberately seeded in the reverse of the expected order by slot.
let rows = vec![
grow(12, "Elwynn Forest", 9, "Zzz", 0),
grow(12, "Elwynn Forest", 3, "beta", 1),
grow(12, "Elwynn Forest", 3, "Alpha", 2),
grow(12, "Elwynn Forest", 1, "Anything", 3),
];
let groups = order_groups(&rows);
assert_eq!(groups.len(), 1);
let titles: Vec<&str> = groups[0]
.2
.iter()
.map(|&i| rows[i].title.as_str())
.collect();
// level 1, then the two level-3s by case-folded title ("Alpha" < "beta" — a byte compare
// would put uppercase "Alpha" before "beta" too, so the discriminating pair is below),
// then level 9.
assert_eq!(titles, vec!["Anything", "Alpha", "beta", "Zzz"]);
}
/// The case-folding is load-bearing in its own right: `"apple"` must precede `"Banana"`, which
/// a byte-wise compare gets wrong (uppercase `B` = 0x42 sorts before lowercase `a` = 0x61).
#[test]
fn title_order_is_case_insensitive() {
let rows = vec![
grow(12, "Zone", 5, "apple", 0),
grow(12, "Zone", 5, "Banana", 1),
];
let groups = order_groups(&rows);
let titles: Vec<&str> = groups[0]
.2
.iter()
.map(|&i| rows[i].title.as_str())
.collect();
assert_eq!(titles, vec!["apple", "Banana"]);
}
// ── remap_selection ─────────────────────────────────────────────────────────────────────────
fn header(title: &str) -> QuestLogEntryView {
@@ -119,10 +119,14 @@ fn install_texture_resolvers(world: &mut World, script: &mut UiScript) {
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
/// the WORLD render — unless the UI is opted in, which happens two ways: `WOW_CAPTURE_UI=1` on any
/// scenario, or the scenario **declaring a `ui:` fixture**, which is the harness saying the window
/// is the subject — [`crate::run_mode::capture_ui_opted_in`] is the one predicate, and its doc says why all
/// three consumers of it must agree. A run with no `CaptureMode` is an ordinary client and always
/// wants its UI.
fn ui_wanted(world: &World) -> bool {
!world.contains_resource::<crate::run_mode::CaptureMode>()
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
|| crate::run_mode::capture_ui_opted_in()
}
/// The world-entry UI load, armed at `OnEnter(InWorld)` and run by [`run_pending_entry_load`]
+4 -2
View File
@@ -439,9 +439,11 @@ impl Plugin for UiScriptPlugin {
/// Should this CAPTURE include the player UI (+ the synthetic unit snapshot)? The visual harness's
/// baselines must stay UI-free (they regression-test the WORLD render), so captures skip the UI
/// unless `WOW_CAPTURE_UI=1` opts in. Normal runs always load the UI and never take synthetic data.
/// unless it is opted in — [`crate::run_mode::capture_ui_opted_in`], the SAME predicate the UI load and the
/// window sizing use, because the three disagreeing is how `ui-unitframes` ends up photographing
/// empty frames. Normal runs always load the UI and never take synthetic data.
fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool {
capture.is_some() && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
capture.is_some() && crate::run_mode::capture_ui_opted_in()
}
/// The pointer arbiter (decision 0026): `PointerOverUi = egui dev overlay ∨ player-UI hover`. Runs
+39 -8
View File
@@ -25,9 +25,15 @@
//! `AcceptBattlefieldPort` for theirs. Names are filtered to the query verbs — `Get*`, `Is*`,
//! `Has*`, `Can*`, `Unit*`, `Num*` — which is a loss of coverage, never a wrong assertion.
//!
//! **A call that raises is skipped, not failed.** 53 of the 83 unit-table bindings gate their
//! arguments and raise on a nil token (1834/1836), so a no-argument probe of those is expected to
//! throw; that says nothing about arity. Only a call that *returns* is measured.
//! **A call that raises is skipped, not failed.** A raise says nothing about arity, so only a call
//! that *returns* is measured. That rule used to cost the whole unit surface: the unit bindings
//! gate their token and raise on a nil one (1834/1836), so a no-argument probe threw for every one
//! of them, and the 62 `Unit*` rows this table calls `arity_conf = exact` were silently
//! uncovered — the gap named in this doc and never priced. The probe now **seats a player** and
//! calls `Unit*` with a real token, which took coverage from **220 bindings to 249**. All 29 newly
//! measured agreed with the reference on the first run; the value here is that they cannot drift
//! away from it unnoticed, not that anything was found wrong. The remainder still raise, needing
//! state a seated body does not supply, and are still skipped.
//!
//! ## The name is not a key
//!
@@ -102,7 +108,21 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
by_name.entry(&r.name).or_default().push(r);
}
let s = benilla_ui::script::UiScript::new().expect("VM");
let mut s = benilla_ui::script::UiScript::new().expect("VM");
// **Seat a player, so the unit surface can be probed at all.** The unit bindings gate their
// token and raise on a nil one (1834/1836), so a no-argument probe measured *nothing* for the
// 62 `Unit*` rows this table calls `arity_conf = exact` — the gate's own doc named that gap in
// prose and nobody had priced it. With a body seated they answer instead of raising, which is
// the difference between covering the unit surface and skipping it.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
@@ -128,11 +148,17 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
}
let want = first.arity;
// A call that raises says nothing about arity — 53 of the unit bindings gate their
// arguments and raise on a nil token (1834/1836).
// A call that raises still says nothing about arity. The unit bindings are the reason:
// they gate their arguments and raise on a nil token (1834/1836), so they are called with
// a real token against the body seated above rather than with nothing.
let call = if name.starts_with("Unit") {
format!(r#"{name}("player")"#)
} else {
format!("{name}()")
};
let probe = format!(
"if type({name}) ~= 'function' then return -1 end \
local ok, n = pcall(function() return select('#', {name}()) end) \
local ok, n = pcall(function() return select('#', {call}) end) \
if not ok then return -1 end return n"
);
let got: i64 = match s.eval(&probe) {
@@ -156,8 +182,13 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
}
}
// A floor, not a target: it exists so a change that quietly stops the probe from measuring
// anything fails loudly instead of passing vacuously. Seating a player took the count from
// **220 to 249** by making the unit bindings answerable, so the old floor of 40 was six times
// below reality and would have sat green through losing the entire unit surface. Raise this
// whenever coverage rises; never lower it to make a change fit.
assert!(
checked >= 40,
checked >= 240,
"the gate measured only {checked} bindings — it has stopped covering anything"
);
// The list may only SHRINK. An entry that now agrees is a fix nobody deleted the note for,
+4 -4
View File
@@ -33,10 +33,10 @@ pub use world_assets::*;
mod model;
pub use model::{
bone_target_id, merged_static_mesh_faded, submesh_to_skinned_mesh, submesh_to_static_mesh,
AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment,
ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource,
PoseTrack, ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
ATTRIBUTE_WOW_MERGED_SLOT,
AnimClip, BillboardInfo, ClipEvent, GlobalBone, GlobalSeqChannel, ModelAnimations,
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_FADE_SPHERE, ATTRIBUTE_WOW_JOINT_INDEX,
ATTRIBUTE_WOW_JOINT_WEIGHT, ATTRIBUTE_WOW_MERGED_SLOT,
};
mod adt;
mod terrain;
+21 -1
View File
@@ -496,6 +496,9 @@ impl AssetLoader for M2ModelLoader {
&benilla_formats::parse_m2_event_markers(&bytes).unwrap_or_default(),
&skeleton_pivots(&skeleton_raw),
);
// …and the same pivots again for the per-clip event keys below ([`crate::ClipEvent`]),
// so a fired key's point is bake-consistent with the joint it composes through.
let pivots = skeleton_pivots(&skeleton_raw);
// The animations (decision 0019): build each sequence's clip into one shared `AnimationGraph`
// (the bench scrubs them; gameplay plays Stand). All as labeled sub-assets. `None` unless at
@@ -644,7 +647,24 @@ impl AssetLoader for M2ModelLoader {
// The axis mapping flips signs, so min/max are re-derived componentwise.
bounds_min: wow_to_bevy(anim.bounds_min).min(wow_to_bevy(anim.bounds_max)),
bounds_max: wow_to_bevy(anim.bounds_min).max(wow_to_bevy(anim.bounds_max)),
events: anim.events.clone().into(),
// The kernel's own quantity, baked (see [`ClipEvent`]): the bone-local
// offset for a rigged model and the model-space point for one whose bone
// chain never moves — both from the record's OWN bone, never a by-4CC
// re-find, because a model may author the tag more than once.
events: anim
.events
.iter()
.map(|e| crate::ClipEvent {
time: e.time,
ident: e.ident,
data: e.data,
bone: e.bone,
offset: wow_to_bevy(e.position)
- pivots.get(e.bone as usize).copied().unwrap_or(Vec3::ZERO),
point: wow_to_bevy(e.position),
})
.collect::<Vec<_>>()
.into(),
arm_nodes,
upper_node,
frequency: anim.frequency,
+1 -1
View File
@@ -23,7 +23,7 @@ mod pose;
// names them yet), so rustc can't see this re-export escape — allow silences the resulting
// unused-import false positive on an otherwise-live facade re-export.
#[allow(unused_imports)]
pub use anims::{AnimClip, ModelAnimations, PlayableAnim, ResolvedAnim};
pub use anims::{AnimClip, ClipEvent, ModelAnimations, PlayableAnim, ResolvedAnim};
pub use pose::{PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack};
/// A billboarded submesh's render data (Bevy space): the bone pivot the card rotates about (model-local
+38 -1
View File
@@ -8,6 +8,43 @@ pub use benilla_formats::PlayableAnim;
use bevy::animation::graph::{AnimationGraph, AnimationNodeIndex};
use bevy::prelude::*;
/// One **event keyframe** on a clip — the baked twin of [`benilla_formats::AnimEvent`], exactly as
/// [`super::ModelMarker`] is the baked twin of `EventMarker`, and carrying the same bone-local
/// Bevy-space offset convention.
///
/// It carries **where** as well as **when**, because the reference's event kernel does: `0x719370`
/// computes `placementMatrix · (boneMatrix[event.bone] · event.position)` and snapshots it by
/// value into the deferred callback record every dispatcher reads (wow-re
/// `spell/scratch/camera-shake-producers.md` §5). A consumer that plays at the model root is using
/// the placement alone, which is the same point only where the record sits at the origin — and
/// corpus-wide it does not: 149 of 244 `$DSL` records are off-origin, out to 67.6 yd
/// (`Maraudon_Waterfall01.m2`), and every player model's six `$CSD` records ride the head
/// (`benilla-extract eventmarkerscan`).
///
/// **Two points, because there are two cases and both are exact.** [`Self::offset`] is bone-local
/// and wants the joint's live global ([`benilla_world::rig_anim::RigPose::posed_point`]);
/// [`Self::point`] is the model-space rest position and wants the model's own world frame. The
/// second is not an approximation of the first: with no keys a bone's matrix composes to the
/// identity, so `placement · position` *is* the kernel's quantity — which is the whole placed-doodad
/// population, where 0 of 244 `$DSL` records ride a bone any sequence keys.
#[derive(Debug, Clone, Copy)]
pub struct ClipEvent {
/// Seconds from the clip start.
pub time: f32,
/// The forward-stored 4CC (`*b"$SND"`).
pub ident: [u8; 4],
/// The payload — a SoundEntries id for `$SND`/`$DSL`/`$DSO`, else 0.
pub data: u32,
/// The bone this record rides.
pub bone: u16,
/// The authored point **relative to [`Self::bone`]'s pivot**, Bevy space — compose it with the
/// joint's live global.
pub offset: Vec3,
/// The authored point in the model's own space, Bevy space — compose it with the model's world
/// frame. Equals the kernel's quantity whenever the bone chain is unanimated.
pub point: Vec3,
}
/// One animation in a model's [`ModelAnimations`] graph (decision 0019): its `AnimationData.dbc` id,
/// the graph node an `AnimationPlayer` plays to run it, and whether it loops.
#[derive(Clone)]
@@ -56,7 +93,7 @@ pub struct AnimClip {
/// The sequence's event keyframes (`$SND`/footsteps/`$CSS`… — decision 0070 slice 3), sorted by
/// time, seconds on the clip clock. Shared (`Arc`) so cloning a clip stays cheap; empty for the
/// overwhelming majority of sequences.
pub events: std::sync::Arc<[benilla_formats::AnimEvent]>,
pub events: std::sync::Arc<[ClipEvent]>,
/// Per-arm masked variants `(right, left)` of this clip — graph nodes that animate **only** the
/// right/left arm subtree (shoulder keybone down), for the client's per-slot one-shots played
/// *over* the gait (the draw/stow: mainhand ceremony on the right arm, offhand on the left,
+7 -1
View File
@@ -2,7 +2,13 @@
//! the player mover's frame lines (`benilla::player`'s `move_trace`), its outbound wire lines
//! (`snd`), the anim driver's event lines (`benilla::creature_anim`), and the remote-replay lines
//! (`rly`/`run`, `benilla::net::motion`) — so the layers interleave on a common timeline and a feel
//! report ("it snaps when I land") can be read across all of them. Each file opens with a
//! report ("it snaps when I land") can be read across all of them.
//!
//! **The two wire tags are `in` and `out`, and between them every packet is accounted for by
//! name.** `in` is the full inbound opcode stream (decision 0624); `out` is the full outbound one
//! (decision 1901), written by the write thread from the writer's own post-write log — so an `out`
//! line is a *transmission*, where the mover's `snd` line is a decision taken before the command is
//! even queued and `wire` carries only failures. Each file opens with a
//! `# t0=<unix epoch>` header so **two clients' traces align with each other**, which is what a
//! sender-vs-observer question needs. Costs one `OnceLock` read per call when the env var is unset.
//!
@@ -330,6 +330,16 @@ enum Command {
/// Also counts the models whose pose depends on the §2c remap (benilla plays nothing there today,
/// i.e. bind pose) and the ones reaching a rate-0 freeze leg.
Goanimscan,
/// Sweep every `.m2` and census the **event table's positional half**: the `bone` and
/// `position` every `M2Event` record carries beside its 4CC. The reference's event dispatchers
/// hand their arms the event's own world point (the authored `position` through its bone's live
/// matrix and the model's placement), while a consumer that plays at the model root uses the
/// placement alone — so this reports, per 4CC, how many records sit off the origin and how many
/// ride a bone any sequence keys. Where both are zero the two are the same point.
Eventmarkerscan {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Census the **GameObject display sound slots** (`GameObjectDisplayInfo.Sound[0..9]`) against
/// the only thing that can reach them. Exactly one function in the reference reads those
/// columns (`0x5f4010`) and it is called only from the GO M2 anim-event dispatcher
@@ -870,6 +880,9 @@ fn main() -> Result<()> {
Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?,
Command::Fxlifescan { prefix } => scan::fxlifescan(&mut chain, prefix.as_deref())?,
Command::Goanimscan => scan::goanimscan(&mut chain)?,
Command::Eventmarkerscan { prefix } => {
scan::eventmarkerscan(&mut chain, prefix.as_deref())?
}
Command::Goslotscan => scan::goslotscan(&mut chain)?,
Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?,
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
@@ -38,7 +38,7 @@ pub use particles::{
pub use sequence::{
fxlifescan, goanimscan, goslotscan, idleslotscan, seqclockscan, soundeventscan,
};
pub use skeleton::{attachscan, bonescan};
pub use skeleton::{attachscan, bonescan, eventmarkerscan};
pub use world::{doodadscan, placescan, skyboxscan, wmodoodads};
/// Every `.m2` in the chain, in listfile order, narrowed to a path `prefix` when one is given.
@@ -581,3 +581,134 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
Ok(())
}
/// Sweep every `.m2` and census the **event table's positional half** — the `bone` and `position`
/// fields every `M2Event` record carries beside its 4CC and timestamps.
///
/// The question it exists to answer: the reference's animation-event dispatchers hand their arms
/// the event's **own** world point (`0x5f3e20`'s `[ebp+0x10]`, `&M2SceneCallback.position` — the
/// authored `position` transformed by its bone's live matrix and the model's placement), while a
/// consumer that plays every anim-event sound at the model root is using the placement alone. That
/// difference is worth code only where the records are actually *off* the origin, or on a bone
/// that *moves*; where every record of a tag sits at the origin on a static bone the two are the
/// same point and the simpler consumer is not wrong.
///
/// So, per 4CC: how many records, how many sit more than [`OFF_ORIGIN`] from the model origin (the
/// `position` field is already model-space — it is the marker's REST point, which is why
/// `build_markers` subtracts the bone pivot to get a bone-local offset), how many ride a bone
/// **whose chain any sequence keys** (the bone's own track being empty says nothing: it inherits
/// its parent's matrix), and the largest offset seen with the model that authors it.
pub fn eventmarkerscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
/// Yards from the model origin past which a marker is "off the origin". Well under any
/// audible 3D difference, so it is a *presence* threshold, not a significance one.
const OFF_ORIGIN: f32 = 0.01;
let names = super::m2_names(chain, prefix)?;
// 4CC → (records, off-origin, on a keyed bone, max offset, the model + bone at that max)
let mut per_tag: BTreeMap<String, (u32, u32, u32, f32, String)> = BTreeMap::new();
let (mut scanned, mut carriers) = (0u32, 0u32);
// Models authoring the SAME 4CC more than once, and those whose duplicates disagree about
// where they are: the reference's kernel fires a specific RECORD and snapshots that record's
// point, so a consumer that resolves the tag by a first-match table scan answers the wrong
// point exactly here.
let (mut dup_models, mut dup_split) = (0u32, 0u32);
let mut dup_examples: Vec<String> = Vec::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let Ok(format) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else {
continue;
};
let model = format.model();
scanned += 1;
if model.event_markers.is_empty() {
continue;
}
carriers += 1;
// Which bones any sequence keys at all. A marker's own bone being unkeyed is NOT enough
// to make it static — the bone inherits its parent's matrix — so the test walks the
// ancestor chain, which is the difference between "sits still" and "sits still relative to
// something that moves".
let keyed: BTreeSet<u16> = benilla_formats::parse_m2_animations(&bytes)
.iter()
.flat_map(|a| a.bones.iter().map(|b| b.bone))
.collect();
let moves = |bone: u16| {
let mut b = bone as i32;
// Bounded by the bone count: a malformed parent cycle must not spin here.
for _ in 0..model.bones.len() + 1 {
let Ok(idx) = usize::try_from(b) else {
return false;
};
if keyed.contains(&(idx as u16)) {
return true;
}
let Some(rec) = model.bones.get(idx) else {
return false;
};
b = rec.parent as i32;
}
false
};
let mut by_ident: BTreeMap<[u8; 4], Vec<(u16, [f32; 3])>> = BTreeMap::new();
for m in &model.event_markers {
by_ident
.entry(m.ident)
.or_default()
.push((m.bone, m.position));
}
let dups: Vec<_> = by_ident.iter().filter(|(_, v)| v.len() > 1).collect();
if !dups.is_empty() {
dup_models += 1;
let split: Vec<_> = dups
.iter()
.filter(|(_, v)| v.iter().any(|x| *x != v[0]))
.collect();
if !split.is_empty() {
dup_split += 1;
if dup_examples.len() < 12 {
let tags: Vec<String> = split
.iter()
.map(|(i, v)| format!("{}×{}", String::from_utf8_lossy(&i[..]), v.len()))
.collect();
dup_examples.push(format!(" {name} {}", tags.join(" ")));
}
}
}
for m in &model.event_markers {
let d = (m.position[0].powi(2) + m.position[1].powi(2) + m.position[2].powi(2)).sqrt();
let e = per_tag
.entry(String::from_utf8_lossy(&m.ident).into_owned())
.or_insert((0, 0, 0, 0.0, String::new()));
e.0 += 1;
e.1 += u32::from(d > OFF_ORIGIN);
e.2 += u32::from(moves(m.bone));
if d > e.3 {
e.3 = d;
e.4 = format!("{name} bone {}", m.bone);
}
}
}
println!(
"=== M2 event markers: how far each 4CC's records sit from the model origin ===\n \
tag records off-origin on a MOVING bone max offset (yd) where"
);
for (tag, (n, off, keyed, max, where_)) in &per_tag {
println!(" {tag} {n:>7} {off:>10} {keyed:>16} {max:>16.3} {where_}");
}
println!(
"\n=== same-4CC duplicate records ===\n {dup_models} model(s) author some 4CC more than \
once; {dup_split} of them place the duplicates at DIFFERENT (bone, position) pairs — the \
population where resolving a fired event by a first-match table scan gives the wrong \
point, and the reason a fired event has to carry its own record's."
);
for e in &dup_examples {
println!("{e}");
}
eprintln!(
"{scanned} models scanned, {carriers} carry an event table. `position` is model space, so \
a record at 0.000 with no keyed bone renders the model root and the event's own point the \
SAME world position — the transform earns its code only on the rows above it."
);
Ok(())
}
+38 -6
View File
@@ -387,6 +387,18 @@ pub struct AnimEvent {
pub ident: [u8; 4],
/// The payload — a SoundEntries id for `$SND`/`$DSL`/`$DSO`, else 0.
pub data: u32,
/// The bone the record rides (`M2Event.bone` @+8) — the same field [`EventMarker`] carries,
/// here on the *fired* key so a consumer never has to re-find the record by 4CC. It cannot:
/// 75 shipped models author some 4CC more than once at different points, and **every player
/// character model authors `$CSD` six times** (one per emote clip), so a first-match table
/// scan answers the wrong point for five of the six.
pub bone: u16,
/// The record's authored point (`M2Event.position` @+12), **raw WoW model space** — the same
/// convention [`EventMarker::position`] uses. The reference's event kernel (`0x719370`)
/// snapshots `placementMatrix · (boneMatrix[bone] · position)` by value into the callback
/// record every dispatcher then reads, so this is half of where the event *happens*, not just
/// when.
pub position: [f32; 3],
}
/// One animation sequence's per-bone keyframes (decision 0019): its `AnimationData.dbc` id, duration,
@@ -749,7 +761,17 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
// global-timeline ms). Read once here; each sequence below selects + rebases its window,
// exactly like the bone tracks.
let (ev_count, ev_ofs) = (le_u32(b, 0x114) as usize, le_u32(b, 0x118) as usize);
let mut model_events: Vec<([u8; 4], u32, Vec<u32>)> = Vec::with_capacity(ev_count);
/// One `M2Event` record as the file holds it: the 4CC and payload, the bone and point the
/// kernel transforms (see [`AnimEvent::position`]), and the absolute global-timeline
/// timestamps each sequence below selects and rebases into its own band.
struct EventRecord {
ident: [u8; 4],
data: u32,
bone: u16,
position: [f32; 3],
times: Vec<u32>,
}
let mut model_events: Vec<EventRecord> = Vec::with_capacity(ev_count);
for e in 0..ev_count {
let erec = ev_ofs + e * 44;
if erec + 44 > b.len() {
@@ -760,6 +782,8 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
None => break,
};
let data = le_u32(b, erec + 4);
let bone = le_u32(b, erec + 8) as u16;
let position = vec3(b, erec + 12);
let (nts, ots) = (le_u32(b, erec + 36) as usize, le_u32(b, erec + 40) as usize);
let mut times = Vec::with_capacity(nts);
for t in 0..nts {
@@ -769,7 +793,13 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
}
times.push(le_u32(b, o));
}
model_events.push((ident, data, times));
model_events.push(EventRecord {
ident,
data,
bone,
position,
times,
});
}
// Every bone's three channel tracks, read ONCE: the key arrays are shared by every sequence,
@@ -841,13 +871,15 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
}
// This sequence's event keys: the same time-band select + rebase as the bone tracks.
let mut events = Vec::new();
for (ident, data, times) in &model_events {
for &ts in times {
for r in &model_events {
for &ts in &r.times {
if ts >= start && ts <= end {
events.push(AnimEvent {
time: (ts - start) as f32 / 1000.0,
ident: *ident,
data: *data,
ident: r.ident,
data: r.data,
bone: r.bone,
position: r.position,
});
}
}
@@ -48,3 +48,74 @@ fn creature_anim_events_parse_within_sequences() {
);
}
}
/// **A fired key carries its OWN record's `(bone, position)`, not the tag's first match**
/// (decision 1904). The reference's M2 event kernel `0x719370` snapshots
/// `placementMatrix · (boneMatrix[event.bone] · event.position)` by value into the callback record
/// every dispatcher reads, so *where* a key fires is a property of the record, and a consumer that
/// re-finds the tag in the marker table by 4CC answers the wrong point wherever a model authors
/// that tag twice.
///
/// It authors it twice a lot: **every player character model carries six `$CSD` records**, one per
/// emote clip, each on its own bone. Pinned against `HumanMale.m2` — if the parse ever collapsed
/// the records (or dropped the two new fields back to zero), the emote voices would all speak from
/// the first one's bone.
#[test]
fn a_fired_key_carries_its_own_records_bone_and_point() {
let data = benilla_formats::wow_data_or_skip!();
let chain = open_chain(&data).expect("open chain");
let model = "Character\\Human\\Male\\HumanMale.m2";
let bytes = chain.read(model).expect("model bytes");
// The table's own records, and the per-sequence keys that fire them.
let markers = benilla_formats::parse_m2_event_markers(&bytes).expect("event markers");
let csd_records: Vec<_> = markers.iter().filter(|m| &m.ident == b"$CSD").collect();
assert_eq!(
csd_records.len(),
6,
"{model}: the six emote-voice records are what makes this test worth having"
);
let distinct: std::collections::BTreeSet<_> =
csd_records.iter().map(|m| (m.bone, m.data_key())).collect();
assert_eq!(
distinct.len(),
6,
"{model}: the six records are distinct — a first-match lookup cannot stand in for them"
);
// Every fired `$CSD` key must name one of those records, and across the model's sequences the
// keys must reach MORE THAN ONE of them — which is exactly what a by-4CC resolve could not do.
let anims = parse_m2_animations(&bytes);
let mut fired: std::collections::BTreeSet<(u16, [u32; 3])> = Default::default();
for a in &anims {
for e in a.events.iter().filter(|e| &e.ident == b"$CSD") {
let key = (e.bone, bits(e.position));
assert!(
distinct.contains(&key),
"{model}: fired $CSD at bone {} is not one of the authored records",
e.bone
);
fired.insert(key);
}
}
assert!(
fired.len() > 1,
"{model}: only {} distinct $CSD point(s) ever fire — the record identity is being lost \
somewhere between the table and the key",
fired.len()
);
}
/// Exact bit patterns, so a float compare never decides record identity.
fn bits(p: [f32; 3]) -> [u32; 3] {
[p[0].to_bits(), p[1].to_bits(), p[2].to_bits()]
}
trait MarkerKey {
fn data_key(&self) -> [u32; 3];
}
impl MarkerKey for benilla_formats::EventMarker {
fn data_key(&self) -> [u32; 3] {
bits(self.position)
}
}
@@ -342,6 +342,19 @@ impl ObjectFields {
(self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x1 != 0
}
/// `UNIT_FIELD_BYTES_1` byte 3's **`0x4`** — the third neighbour of the ghost (`0x1`) and creep
/// (`0x2`) bits above. Byte-VERIFIED as the minimap object-dot classifier's own third
/// precondition (wow-re `questgiver-marker.md` §W15: `byte [eax+0x213] & 4`, where `eax` is the
/// descriptor block base — `0x210/4 = 132` = `UNIT_FIELD_BYTES_1`; the only `& 4` site on this
/// byte image-wide, by two independent censuses). Set ⇒ the unit draws **no minimap dot at
/// all**, quest or tracking.
///
/// The *name* is INFERRED from vmangos's `UNIT_VIS_FLAGS_UNTRACKABLE`, like its two siblings —
/// no flag-name string exists in the image. The bit and its effect are verified.
pub fn unit_is_untrackable(&self) -> bool {
(self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x4 != 0
}
/// `UNIT_FIELD_AURASTATE` ([`FIELD_UNIT_AURASTATE`]) — the aura-state bit set (defense 1,
/// healthless-20% 2, …); the usable walk tests `1 << (state-1)` against it.
pub fn unit_aura_state(&self) -> u32 {
@@ -935,6 +935,7 @@ impl WorldSession {
stream: self.stream,
encrypter,
chat_language: self.chat_language,
sent: None,
},
))
}
@@ -103,6 +103,21 @@ mod vendor;
pub struct WorldWriter {
pub(super) stream: TcpStream,
pub(super) encrypter: EncrypterHalf,
/// Every packet that has actually reached the socket since the last drain, as
/// `(opcode, body length)` — armed by [`Self::watch_sends`], `None` (and free) otherwise.
///
/// The **outbound twin of the app's inbound `in` trace**. Inbound, every packet is accounted
/// for by name the moment it arrives; outbound there was nothing of the kind — the mover's
/// `snd` lines record a *decision* taken before the command is even queued, and the `wire` tag
/// records only failures, so "did we actually send X?" could be answered only by inference
/// from a silence. That is a poor instrument for a client whose fidelity is partly *what it
/// puts on the wire*: decision 1872 landed a whole feature — the GameObject questgiver query —
/// whose entire observable effect is a send, and the only way to see it from inside was to add
/// state to a game resource.
///
/// Recorded **after** a successful write, never before, so a line here means a transmission
/// and not an intention.
pub(super) sent: Option<Vec<(u16, usize)>>,
/// The language every chat send carries — the logged-in character's faction tongue,
/// inherited from the session at split (set by `WorldSession::player_login` off the roster's
/// race). Load-bearing: vmangos drops the whole message (dot-commands included) when the
@@ -116,6 +131,28 @@ impl WorldWriter {
/// family modules goes through here, so there is exactly one place framing/encryption happens.
/// Visible to them because a private item is in scope throughout its module's descendants.
fn send(&mut self, opcode: u16, body: &[u8]) -> Result<()> {
send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body)
let sent = send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body);
if sent.is_ok() {
if let Some(log) = &mut self.sent {
log.push((opcode, body.len()));
}
}
sent
}
/// Start recording what reaches the socket — see [`Self::sent`]. Idempotent; a writer that is
/// already watching keeps whatever it has not yet handed over.
pub fn watch_sends(&mut self) {
self.sent.get_or_insert_with(Vec::new);
}
/// Hand over (and clear) everything recorded since the last call — `(opcode, body length)` per
/// packet, in send order. A no-op on a writer that is not watching.
pub fn drain_sent(&mut self, mut each: impl FnMut(u16, usize)) {
if let Some(log) = &mut self.sent {
for (opcode, len) in log.drain(..) {
each(opcode, len);
}
}
}
}
+101
View File
@@ -343,6 +343,36 @@ pub(crate) fn clear_cursor(model: &mut Model) {
}
}
/// **The SELL fork's cursor clear** — `0x494b60 SetCursorItem(0, …)`, which is *not* `ClearCursor`
/// and differs from [`clear_cursor`] in exactly one way that a player can see.
///
/// Both drop the payload and fire `CURSOR_UPDATE`. `ClearCursor` also un-locks the source slot;
/// this does **not**: `0x494b60`'s mode-1 arm is entered with `param1 = 0`, which skips `0x495420`,
/// so the sold item's slot stays **greyed** until the server's own inventory update removes it
/// (wow-re `object-layer/scratch/vendor-sell-on-right-click.md` §3, the §5 trio dispatched from
/// benilla 1905). Leaving the lock set is the point: the item is gone from the cursor but not yet
/// gone from the bag, and the grey is what says so.
///
/// Shared by the two verbs that sell off the cursor — `PickupMerchantItem`'s sell fork
/// (`0x4fb760`) and the interact ladder's vendor arm (`0x5df5d0`) — because they are the same
/// clear in the binary, and two copies of it is how one of them quietly grows a different
/// behaviour. Returns the item so the caller can address the packet by its slot.
///
/// A non-item payload is left exactly where it was: the fork answers for **mode 1 only**
/// (`GetCursorItem 0x494c60`), so a spell, a macro or a vendor row on the cursor is not a sale
/// and must not be dropped by asking.
pub(crate) fn take_cursor_item_for_sale(model: &mut Model) -> Option<CursorItem> {
let item = match model.cursor.take() {
Some(CursorPayload::Item(item)) => item,
other => {
model.cursor = other;
return None;
}
};
queue_cursor_update(model);
Some(item)
}
/// What the app's world pick resolves under the cursor this frame — the reference's click-time
/// pick state `[this+0x350]` (0 = nothing / 1 = terrain / 2 = object; `0x481f60`, decisions
/// 0571 + 0574). Fed per frame by the app ([`super::UiScript::set_world_pick`]; stays
@@ -486,6 +516,13 @@ impl super::UiScript {
self.model_mut().world_pick = pick;
}
/// The sell fork's take-and-clear, for the app-side interact ladder — see
/// [`take_cursor_item_for_sale`]. `None` for an empty cursor or any non-item payload, and in
/// that case the payload is left untouched.
pub fn take_cursor_item_for_sale(&mut self) -> Option<CursorItem> {
take_cursor_item_for_sale(&mut self.model_mut())
}
/// `ClearCursor()`'s Rust seam — drops whatever the cursor holds, any arm, silently (fires
/// `CURSOR_UPDATE` + the item source un-lock, exactly the Lua `ClearCursor`). The app's
/// world-click router calls it for a clean RIGHT-click over empty world (`0x492c90`/
@@ -729,6 +766,70 @@ mod tests {
}
}
/// **The sell clear is not `ClearCursor`** — decision 1914, and the difference is the one a
/// player sees.
///
/// `0x494b60 SetCursorItem(0, …)` drops the payload and fires `CURSOR_UPDATE`, but its mode-1
/// arm skips `0x495420` with `param1 = 0`, so it never un-locks the source slot: the item is
/// off the cursor and the bag slot stays **greyed** until the server's inventory update takes
/// it away. `ClearCursor` un-locks. Firing `ITEM_LOCK_CHANGED` here would un-grey a slot whose
/// item is still sitting in it awaiting the server, which is the visible wrong.
///
/// The second half is the blast radius: the fork answers for **mode 1 only**, so asking a
/// spell or a vendor row must not drop it.
#[test]
fn the_sell_clear_keeps_the_source_slot_locked() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
// Pick the item up, then drain whatever the pickup queued so the assert below sees only
// what the SELL clear itself fires.
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "the pickup did not take");
s.model_mut().pending_events.clear();
let sold = s.take_cursor_item_for_sale().expect("mode 1 is a sale");
assert_eq!((sold.bag, sold.slot), (0, 1));
assert!(
s.cursor_payload().is_none(),
"the cursor kept the sold item"
);
let fired: Vec<String> = s
.model_ref()
.pending_events
.iter()
.map(|(e, _)| e.clone())
.collect();
assert!(
fired.iter().any(|e| e == "CURSOR_UPDATE"),
"the sell clear must fire CURSOR_UPDATE — got {fired:?}"
);
assert!(
!fired.iter().any(|e| e == "ITEM_LOCK_CHANGED"),
"the sell clear must NOT un-lock the source slot (that is `ClearCursor`'s job, not \
`SetCursorItem(0, …)`'s) — got {fired:?}"
);
// Mode 1 only: every other payload is left exactly where it was.
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
passive: false,
}));
assert!(
s.take_cursor_item_for_sale().is_none(),
"a spell is not a sale"
);
assert!(
matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))),
"asking for a sale dropped a non-item payload"
);
}
#[test]
fn get_cursor_info_and_has_checks_per_arm() {
let mut s = UiScript::new().unwrap();
+7 -5
View File
@@ -371,11 +371,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, index: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The sell fork, and it comes FIRST — before the argument is even looked at.
if let Some(CursorPayload::Item(item)) = &model.cursor {
let pair = (item.bag, item.slot);
model.cursor = None;
model.merchant_cursor_sells.push(pair);
// The sell fork, and it comes FIRST — before the argument is even looked at. The
// clear is the SELL clear, not `ClearCursor`: it fires `CURSOR_UPDATE` and leaves the
// source slot greyed until the server's inventory update
// (`cursor::take_cursor_item_for_sale`, shared with the interact ladder's vendor arm,
// which is the same `0x494b60` call — decision 1914).
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(&mut model) {
model.merchant_cursor_sells.push((item.bag, item.slot));
return Ok(());
}
+56 -3
View File
@@ -337,12 +337,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
0 => Value::Nil,
c => Value::Integer(i64::from(c.signum())),
};
// **Returns 4 and 5 are `1`/`nil`, NOT booleans** (wow-re
// `ui/scratch/questlog-title-tag.md` §7's table): `isHeader` is `1` on a header row
// and `nil` on a quest; `isCollapsed` is `1` only for a header whose bit in
// `[0xbb748c]` is clear — a quest row and an EXPANDED header both answer `nil`. We
// pushed `true`/`false`, which every `if ( isHeader )` in FrameXML reads the same and
// which `isHeader == false` or a `type()` test does not. The two are one expression
// here because `collapsed` is meaningless off a header (the field's own doc).
let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil };
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&e.title)?),
Value::Integer(i64::from(e.level)),
tag,
Value::Boolean(e.is_header),
Value::Boolean(e.collapsed),
flag(e.is_header),
flag(e.is_header && e.collapsed),
complete,
]))
})?,
@@ -892,7 +900,7 @@ mod tests {
.eval::<bool>(
"local t, l, tag, h, c, done = GetQuestLogTitle(1)\n\
return t == 'A Threat Within' and l == 1 and tag == nil\n\
and h == false and c == false and done == nil"
and h == nil and c == nil and done == nil"
)
.unwrap());
assert!(s
@@ -950,6 +958,51 @@ mod tests {
}
}
/// §7's table for returns 4 and 5: `1` on a header, `nil` on a quest; `isCollapsed` is `1`
/// only for a COLLAPSED header — an expanded one and every quest row answer `nil`.
#[test]
fn is_header_and_is_collapsed_are_one_or_nil_never_booleans() {
let mut s = UiScript::new().unwrap();
let mut state = two_quests();
state.entries.insert(
0,
QuestLogEntryView {
title: "Elwynn Forest".into(),
is_header: true,
collapsed: true,
..Default::default()
},
);
state.entries.insert(
1,
QuestLogEntryView {
title: "Westfall".into(),
is_header: true,
collapsed: false,
..Default::default()
},
);
s.set_quest_log(state);
// A collapsed header: both flags are the number 1.
assert!(s
.eval::<bool>(
"local _, l, _, h, c = GetQuestLogTitle(1) return h == 1 and c == 1 and l == 0"
)
.unwrap());
// An EXPANDED header: isHeader 1, isCollapsed nil.
assert!(s
.eval::<bool>("local _, _, _, h, c = GetQuestLogTitle(2) return h == 1 and c == nil")
.unwrap());
// A quest row: both nil — and `false` would be wrong, so pin the type too.
assert!(s
.eval::<bool>(
"local _, _, _, h, c = GetQuestLogTitle(3)\n\
return h == nil and c == nil and type(h) ~= 'boolean'"
)
.unwrap());
}
#[test]
fn tag_is_the_bare_word_and_nil_when_absent() {
// The third return is the BARE word — the ref's own Lua adds the parentheses
+102 -10
View File
@@ -39,8 +39,15 @@
//! in, then `max` forced to `1` on a **single-rank** line whatever the server said
//! ([`SkillEntry::mono`]; the pane's proficiency gate is `skillMaxRank == 1`).
//!
//! `numTempPoints` is always `0`: its only writer in the real client is `AddSkillUp`, wired solely
//! to the training-up arrow this pane doesn't ship. `stepCost`/`rankCost` are always **nil** —
//! `numTempPoints` is a **REAL FIELD** (`entry->+0x10`) that this client answers as `0`. The
//! distinction matters and this comment used to blur it (decision 1919): the field has four writers
//! in the real client — zeroed per list rebuild (`0x4d2e2e`), incremented by `AddSkillUp`'s worker
//! (`0x4d345b`), decremented at `0x4d358a`, reset by `CancelSkillUps` (`0x4d35c9`) — and
//! `SkillFrame.xml:697` wires `AddSkillUp` for real. What is true is a **data-reachability** verdict
//! about the shipped tables, not a property of the field: the `rankCost or numTempPoints > 0` guard
//! never opens on 1.12's data, so nothing a player can hold moves it off `0`. Say `0` because the
//! model carries no temp points, never because the field is fictional — the day we model training
//! points this is a value to push, not a constant to keep. `stepCost`/`rankCost` are always **nil** —
//! for DATA reasons, not code ones (`SkillLine.skillCostsID` is 0 in all 123 rows, and the
//! step-cost gate's flag bits are set on no line a player can hold) — and nil, not `0`, is what
//! the ref's `if (stepCost)` / `elseif (rankCost or …)` branches read, since `0` is truthy in Lua.
@@ -296,6 +303,35 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
/// `SetSelectedSkill(index)` — resolve the 1-based VISIBLE index to a skill id and hold THAT (the
/// module doc's by-id persistence); a header row or an out-of-range index clears the selection.
/// `GetSkillLineInfo`'s **out-of-range tuple** — thirteen values, four of them numeric zeros.
///
/// Byte-verified in wow-re's `system/tradeskill/scratch/skillframe-selection-and-oob.md` (pushes
/// `0x4d3a2c`…`0x4d3a98`, `mov eax,0xd` at `0x4d3a9f`); decision 1919. One shape serves five
/// distinct conditions — index 0, a negative index, an index past the end, no active player, and an
/// in-range row whose DBC record is missing — because the reference's bounds test (`0x4d3675`) is a
/// single UNSIGNED compare that funnels them all to the same exit.
///
/// The numeric slots are 4 (`skillRank`), 5 (`numTempPoints`), 6 (`skillModifier`), 7
/// (`skillMaxRank`), 11 (`minLevel`) and 12 (`skillCostType`). Slots 4 and 5 must be numbers or the
/// reference's own unguarded `skillRank + numTempPoints` raises.
fn out_of_range_tuple(_lua: &Lua) -> mlua::Result<MultiValue> {
Ok(MultiValue::from_vec(vec![
Value::Nil, // 1 skillName
Value::Nil, // 2 header
Value::Nil, // 3 isExpanded
Value::Integer(0), // 4 skillRank
Value::Integer(0), // 5 numTempPoints
Value::Integer(0), // 6 skillModifier
Value::Integer(0), // 7 skillMaxRank
Value::Nil, // 8 isAbandonable
Value::Nil, // 9 stepCost
Value::Nil, // 10 rankCost
Value::Integer(0), // 11 minLevel
Value::Integer(0), // 12 skillCostType
Value::Nil, // 13 skillDescription
]))
}
fn set_selected(model: &mut Model, index: u32) {
model.skills_selected = entry_at(model, index as usize).map(|e| e.skill_id);
}
@@ -364,18 +400,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetSkillLineInfo(index) → the ref's own tuple (module doc): 13 values on a skill row
// (`0x4d3a20`), 12 on a header (`0x4d3768`). `index` 1-based into the visible tree; out of
// range → a single nil.
// GetSkillLineInfo(index) → the ref's own tuple: 13 values on a skill row (`0x4d3a20`), 12 on
// a header (`0x4d3768`). `index` 1-based.
//
// **OUT OF RANGE IS THE 13-VALUE FALLBACK, NOT A SINGLE NIL.** This comment used to say the
// opposite and it was wrong; the correction is decision 1919, from a wow-re trio cross-check
// (`system/tradeskill/scratch/skillframe-selection-and-oob.md`). `0x4d3675`'s bounds test is
// UNSIGNED against the total row count, so index 0, a negative index, an index past the end, no
// active player, and an in-range row whose DBC record is missing all fall to `0x4d3a2a` and push
// the same thirteen: `nil, nil, nil, 0, 0, 0, 0, nil, nil, nil, 0, 0, nil` (`0x4d3a2c`…
// `0x4d3a98`, then `mov eax,0xd`).
//
// **Slots 4 and 5 being NUMBERS is the whole point.** The reference's own
// `SkillDetailFrame_SetStatusBar` does `skillRank = skillRank + numTempPoints`
// (`SkillFrame.lua:193-194`) with NO guard, straight off `SkillFrame_OnLoad`'s
// `SetSelectedSkill(0)` — so a single nil here raises "attempt to perform arithmetic on a nil
// value" the moment the stock file drives us. Slot 1 being nil is what drives its
// `if (not skillName …) then … return` at l.211. The selection verbs are NOT the divergence:
// `GetSelectedSkill 0x4d4090` always pushes exactly one number and legitimately answers 0.
g.set(
"GetSkillLineInfo",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(n) = index.checked_sub(1) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return out_of_range_tuple(lua);
};
let Some(row) = rows(&model).get(n).copied() else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return out_of_range_tuple(lua);
};
match row {
Row::Header(gi) => {
@@ -386,7 +437,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Integer(1), // isHeader
era_bool(expanded),
Value::Integer(0), // skillRank
Value::Integer(0), // numTempPoints — always 0 (module doc)
Value::Integer(0), // numTempPoints — a real field; 0 because we model no temp points (1919)
Value::Integer(0), // skillModifier
Value::Integer(0), // skillMaxRank
Value::Nil, // isAbandonable
@@ -404,7 +455,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Nil, // isHeader
Value::Nil, // isExpanded
Value::Integer(rank),
Value::Integer(0), // numTempPoints — always 0 (module doc)
Value::Integer(0), // numTempPoints — a real field; 0 because we model no temp points (1919)
Value::Integer(i64::from(e.temp_bonus)), // skillModifier — TEMP only
Value::Integer(max),
era_bool(e.abandonable), // isAbandonable
@@ -826,11 +877,52 @@ mod tests {
);
}
/// **Out of range answers the reference's THIRTEEN-value tuple, not a bare nil** — decision
/// 1919, byte-verified in wow-re's `skillframe-selection-and-oob.md`.
///
/// The COUNT is the whole assertion. `GetSkillLineInfo(0) == nil` reads true either way,
/// because Lua compares only the first returned value — which is precisely how the old
/// one-nil shape passed for as long as it did. What breaks a caller is slots 4 and 5: the
/// reference's own `SkillDetailFrame_SetStatusBar` does `skillRank = skillRank + numTempPoints`
/// with NO guard, straight off `SkillFrame_OnLoad`'s `SetSelectedSkill(0)`, so those two must be
/// NUMBERS or the stock file raises the moment it loads. One shape serves index 0, a negative
/// index, past-the-end, no active player and a DBC miss — the reference funnels all five
/// through one unsigned bounds test (`0x4d3675`).
#[test]
fn out_of_range_answers_the_thirteen_value_tuple() {
let s = UiScript::new().unwrap();
for idx in ["0", "1", "99"] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', GetSkillLineInfo({idx}))"))
.unwrap(),
13,
"GetSkillLineInfo({idx}) must answer 13 values"
);
assert_eq!(
s.eval::<i64>(&format!(
"local _,_,_,r,t = GetSkillLineInfo({idx}) return r + t"
))
.unwrap(),
0,
"GetSkillLineInfo({idx}) slots 4+5 must be NUMBERS — the reference adds them unguarded"
);
assert!(
s.eval::<bool>(&format!("return (GetSkillLineInfo({idx})) == nil"))
.unwrap(),
"slot 1 must stay nil — it drives the stock file's own `if (not skillName)` early-out"
);
}
}
#[test]
fn no_push_reports_zero_rows() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumSkillLines()").unwrap(), 0);
assert!(s.eval::<bool>("return GetSkillLineInfo(1) == nil").unwrap());
// The empty pane answers the out-of-range tuple; the test above holds its ARITY, which is
// the load-bearing half. This line only pins slot 1.
assert!(s
.eval::<bool>("return (GetSkillLineInfo(1)) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetSelectedSkill()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetAdjustedSkillPoints()").unwrap(), 0);
// Collapse/expand/select on an empty pane are harmless no-ops.