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:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
///
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user