the spell owns its state, and a malformed file is refused, not a crash

- the spell owns its lifecycle state: the in-flight cast, the local cancel, the cooldowns and the spell-modifier tables live in one module (2328)
- a malformed MPQ, DBC, M2 or WDT file is refused instead of crashing the client, nine read sites bounded
- a test that needs the install or the addon corpus fails instead of skipping when the data is there; the Bagnon render suite runs again (2329)
- the dev crash injector is armed on every platform, and no instrument writes a path relative to the working directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 12:08:33 +07:00
co-authored by Claude Opus 5.5
parent ac1084783a
commit c2d6f7c858
91 changed files with 2266 additions and 1813 deletions
Generated
-2
View File
@@ -513,7 +513,6 @@ dependencies = [
"benilla-formats",
"benilla-protocol",
"benilla-ui",
"benilla-wmo",
"benilla-world",
"bevy",
"bevy_egui",
@@ -674,7 +673,6 @@ dependencies = [
"bevy",
"bytemuck",
"image",
"libc",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
+3 -3
View File
@@ -57,7 +57,7 @@ sha1 = "0.10.6"
#
# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's
# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua
# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM:
# accepts — no more and no less. The hunks — `third_party/lua-src/BENILLA.md` lists them — all byte-read out of the client's own parser and VM:
# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which
# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat
# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are
@@ -105,11 +105,11 @@ lto = "fat"
codegen-units = 1
# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of
# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes
# hundreds of thousands of lines (243k when this was measured), and release is non-incremental by cargo's default, so a one-line edit re-optimizes
# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild
# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it
# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental`
# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and
# holds the workspace members and zero registry deps), so the ~760 dependencies stay non-incremental and
# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched.
#
# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count
+11 -9
View File
@@ -35,11 +35,11 @@ and no bundled game assets.
- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank,
loot, the auction house and PvP honor.
- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and
character screens through the full HUD, the classic windows (guild, macros and key bindings
included), chat, nameplates, floating combat text and tooltips; third-party addons load from
a `benilla-config/AddOns/` folder beside the executable (partial: AtlasLoot and Bagnon run,
see below).
- **UI:** a from-scratch FrameXML + Lua engine that runs the client's own stock interface off
your install's patch chain, from the login and character screens through the full HUD, the
classic windows (guild, macros and key bindings included), chat, nameplates, floating combat
text and tooltips; third-party addons load from a `benilla-config/AddOns/` folder beside the
executable (partial: AtlasLoot and Bagnon run).
- **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and
cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline.
- **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with
@@ -82,9 +82,11 @@ Early inspiration and file format guidance came from the
benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment.
It ships **no Blizzard content** — no art, models, sounds, maps, MPQ contents or FrameXML; you
provide your own legally obtained 1.12.1 client. The interface code under
`crates/benilla-app/assets/ui/` is ours, written to the client's own layout and API names so that
the windows look right and 1.12.1 addons find the names they expect.
provide your own legally obtained 1.12.1 client. The stock interface runs off your own install's
FrameXML at runtime; the handful of files under `crates/benilla-app/assets/ui/` are our own
adapters and developer frames, not copies of it.
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option.
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components
under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect —
keep their own upstream licenses, alongside each.
-6
View File
@@ -134,12 +134,6 @@ mint = "0.5"
# source. Pinned to 0.8: the version `benilla-srp` already depends on, so this adds no crate.
rand = "0.8"
[dev-dependencies]
# The WMO portal-cull audit's raw-face instruments (`WOW_PIN_COL`): the pin probe compares the
# filtered walking-collision set against the group file's FULL face list with per-face MOPY flags,
# which only the raw parser exposes — benilla-formats' gathers are exactly the filtered views.
benilla-wmo = { path = "../benilla-wmo" }
# Linux/BSD: the Wayland clipboard (decision 0702). We build with bevy's `wayland` feature, so on a
# Wayland session winit gives us a native Wayland window and `arboard` — X11-only without its
# non-default `wayland-data-control` feature — finds no X11 CLIPBOARD owner and every paste fails.
@@ -157,22 +157,6 @@ fn the_render_column_can_fail() {
);
}
/// Where the corpus might be — the `ui_chat::ace_gate_tests` resolver, so a machine without the
/// third-party corpus skips rather than reddens.
fn corpus() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
let p = PathBuf::from(over);
if p.is_dir() {
return Some(p);
}
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
(2usize..=4)
.filter_map(|up| manifest.ancestors().nth(up))
.map(|root| root.join("wow-addons-vanilla"))
.find(|c| c.is_dir())
}
/// **The oracle: two real addons, opposite director-verified outcomes.**
///
/// `!OmniCC` puts cooldown countdown numbers on the director's screen — it works, and it works by
@@ -187,10 +171,8 @@ fn corpus() -> Option<PathBuf> {
/// If this test ever disagrees with the director's eyes, **the test is wrong**.
#[test]
fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
let Some(corpus) = corpus() else {
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
return;
};
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
let corpus = benilla_formats::addon_corpus_or_skip!();
// A root holding just these four, SYMLINKED rather than copied. Surveying the whole corpus
// here would put a minute onto `cargo test --workspace` for four rows — the full sweep is the
// `addon_harness` example's job, not a unit test's.
@@ -248,22 +248,6 @@ fn the_use_column_can_fail() {
);
}
/// Where the corpus might be — the same resolver `render_tests` uses, so a machine without the
/// third-party corpus skips rather than reddens.
fn corpus() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
let p = PathBuf::from(over);
if p.is_dir() {
return Some(p);
}
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
(2usize..=4)
.filter_map(|up| manifest.ancestors().nth(up))
.map(|root| root.join("wow-addons-vanilla"))
.find(|c| c.is_dir())
}
/// **The real oracle: the director's two verified addons.**
///
/// - `!OmniCC` **works** — its countdown numbers are on their screen. It must not be reported as
@@ -278,10 +262,8 @@ fn corpus() -> Option<PathBuf> {
/// silently stops touching them is exactly how this instrument has been wrong four times.
#[test]
fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
let Some(corpus) = corpus() else {
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
return;
};
// The one resolver, and a skip the gate can refuse (`benilla_formats::install`).
let corpus = benilla_formats::addon_corpus_or_skip!();
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap();
@@ -4,7 +4,7 @@
//! started-`Casting` half of the cancel's inflight union), then 2 s into the bar inject a real
//! `W` press into `ButtonInput<KeyCode>` — the ACTUAL controller path, so the probe drives the
//! same move-start edge a player's key does (controller → `LocalMoveStart` →
//! `ui_cast::local_self_cancel` → `CMSG_CANCEL_CAST`).
//! `spell::local_self_cancel` → `CMSG_CANCEL_CAST`).
//!
//! The verdict is read off the run log with `WOW_CAST_TRACE=1`: the `LOCAL self-cancel` line
//! must land beside the `SEND move StartForward` line — frames, not a server round trip, after
+1 -1
View File
@@ -3,7 +3,7 @@
//!
//! Three consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag, and
//! [`crate::spell_mods`] wants field 15's class spell-family — which is exactly why the
//! `crate::spell::mods` wants field 15's class spell-family — which is exactly why the
//! table does not live inside any of them. It used to live in the pet book, back when the pet
//! token was the only column anyone read.
//!
+4 -32
View File
@@ -46,38 +46,10 @@ pub(crate) fn install(build: BuildId) {
}));
}
/// `WOW_CRASH_INJECT=<at_secs>` — one deliberate main-thread panic, mid-run: the standing test
/// affordance for this module, in the shape of `perf::stall`'s injectors and armed beside them.
/// The end-to-end falsifier is a run with it set: the process must die with a
/// `crash-<unix>.txt` whose log tail ends in the `crash-inject` line below.
pub(crate) fn arm_injector(app: &mut bevy::prelude::App) {
use bevy::prelude::*;
if let Some(at) = std::env::var("WOW_CRASH_INJECT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
{
app.insert_resource(CrashInject { at });
app.add_systems(Update, crash_inject);
}
}
#[derive(bevy::prelude::Resource)]
struct CrashInject {
at: f32,
}
fn crash_inject(
inject: bevy::prelude::Res<CrashInject>,
time: bevy::prelude::Res<bevy::prelude::Time<bevy::time::Real>>,
) {
if time.elapsed_secs() >= inject.at {
bevy::log::warn!(
"crash-inject: panicking the main thread at {:.1} s",
inject.at
);
panic!("crash-inject: deliberate panic (WOW_CRASH_INJECT)");
}
}
// The standing test affordance — `WOW_CRASH_INJECT=<at_secs>`, one deliberate main-thread panic
// mid-run — is `perf::crash_inject`, a dev root: this module ships to players and knows nothing
// of the `dev` seam (run_mode's one-door rule, 1176), and an instrument's home is with the
// instruments. Its module doc carries the crosscheck incident that moved it there.
/// Re-entrancy latch: a panic *inside* the hook (a poisoned lock, a failed write) must not
/// recurse into it. Never cleared on purpose — after one report the process is on its way out,
+11 -11
View File
@@ -217,7 +217,7 @@ pub(crate) struct Engaged(pub(crate) u64);
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and
/// `SMSG_CANCEL_AUTO_REPEAT` — which vmangos DOES send (corrected 2026-08-05; see
/// [`crate::net::apply::spells::cancel_auto_repeat`]), so against a live server that packet, not
/// `crate::spell::net`'s `cancel_auto_repeat`), so against a live server that packet, not
/// a local death watcher, is what ends a volley whose target dies (wow-re
/// `nocked-ammo-cancel.md`).
#[derive(Component)]
@@ -310,7 +310,7 @@ pub(crate) fn drive_nock_latch(
/// live callers.
pub(crate) fn cancel_auto_repeat_local(
entity: Option<Entity>,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
auto_repeat: &mut crate::spell::AutoRepeatActive,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
@@ -335,7 +335,7 @@ pub(crate) fn cancel_auto_repeat_local(
/// `Attributes & 0x404` and hands it to `CancelCast 0x6e4940(dl=1, reason 0x1c)`, whose casting arm
/// sends `CMSG_CANCEL_CAST 0x12f` naming it and then pops the slot through `0x6e4ad0`.
///
/// This is the un-queue [`crate::ui_cast::QueuedMeleeSpell`] names as its real clear path, and it
/// This is the un-queue [`crate::spell::QueuedMeleeSpell`] names as its real clear path, and it
/// is why a Raptor Strike ring goes dark the moment Auto Shot starts (the auto-repeat arm inside
/// the cast commit calls straight into here, `0x6e5976`, guarded by nothing but "the caster is the
/// active player").
@@ -351,7 +351,7 @@ pub(crate) fn cancel_auto_repeat_local(
/// not part of this seam.
pub(crate) fn stop_attack_local(
engaged: bool,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
queued_melee: &mut crate::spell::QueuedMeleeSpell,
net: &crate::net::NetCommands,
) {
if !engaged {
@@ -395,7 +395,7 @@ pub(crate) fn start_attack_local(
target: u64,
engaged: bool,
stop_in_flight: bool,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
auto_repeat: &mut crate::spell::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
@@ -447,8 +447,8 @@ pub(crate) fn toggle_attack_local(
entity: Entity,
target: u64,
engaged: bool,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
queued_melee: &mut crate::spell::QueuedMeleeSpell,
auto_repeat: &mut crate::spell::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
@@ -481,8 +481,8 @@ pub(crate) fn toggle_attack_local(
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct AttackSeam<'w, 's> {
pub(crate) net: Res<'w, crate::net::NetCommands>,
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
pub(crate) auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>,
pub(crate) queued_melee: ResMut<'w, crate::spell::QueuedMeleeSpell>,
pub(crate) auto_repeat: ResMut<'w, crate::spell::AutoRepeatActive>,
pub(crate) sheath: MessageWriter<'w, SheathRequest>,
pub(crate) ecs: Commands<'w, 's>,
/// Our own entity — the sheath snap's and the auto-repeat cancel's subject.
@@ -1358,13 +1358,13 @@ mod attack_stand_tests {
// A dead-letter net channel: the send is `let _ = …` and no packet is under test here.
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.insert_resource(crate::ui_action::AutoRepeatActive(None));
app.insert_resource(crate::spell::AutoRepeatActive(None));
let me = app.world_mut().spawn_empty().id();
app.add_systems(
Update,
move |mut commands: Commands,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut auto_repeat: ResMut<crate::spell::AutoRepeatActive>,
mut sheath: MessageWriter<SheathRequest>,
net: Res<crate::net::NetCommands>| {
start_attack_local(
@@ -2272,7 +2272,7 @@ fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() {
"the reconcile alone never leaves the ranged stance — this is the bug's shape"
);
// The packet arm's snap (what `net::apply::combat::attacker_state` now writes beside the
// The packet arm's snap (what `creature_anim::net::attacker_state` now writes beside the
// swing): the sword comes out on the first landed blow.
app.world_mut().write_message(SheathRequest {
entity: unit,
@@ -331,7 +331,7 @@ pub(crate) fn strafe_body_offset(flags: u32) -> f32 {
/// One function rather than one per pose owner: the law has three consumers — our own avatar
/// ([`crate::player::body_pose`]), an observed relay mover
/// ([`crate::net::motion::extrapolate_remote_units`]), and the create-block seed
/// ([`crate::net::apply::objects`]) — and TU-A is explicit that all of them present the *same*
/// ([`crate::net::objects`]) — and TU-A is explicit that all of them present the *same*
/// pitch. Held apart, they drift: the remote lane shipped in July 2026 with no test and no trace
/// of its own, so "does an observed swimmer tilt?" had no answer short of the director's eye.
pub(crate) fn swim_body_rotation(yaw: f32, flags: u32, pitch: f32) -> Quat {
+13 -1
View File
@@ -512,7 +512,19 @@ fn debug_panel_ui(
// room vanishes, click, and the exact seed evidence + per-portal verdicts land
// in a file.
if ui.button("dump WMO cull trace").clicked() {
cull_probe.dump_requested = true;
// Under the one folder (0954/1486), beside the crash reports and
// the stall samples. The world crate is handed the path because
// it has no `local_state` of its own; a hermetic run has no
// folder, and the click says so instead of writing into the cwd.
match crate::local_state::diagnostics_dir() {
Some(dir) => {
cull_probe.dump_to = Some(dir.join("wmo-cull-trace.txt"));
}
None => bevy::log::warn!(
"wmo cull trace: no benilla-config folder to write into \
(hermetic run) — set WOW_CULLDUMP=<path> to name one"
),
}
}
});
@@ -124,7 +124,7 @@ const MAX_SUBDIVISIONS: usize = 256;
/// (`{capacity, count, data, quantum}`; the guid list, in wire order, with the caster's own guid
/// dropped as it fills, `0x6057bf`/`0x6057c9`).
///
/// Two producers write it (`crate::net::apply::spells`: the `SMSG_SPELL_GO` hit list and
/// Two producers write it (`crate::spell::net`: the `SMSG_SPELL_GO` hit list and
/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS`), each **clearing before it fills**; [`spawn_chain_beams`]
/// consumes it **once** and removes it, exactly as `0x60db72` zeroes the count on every exit —
/// including the paths that draw nothing. Targets not streamed to us drop out as they resolve: an
+4 -6
View File
@@ -268,11 +268,6 @@ impl PluginGroup for GamePlugins {
// `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an
// attack target stands and no swing lands.
.add(crate::swing_refusal::SwingRefusalPlugin)
// The talent spell-modifier tables (`SMSG_SET_FLAT_/PCT_SPELL_MODIFIER`): the wire
// fills them, world-enter clears them, and `usable::power_cost` reads op 14 out of
// them. Beside the swing refusal because both are the same shape — a small wire-fed
// store with a world-entry reset — and neither has a feed of its own.
.add(crate::spell_mods::SpellModsPlugin)
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
// server-asked question whose only wire answer is yes — and here for that reason.
@@ -322,7 +317,10 @@ impl PluginGroup for GamePlugins {
// feed, whose shape it shares (intents in, a booth look out).
.add(crate::ui_dressup::DressUpUiPlugin)
.add(UiActionPlugin)
// The spell — the cast lifecycle's packet handlers (decision 2324; 2265 §A7's owner).
// The spell (2265 §A7's owner): the cast lifecycle's state — the pending cast, the queued
// strike, the running channel, the auto-repeat key, the chain outbox — its local cancel,
// the cooldown store, the talent modifier tables and the packet handlers (decisions 2324,
// 2328). After UiActionPlugin, whose feeds and cast ladder read the state through it.
.add(crate::spell::SpellPlugin)
// The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the
// self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and
+15 -3
View File
@@ -28,13 +28,25 @@ use benilla_ui::script::UiScript;
/// itself (decision 1174) — an instrument reads the fact, it does not define it.
use crate::ui_script::{UiCostWanted, UiFrameCost};
/// Where to write, from `$WOW_HOVER_LOG`: unset ⇒ off, `1` ⇒ the default path, anything else ⇒
/// that path.
/// Where to write, from `$WOW_HOVER_LOG`: unset ⇒ off, `1` ⇒
/// `benilla-config/Diagnostics/hover-log.csv` (the one folder, 0954/1486 — never the cwd-relative
/// `target/…` this once named, which is the install folder when the binary is launched from
/// inside it), anything else ⇒ that path. A hermetic run has no folder: `1` is then off, and
/// says so, the sound probe's rule.
fn log_path() -> Option<String> {
match std::env::var("WOW_HOVER_LOG") {
Err(_) => None,
Ok(v) if v.is_empty() || v == "0" => None,
Ok(v) if v == "1" => Some("target/hover-log.csv".to_string()),
Ok(v) if v == "1" => match crate::local_state::diagnostics_dir() {
Some(dir) => Some(dir.join("hover-log.csv").to_string_lossy().into_owned()),
None => {
warn!(
"hover log: no benilla-config folder to write into (hermetic run) — set \
WOW_HOVER_LOG=<path> to name one. Not recording."
);
None
}
},
Ok(v) => Some(v),
}
}
-2
View File
@@ -60,7 +60,6 @@ mod cinematic;
mod combat_log;
mod combat_text;
mod console;
mod cooldowns;
mod crash;
mod creature_anim;
mod cursor;
@@ -124,7 +123,6 @@ mod sound;
/// The two talent spell-modifier tables (`SMSG_SET_FLAT_/PCT_SPELL_MODIFIER`) and the read that
/// puts them on a number.
mod spell;
mod spell_mods;
/// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`).
mod swing_refusal;
mod target;
+1 -1
View File
@@ -263,7 +263,7 @@ mod tests {
std::fs::write(&path, on_disk.to_tsv("Realm")).expect("write the realm cache");
// The live session: the login has already seeded our own name (and a pet's), exactly as
// `net::apply::session::connected` does a moment after the pick goes out.
// `net::session::connected` does a moment after the pick goes out.
let mut names = NameCache::default();
names.insert_player(me, "Nelprifour".into(), None);
names.insert_pet(PET_NUMBER, "Fluffy".into());
+1 -1
View File
@@ -309,7 +309,7 @@ impl NameCache {
/// the first writer of the session — and it is not. The loader fires the first frame the realm
/// identity is known, which is the frame the *pick* is in flight; the login's
/// `SMSG_..._VERIFY_WORLD` seed of our OWN name ([`Self::insert_player`], from
/// `net::apply::session::connected`) lands in the same neighbourhood, and whichever arrives
/// `net::session::connected`) lands in the same neighbourhood, and whichever arrives
/// second wins. When the load won, it discarded the player's own name — so `UnitName("player")`
/// answered nil for one wire round-trip while the feed re-asked for a guid it had just been
/// told about, and every addon reading it in that window saw nil (the KLHThreatMeter
+5 -13
View File
@@ -1872,13 +1872,12 @@ pub(crate) enum ClientCommand {
// several refusals (a zero bid/duration, an unaffordable bid, a cancel whose cut can't be
// paid, a list request while one is in flight) come back as NO packet at all. ──────────
//
// Each variant carries `#[allow(dead_code)]`: decision 1511 P0 is the WIRE, and the
// auction window that will construct these is a later phase. The allow is per-variant
// rather than on the enum so a genuinely dead command elsewhere still surfaces, and each
// one comes off the moment its caller lands.
// Every one of these is constructed by `crate::ui_auction`. 1511 P0 landed the wire
// first, under a per-variant `#[allow(dead_code)]` that was to come off the moment its
// caller landed; the window came and the allows stayed, so rustc could no longer judge
// them. A stale allow is a lie the compiler cannot catch — none here, by design.
/// Greet an auctioneer (`MSG_AUCTION_HELLO`, one guid) — the two-way opcode whose *reply*
/// (an `AuctionHello` event, carrying the `AuctionHouse.dbc` house id) opens the window.
#[allow(dead_code)]
AuctionHello {
auctioneer: u64,
},
@@ -1886,7 +1885,6 @@ pub(crate) enum ClientCommand {
/// reads them, with `benilla_protocol::messages::auction_filter`'s sentinels for the unset
/// ones. **No sort rides the wire** — sorting the page is ours. `list_from` pages by 50.
/// Answered by `SMSG_AUCTION_LIST_RESULT` (an `AuctionListResult` event).
#[allow(dead_code)]
AuctionListItems {
auctioneer: u64,
list_from: u32,
@@ -1900,7 +1898,6 @@ pub(crate) enum ClientCommand {
usable: u8,
},
/// Ask the Auctions tab page (`CMSG_AUCTION_LIST_OWNER_ITEMS`) — our own listings.
#[allow(dead_code)]
AuctionListOwnerItems {
auctioneer: u64,
list_from: u32,
@@ -1908,7 +1905,6 @@ pub(crate) enum ClientCommand {
/// Ask the Bid tab page (`CMSG_AUCTION_LIST_BIDDER_ITEMS`). `auction_ids` is a **refresh
/// set**, not a filter: those rows are emitted first, then every auction we currently hold the
/// bid on. Empty for a plain page.
#[allow(dead_code)]
AuctionListBidderItems {
auctioneer: u64,
list_from: u32,
@@ -1916,7 +1912,6 @@ pub(crate) enum ClientCommand {
},
/// List an item for auction (`CMSG_AUCTION_SELL_ITEM`) — the Create Auction pane.
/// `etime_minutes` must be 120, 480 or 1440; the deposit leaves the purse immediately.
#[allow(dead_code)]
AuctionSellItem {
auctioneer: u64,
item_guid: u64,
@@ -1926,7 +1921,6 @@ pub(crate) enum ClientCommand {
},
/// Bid on — or buy out — an auction (`CMSG_AUCTION_PLACE_BID`). One verb for both: a `price`
/// at or above a nonzero buyout *is* the buyout, inferred server-side.
#[allow(dead_code)]
AuctionPlaceBid {
auctioneer: u64,
auction_id: u32,
@@ -1934,7 +1928,6 @@ pub(crate) enum ClientCommand {
},
/// Cancel one of our own auctions (`CMSG_AUCTION_REMOVE_ITEM`). The deposit is forfeit, and a
/// cancel on an auction that already has a bid costs the 5% cut out of pocket.
#[allow(dead_code)]
AuctionRemoveItem {
auctioneer: u64,
auction_id: u32,
@@ -2193,7 +2186,7 @@ pub(crate) enum ClientCommand {
///
/// **Sent from exactly two edges, both the reference's** (decision 1640, wow-re
/// `ui/scratch/party-oor-stats-and-portrait-law.md` §2): the moment a member's object leaves
/// the object manager (`net::apply::group::member_deactivated`), and the GROUP_LIST seat of a
/// the object manager (`ui_party::net::member_deactivated`), and the GROUP_LIST seat of a
/// member new to the roster whose object we do not hold (`seat_new_records`). There is no
/// timer and no Lua binding — a periodic poll would be ours, not the client's.
RequestPartyMemberStats {
@@ -2418,7 +2411,6 @@ pub(crate) enum ClientCommand {
// be treated as applied at the send.
/// Ask a petitioner NPC for its charter list (`CMSG_PETITION_SHOWLIST`). The gossip row pushes
/// the same answer unasked, so this is the direct re-open, not the only way in.
#[allow(dead_code)]
PetitionShowList {
npc: u64,
},
+12 -1
View File
@@ -305,7 +305,18 @@ mod tests {
})
.map(|(_, l)| l)
.collect();
if !body.join("\n").contains(&format!("SessionEvent::{kind}")) {
// The whole variant name, not a prefix of one: `SessionEvent::Chat` is the head
// of `ChatPlayerNotFound` too, so a bare `contains` let a handler registered for
// `Chat` pass by matching a cousin (six kinds are prefixes of others).
let names_kind = {
let body = body.join("\n");
let needle = format!("SessionEvent::{kind}");
body.match_indices(&needle).any(|(at, hit)| {
!body[at + hit.len()..]
.starts_with(|c: char| c.is_alphanumeric() || c == '_')
})
};
if !names_kind {
problems.push(format!(
"{}: `{handler}` is registered for {kind} and never names it",
path.display()
+6 -4
View File
@@ -542,7 +542,7 @@ fn object_create(
// Where and how big, the moment it streams in — the readout that answers "is this prop in
// the wrong place, or the wrong size, or just drawn wrong" without a guess (decision 0637:
// the duel flag read as huge and mislocated, and nothing in the client could say which).
// `RUST_LOG=benilla_app::net::apply::objects=debug`.
// `RUST_LOG=benilla_app::net::objects=debug`.
debug!(
"gameobject spawn: entry {:?} display {display_id:?} type {go_type:?} \
pos [{:.2}, {:.2}, {:.2}] scale {scale}",
@@ -561,9 +561,11 @@ fn object_create(
// Re-create of a tracked guid: refresh identity + pose. A create is a fresh server snapshot, so
// any in-flight extrapolation is stale too — clear it.
commands.entity(e).insert(net).remove::<RemoteMotion>();
// Speeds go through the drain's stage, never straight onto the entity — a
// `SMSG_FORCE_*_SPEED_CHANGE` riding the same tick as this create has to be able to land
// on top of it (decision 1478, B213).
// Speeds land straight on the entity (decision 2327 — the drain's staging maps are gone):
// a fresh create inserts them as a component at spawn, a re-create replaces them whole
// (below). A `SMSG_FORCE_*_SPEED_CHANGE` riding the same tick as this create still lands
// on top of it, because each handler's commands are applied before the next packet's
// runs — 1478's law unchanged (B213, `apply::seam_tests`).
if let Some(s) = speeds {
// A create is the server's newest snapshot of the mover: it replaces the set whole
// (decision 1478), and lands before the next packet's handler reads it.
+2 -2
View File
@@ -90,7 +90,7 @@ pub(crate) struct Bridge<'w, 's> {
status: ResMut<'w, NetStatus>,
names: ResMut<'w, NameCache>,
items: ResMut<'w, Items>,
cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>,
cooldowns: ResMut<'w, crate::spell::Cooldowns>,
reputations: ResMut<'w, Reputations>,
server_time: ResMut<'w, ServerTime>,
wall_clock: ResMut<'w, ServerWallClock>,
@@ -487,7 +487,7 @@ fn disconnected(
status: &mut NetStatus,
names: &mut NameCache,
items: &mut Items,
cooldowns: &mut crate::cooldowns::Cooldowns,
cooldowns: &mut crate::spell::Cooldowns,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
) {
@@ -0,0 +1,40 @@
//! `WOW_CRASH_INJECT=<at_secs>` — one deliberate main-thread panic, mid-run: the crash reporter's
//! standing test affordance (`crate::crash`, decision 2266 §B2), in the shape of the stall
//! sampler's own injectors. The end-to-end falsifier is a run with it set: the process must die
//! with a `crash-<unix>.txt` whose log tail ends in the `crash-inject` line below.
//!
//! **Why it lives here and not in `crash.rs`, and not in `stall.rs`.** An instrument's home is a
//! dev root (`run_mode::the_dev_plane_has_exactly_one_door`, 1176): the reporter ships to
//! players and knows nothing of the `dev` seam, so its injector cannot sit beside it under a
//! `cfg`. And it was first armed from inside the stall sampler, which is macOS-only
//! (`/usr/bin/sample`) — so on Linux and Windows the injector was dead code, `-D warnings` made
//! that a red `scripts/crosscheck.sh` on both, and the macOS gates stayed green for six days. An
//! instrument's cfg is `dev`, never a platform's; `PerfPlugin` arms this on every platform.
use bevy::prelude::*;
/// Arm the injector when the switch is set; register nothing otherwise.
pub(super) fn arm(app: &mut App) {
if let Some(at) = std::env::var("WOW_CRASH_INJECT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
{
app.insert_resource(CrashInject { at });
app.add_systems(Update, crash_inject);
}
}
#[derive(Resource)]
struct CrashInject {
at: f32,
}
fn crash_inject(inject: Res<CrashInject>, time: Res<Time<bevy::time::Real>>) {
if time.elapsed_secs() >= inject.at {
warn!(
"crash-inject: panicking the main thread at {:.1} s",
inject.at
);
panic!("crash-inject: deliberate panic (WOW_CRASH_INJECT)");
}
}
+6
View File
@@ -56,6 +56,8 @@ mod blend_check;
mod census;
mod clock;
#[cfg(feature = "dev")]
mod crash_inject;
#[cfg(feature = "dev")]
mod gpu;
#[cfg(feature = "dev")]
mod hud;
@@ -196,6 +198,10 @@ impl Plugin for PerfPlugin {
app.insert_resource(c);
app.add_systems(Last, census::res_census::res_census);
}
// `WOW_CRASH_INJECT=<at>` — the crash reporter's standing injector (its module doc says
// why it lives here): armed on every platform, and ahead of the stall sampler's own
// off-switch for the same reason the sampler arms its injectors first.
crash_inject::arm(app);
#[cfg(target_os = "macos")]
stall::plugin(app);
// `WOW_FRAME_PHASES=<ms>` — which PHASE of a slow frame spent it (see the module doc).
+3 -4
View File
@@ -135,10 +135,9 @@ fn arm_injectors(app: &mut App) {
app.insert_resource(TeardownWedge(ms));
}
}
// The third injector lives with the instrument it exercises — `crash::arm_injector`, a
// deliberate main-thread panic — and is armed from here so all three standing injectors
// are named in one place.
crate::crash::arm_injector(app);
// The third standing injector — `crash::arm_injector`, a deliberate main-thread panic — is
// armed by `PerfPlugin` itself, beside this plugin's registration, not from here: this
// module is macOS-only and the crash injector is not (its doc has the crosscheck incident).
}
fn watchdog(dir: Option<std::path::PathBuf>) {
+2 -2
View File
@@ -60,7 +60,7 @@ pub(super) fn control(
MessageWriter<crate::creature_anim::HardLanding>,
// The cast bar's local self-cancel trigger (decision 0256 open item 2): the controller
// reports the move edges the real client's movement machine hands `AbortCast 0x6e4940`.
ResMut<crate::ui_cast::LocalMoveStart>,
ResMut<crate::spell::LocalMoveStart>,
// The mounted space-bar flourish (decision 0441 P2): our own MountSpecial(94) plays
// locally at send time; the net drain self-suppresses any broadcast echo.
MessageWriter<crate::creature_anim::MountFlourish>,
@@ -1126,7 +1126,7 @@ pub(super) fn control(
&dynamics,
);
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
// The cast bar's local self-cancel trigger (`spell::local_self_cancel`): a fresh
// *directional* start (the same wire-axis edge the stream below turns into a
// MSG_MOVE_START_*; diffed against the pre-stream `player.move_flags`) or a jump launch.
// Turn-in-place and pitch deliberately absent — VERIFIED (wow-re `move-selfcancel.md`,
+1 -1
View File
@@ -1986,7 +1986,7 @@ fn sync_portraits(
// A party member's slot bakes only while the member is streamed (in range). Out of
// range there is no model to pose — and no descriptor either, so the stand-in's
// race/sex come from the name cache's `SMSG_NAME_QUERY_RESPONSE` triple, warmed for
// every roster entry by `net::apply::group::list`.
// every roster entry by `ui_party::net::list`.
tok => {
let member = tok
.strip_prefix("party")
+1 -1
View File
@@ -227,7 +227,7 @@ fn death_vocals(
///
/// **That gate is now in.** 1401 recorded the opcode as one "benilla does not implement at all",
/// which was wrong when it was written — benilla has parsed, decoded and played
/// `SMSG_PLAY_OBJECT_SOUND` since well before it (`net::apply::world::play_object_sound` →
/// `SMSG_PLAY_OBJECT_SOUND` since well before it (`sound::net::play_object_sound` →
/// `ServerSoundKind::ObjectSound` → `sound::zone::server_sounds`, which even resolves the source
/// entity to position the kit at it). What was missing was the **per-GUID registry** the gates
/// consult (`0x4591f0`, tested by `0x623a40`'s gate (i) and by `0x623490`'s gate 3 for classes 0,
@@ -404,7 +404,7 @@ impl Cooldowns {
/// **The list belongs to the world session, not to the process** (decision 2116) — the
/// session-end clear every other net-backed store already had
/// ([`crate::net::apply::session::disconnected`]).
/// ([`crate::net::session::disconnected`]).
///
/// `SMSG_INITIAL_SPELLS` carries the WHOLE set of cooldowns still running, at every world
/// entry, and [`Self::seed_initial`] appends (the reference's `AddCooldown 0x6e12c0` never
File diff suppressed because it is too large Load Diff
+54 -5
View File
@@ -1,18 +1,67 @@
//! **The spell** — the reference's `Spell_C` owns the cast lifecycle, the cooldowns, the
//! channels, the spell table and its 22 packet registrations in one translation unit; benilla
//! had no such owner (2265 §A7). This module is where that owner lives. Since decision 2324 it
//! holds the packet handlers ([`net`]); the lifecycle state they fold into is still spread over
//! `ui_action`, `ui_cast` and `cooldowns`, and moving it here is A7's remaining half.
//! channels, the spell-modifier tables and its 22 packet registrations in one translation unit;
//! benilla had no such owner (2265 §A7). This module is that owner: the in-flight slot and the
//! local cancel ([`inflight`]), the cooldown store ([`cooldowns`]), the talent modifier tables
//! ([`mods`]) and the packet handlers ([`net`]) — decisions 2324 and 2328.
//!
//! What it delegates, as the reference delegates it: the wire to `net`, the units to the object
//! layer, the spell table to `ui_action::Spells` (the DBC catalog every face reads), the error
//! text and the VM to the `ui_*` feeds, the visuals and animation to `creature_anim`. The one
//! cast path — the `TryCast` ladder — is still `ui_action`'s; it writes this module's state
//! through `crate::spell` like every other caller (2328 §Open).
use bevy::prelude::*;
use benilla_world::schedule::WorldStage;
use crate::char_select::ClientState;
use crate::ui_unit::UnitFeed;
pub(crate) mod cooldowns;
mod inflight;
mod mods;
pub(crate) mod net;
/// Registration only — the state is still the feeds' (see the module doc).
pub(crate) use cooldowns::Cooldowns;
pub(crate) use inflight::{
inflight, ActiveChannel, AutoRepeatActive, ChainCasts, LocalMoveStart, PendingCast,
QueuedMeleeSpell, SPELL_INTERRUPT_MOVEMENT,
};
pub(crate) use mods::{SpellModifiers, OP_COST};
/// The local cancel's slot in the frame: a feed that drains the edges it pushes (the cast bar)
/// orders itself `.after(LocalCancel)` so a move-edge's `Stop` reaches the VM the same frame it
/// happened — the `chain()` `ui_cast` held while both were its (2328).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct LocalCancel;
pub(crate) struct SpellPlugin;
impl Plugin for SpellPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<ActiveChannel>()
.init_resource::<LocalMoveStart>()
.init_resource::<AutoRepeatActive>()
.init_resource::<ChainCasts>()
.init_resource::<Cooldowns>()
.init_resource::<SpellModifiers>()
.add_systems(
Update,
(
inflight::local_self_cancel
.in_set(UnitFeed)
.in_set(LocalCancel),
// After the net stage that merges the avatar's descriptor, and before the
// feeds that read a cost through it — so the family is this frame's, never
// last frame's, on the frame the avatar first resolves.
mods::track_class_family
.after(WorldStage::Net)
.before(UnitFeed),
),
)
.add_systems(OnEnter(ClientState::InWorld), mods::clear_on_world_enter);
net::register(app);
}
}
@@ -101,7 +101,6 @@ use bevy::prelude::*;
use benilla_formats::SpellDisplay;
use crate::char_select::ClientState;
use crate::chr_classes::ChrClassTable;
use crate::net::{ObjectStore, SelfPlayer};
@@ -171,7 +170,7 @@ impl SpellMod {
/// The two tables and the class family the gate compares against — benilla's `0xcead60` /
/// `0xcecb30` / `0xcecaac`.
///
/// Filled only by the wire (`net::apply::spells::set_spell_modifier`), cleared only at world-enter
/// Filled only by the wire (`spell::net::set_spell_modifier`), cleared only at world-enter
/// ([`clear_on_world_enter`]), and read live at every call site: like the reference, there is no
/// derived cache to invalidate.
#[derive(Resource)]
@@ -286,7 +285,7 @@ impl SpellModifiers {
/// There is deliberately nothing else: no talent-change clear (the server re-sends absolute cells),
/// and no world-LEAVE clear (the reference's own teardown `0x6e99e0` never touches them, and the
/// next entry is what zeroes them).
fn clear_on_world_enter(mut mods: ResMut<SpellModifiers>) {
pub(super) fn clear_on_world_enter(mut mods: ResMut<SpellModifiers>) {
mods.clear();
}
@@ -299,7 +298,7 @@ fn clear_on_world_enter(mut mods: ResMut<SpellModifiers>) {
/// the coupling it replaces. And it **never writes 0 back** — an absent avatar leaves the last
/// value standing, because a cross-map worldport drops the entity mid-session while everything the
/// server told us stays true (decision 0900's shape). Only the world-enter clear zeroes it.
fn track_class_family(
pub(super) fn track_class_family(
mut mods: ResMut<SpellModifiers>,
classes: Option<Res<ChrClassTable>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
@@ -316,27 +315,10 @@ fn track_class_family(
}
}
pub(crate) struct SpellModsPlugin;
impl Plugin for SpellModsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SpellModifiers>()
.add_systems(
Update,
// After the net stage that merges the avatar's descriptor, and before the feeds
// that read a cost through it — so the family is this frame's, never last
// frame's, on the frame the avatar first resolves.
track_class_family
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::ui_unit::UnitFeed),
)
.add_systems(OnEnter(ClientState::InWorld), clear_on_world_enter);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::char_select::ClientState;
/// A mage spell (family 3) with the given bits set, against a mage player.
fn mage(bits: &[u8]) -> SpellDisplay {
@@ -493,12 +475,13 @@ mod tests {
/// is.
#[test]
fn world_enter_clears_both_tables_and_the_family() {
// A bare app: the plugin's only Update system takes both of its inputs as an `Option`/an
// empty query, so the state machine the clear hangs off is all this needs.
// A bare app with the world-enter clear alone: the state machine it hangs off is
// all this needs.
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::Login)
.add_plugins(SpellModsPlugin);
.init_resource::<SpellModifiers>()
.add_systems(OnEnter(ClientState::InWorld), clear_on_world_enter);
{
let mut mods = app.world_mut().resource_mut::<SpellModifiers>();
+33 -36
View File
@@ -1,9 +1,9 @@
//! The spell's packet handlers (in the net handler table since 2324, moved out of the drain's
//! spells arm file) — the spell book and the action bar, the cast lifecycle (the decision
//! 0099/0107 precast → resolve pipeline), the cooldowns, the channels, the aura durations and the
//! spell modifiers. The lifecycle *state* these fold into is still spread over `ui_action`,
//! `ui_cast` and `cooldowns` (2265 §A7's remaining half); the handlers reach it through
//! [`Lifecycle`] and [`Scene`].
//! spell modifiers. The lifecycle *state* these fold into is this module's since decision
//! 2328 ([`super::inflight`], [`super::cooldowns`], [`super::mods`]); the handlers reach it,
//! and the windows' stores they also touch, through [`Lifecycle`] and [`Scene`].
use std::time::{Duration, Instant};
@@ -11,11 +11,11 @@ use benilla_formats::LearnAnnouncement;
use benilla_protocol::messages::{ActionButton, SpellCooldown};
use bevy::prelude::*;
use crate::cooldowns::Cooldowns;
use super::{ActiveChannel, AutoRepeatActive, Cooldowns, PendingCast, QueuedMeleeSpell};
use crate::creature_anim::{CastEvent, CastEventKind, Casting, SpellGoTargets};
use crate::ui_action::{AutoRepeatActive, CastErrors, PlayerActions, Spells, UiError, UiErrorKeys};
use crate::ui_action::{CastErrors, PlayerActions, Spells, UiError, UiErrorKeys};
use crate::ui_aura::AuraDurations;
use crate::ui_cast::{ActiveChannel, CastBarEdge, CastBarFeed, PendingCast, QueuedMeleeSpell};
use crate::ui_cast::{CastBarEdge, CastBarFeed};
use crate::ui_spellbook::LearnedInTab;
use benilla_protocol::{SessionEvent, SessionEventKind};
@@ -50,8 +50,9 @@ pub(super) fn register(app: &mut App) {
}
/// The cast lifecycle's state and catalogs, as one parameter — what every handler here folds
/// its packet into. Still owned by `ui_action`, `ui_cast`, `cooldowns`, `ui_aura`,
/// `spell_mods` and the pet bar (2265 §A7).
/// its packet into. The in-flight slot, the cooldowns and the modifiers are the spell's own
/// (2328); the action store, the errors, the spellbook's learned-tab list, the aura durations
/// and the pet bar are their windows', written here because the packet is the spell's.
#[derive(SystemParam)]
pub(crate) struct Lifecycle<'w> {
self_guid: Res<'w, SelfGuid>,
@@ -60,7 +61,7 @@ pub(crate) struct Lifecycle<'w> {
net: Res<'w, NetCommands>,
actions: ResMut<'w, PlayerActions>,
cast_errors: ResMut<'w, CastErrors>,
chain_casts: ResMut<'w, crate::ui_action::ChainCasts>,
chain_casts: ResMut<'w, crate::spell::ChainCasts>,
learned_in_tab: ResMut<'w, LearnedInTab>,
cast_bar: ResMut<'w, CastBarFeed>,
pending: ResMut<'w, PendingCast>,
@@ -69,7 +70,7 @@ pub(crate) struct Lifecycle<'w> {
auto_repeat: ResMut<'w, AutoRepeatActive>,
channel: ResMut<'w, ActiveChannel>,
aura_durations: ResMut<'w, AuraDurations>,
spell_mods: ResMut<'w, crate::spell_mods::SpellModifiers>,
spell_mods: ResMut<'w, crate::spell::SpellModifiers>,
pet_bar: ResMut<'w, crate::ui_pet::PetBar>,
/// The PlayAnimation call-order counter: every animation-bearing message stamps `next()`,
/// in packet order.
@@ -417,9 +418,9 @@ fn on_spell_modifier(In(ev): In<SessionEvent>, mut l: Lifecycle) {
fn addressed_store<'a>(
caster: u64,
self_guid: &SelfGuid,
player: &'a mut crate::cooldowns::Cooldowns,
player: &'a mut crate::spell::Cooldowns,
pet: &'a mut crate::ui_pet::PetBar,
) -> Option<&'a mut crate::cooldowns::Cooldowns> {
) -> Option<&'a mut crate::spell::Cooldowns> {
if self_guid.0 == Some(caster) {
Some(player)
} else if pet.has_bar() && pet.spells.pet_guid == caster {
@@ -611,7 +612,7 @@ fn cast_result(
spells: Option<&Spells>,
net: &crate::net::NetCommands,
// The `modalNextSpell` chain's outbox (`0x6e74aa`) — filled here, sent by the one cast path.
chain: &mut crate::ui_action::ChainCasts,
chain: &mut crate::spell::ChainCasts,
seq: u64,
) {
debug!("net: cast result — spell {spell_id} success={success} reason={reason:?}");
@@ -897,7 +898,7 @@ fn spell_go(
// The GO-deferred melee auto-attack start's write set (`0x6e83c0`, the arm below), plus the
// attack lock it gates on: our server-echoed `Engaged`, the ref's `[player+0xc48]`.
attack_ctx: (
&mut crate::ui_action::AutoRepeatActive,
&mut crate::spell::AutoRepeatActive,
&mut MessageWriter<crate::creature_anim::SheathRequest>,
bool,
),
@@ -1454,14 +1455,14 @@ fn aura_duration(slot: u8, remaining_ms: u32, durations: &mut AuraDurations, now
/// single store: the server sends the absolute value of that `(family bit, op)` pair, never a
/// delta, so there is nothing to accumulate and nothing to invalidate.
///
/// The out-of-range refusal lives on the store ([`crate::spell_mods::SpellModifiers::set`], which
/// The out-of-range refusal lives on the store ([`crate::spell::SpellModifiers::set`], which
/// documents why it is ours and not the reference's).
fn set_spell_modifier(
flat: bool,
mask_bit: u8,
op: u8,
value: i32,
mods: &mut crate::spell_mods::SpellModifiers,
mods: &mut crate::spell::SpellModifiers,
) {
mods.set(flat, mask_bit, op, value);
}
@@ -1785,7 +1786,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
@@ -1864,7 +1865,7 @@ mod tests {
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
),
@@ -2078,7 +2079,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
@@ -2154,7 +2155,7 @@ mod tests {
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
),
@@ -2247,7 +2248,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
@@ -2316,7 +2317,7 @@ mod tests {
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
),
@@ -2382,7 +2383,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
@@ -2462,7 +2463,7 @@ mod tests {
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
false,
),
@@ -2513,7 +2514,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<AutoRepeatActive>();
let self_e = app
@@ -2567,7 +2568,7 @@ mod tests {
&mut auto_repeat,
None,
&net,
&mut crate::ui_action::ChainCasts::default(),
&mut crate::spell::ChainCasts::default(),
1,
);
},
@@ -2795,7 +2796,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
@@ -2872,7 +2873,7 @@ mod tests {
&mut pet_bar,
),
(
&mut crate::ui_action::AutoRepeatActive::default(),
&mut crate::spell::AutoRepeatActive::default(),
&mut sheath,
engaged,
),
@@ -2985,9 +2986,9 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_action::ChainCasts>();
.init_resource::<crate::spell::ChainCasts>();
let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id();
app.world_mut()
.resource_mut::<GuidIndex>()
@@ -3018,7 +3019,7 @@ mod tests {
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut cooldowns: ResMut<Cooldowns>,
mut auto_repeat: ResMut<AutoRepeatActive>,
mut chain: ResMut<crate::ui_action::ChainCasts>| {
mut chain: ResMut<crate::spell::ChainCasts>| {
let net = crate::net::NetCommands(tx.clone());
cast_result(
spell_id,
@@ -3044,11 +3045,7 @@ mod tests {
},
)
.unwrap();
let queued = app
.world()
.resource::<crate::ui_action::ChainCasts>()
.0
.clone();
let queued = app.world().resource::<crate::spell::ChainCasts>().0.clone();
let still_armed = app
.world()
.resource::<PendingCast>()
+1 -1
View File
@@ -521,7 +521,7 @@ pub(super) fn follow_requests(
group: Res<crate::ui_party::GroupState>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
cast: Res<crate::ui_cast::PendingCast>,
cast: Res<crate::spell::PendingCast>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
mut follow: ResMut<crate::player::FollowState>,
) {
+6 -6
View File
@@ -2175,8 +2175,8 @@ mod tests {
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<InspectMode>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_script::CursorPayloadHeld>();
world.init_resource::<crate::ui_action::SpellTargeting>();
world.init_resource::<ClickConfig>();
@@ -2261,8 +2261,8 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<crate::ui_party::GroupState>();
world.init_resource::<crate::net::GuidIndex>();
@@ -2352,8 +2352,8 @@ mod tests {
world.init_resource::<Messages<WorldRightClick>>();
world.init_resource::<PressPick>();
world.init_resource::<Selection>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<crate::creature_anim::GestureQueue>();
+1 -1
View File
@@ -19,7 +19,7 @@
//! (slot 2 is never read), max with candidate-1 winning ties/NaN. **Out of range forces the
//! radius to 0.0** — the decal shrinks to the 1.3888889 default *and* turns red. `r == 0` (no
//! radius rows — a dest spell with no area) also draws at the default. Spell-mod op 6
//! (SPELLMOD_RADIUS) is not folded in: the tables are live ([`crate::spell_mods`]), this consumer
//! (SPELLMOD_RADIUS) is not folded in: the tables are live (`crate::spell::mods`), this consumer
//! is not wired to them (the same residual as the range gate, 0792).
//!
//! **States**: in range → Acceptable at `r`; out of range → Unacceptable at the default size;
+6 -8
View File
@@ -1243,8 +1243,8 @@ mod tests {
let (tx, rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<Selection>();
@@ -1312,9 +1312,9 @@ mod tests {
// engaged switch onto an attackable unit: the stop un-queues the strike, the re-swing
// kills the repeat.
world
.resource_mut::<crate::ui_cast::QueuedMeleeSpell>()
.resource_mut::<crate::spell::QueuedMeleeSpell>()
.arm(78);
world.resource_mut::<crate::ui_action::AutoRepeatActive>().0 = Some(75);
world.resource_mut::<crate::spell::AutoRepeatActive>().0 = Some(75);
let out = go(&mut world, 0xF, true, Some(1), true);
assert!(out.changed && out.swung);
assert_eq!(
@@ -1322,14 +1322,12 @@ mod tests {
["stop", "cancel-cast", "select", "swing", "cancel-repeat"]
);
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
world.resource::<crate::spell::QueuedMeleeSpell>().current(),
None,
"the switch un-queued Heroic Strike"
);
assert_eq!(
world.resource::<crate::ui_action::AutoRepeatActive>().0,
world.resource::<crate::spell::AutoRepeatActive>().0,
None,
"and the re-swing killed Auto Shot"
);
+42 -41
View File
@@ -38,7 +38,8 @@ use bevy::prelude::*;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use super::{cast_target, reagent_totem_refusal, state, AutoRepeatActive, CastErrors, Spells};
use super::{cast_target, reagent_totem_refusal, state, CastErrors, Spells};
use crate::spell::AutoRepeatActive;
/// **What the commit writes** — `SendCast 0x6e54f0`'s one branch on item-present. The sender
/// discriminates on whether the pending-cast block's guid (`0xceac48`, filled at `6e4f8d`–`6e4fa6`
@@ -99,12 +100,12 @@ pub(crate) struct CastLadder<'w, 's> {
pub(crate) items: Res<'w, Items>,
pub(crate) sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
pub(crate) ecs: Commands<'w, 's>,
pub(crate) pending: ResMut<'w, crate::ui_cast::PendingCast>,
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
pub(crate) cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>,
pub(crate) pending: ResMut<'w, crate::spell::PendingCast>,
pub(crate) queued_melee: ResMut<'w, crate::spell::QueuedMeleeSpell>,
pub(crate) cooldowns: ResMut<'w, crate::spell::Cooldowns>,
/// The talent spell-modifier tables — rung 2's cost goes through them
/// ([`super::usable::power_cost`]).
pub(crate) spell_mods: Res<'w, crate::spell_mods::SpellModifiers>,
pub(crate) spell_mods: Res<'w, crate::spell::SpellModifiers>,
pub(crate) cast_errors: ResMut<'w, CastErrors>,
pub(crate) auto_repeat: ResMut<'w, AutoRepeatActive>,
pub(crate) trade_skill_opens: ResMut<'w, crate::ui_tradeskill::TradeSkillOpens>,
@@ -265,7 +266,7 @@ impl CastLadder<'_, '_> {
/// duplicate a send path).
///
/// The `pending` guard is the client's optimistic in-flight refusal (wow-re `wave-cast.md`
/// `TryCast` IsCasting gate; see [`crate::ui_cast::PendingCast`]): a normal cast is dropped at the
/// `TryCast` IsCasting gate; see [`crate::spell::PendingCast`]): a normal cast is dropped at the
/// source while one is already in flight, so mashing a key can no longer fire a duplicate
/// `CMSG_CAST_SPELL` the server bounces back as a spurious cast-bar cancel. Ranged/auto-repeat
/// shots keep their own lifecycle — they never arm the guard and are never blocked by it.
@@ -283,10 +284,10 @@ fn send_spell_cast(
items: &Items,
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>,
ecs: &mut Commands,
pending: &mut crate::ui_cast::PendingCast,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
cooldowns: &mut crate::cooldowns::Cooldowns,
spell_mods: &crate::spell_mods::SpellModifiers,
pending: &mut crate::spell::PendingCast,
queued_melee: &mut crate::spell::QueuedMeleeSpell,
cooldowns: &mut crate::spell::Cooldowns,
spell_mods: &crate::spell::SpellModifiers,
cast_errors: &mut CastErrors,
auto_repeat: &mut AutoRepeatActive,
trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens,
@@ -324,7 +325,7 @@ fn send_spell_cast(
// The cast classes at this seam. A ranged/auto-repeat shot (Auto Shot, wand Shoot, Throw) is
// not a cast-bar cast — it runs the ranged-stance / `AutoRepeatArmed` path, outside the
// in-flight guard. An on-next-swing spell (`Attributes & 0x404` — Heroic Strike, Cleave)
// queues on the server's melee slot: it arms [`crate::ui_cast::QueuedMeleeSpell`], never the
// queues on the server's melee slot: it arms [`crate::spell::QueuedMeleeSpell`], never the
// in-flight guard, so a queued strike cannot block the next cast (the ref's `6e4d97`
// exemption on the inflight rec's 0x404 bits — wow-re `wave-cast.md`).
let on_next_swing = def.is_some_and(|d| d.on_next_swing());
@@ -477,7 +478,7 @@ fn send_spell_cast(
// closed 0379's INTERIM; decision 0948) — after IsCasting, reagents and the range test,
// exactly where the ref calls the validator. ──
//
// Rung 1 — not-ready: ONE getter query ([`crate::cooldowns::Cooldowns::not_ready`] =
// Rung 1 — not-ready: ONE getter query ([`crate::spell::Cooldowns::not_ready`] =
// `GetCooldownInfo != 0`), forked by the commit at `0x60952b`: an item press queries the
// (use-spell, item ENTRY) pair and refuses **0x28** — and per the byte law is never
// power-gated; a spell press queries (spell, 0) and refuses **0x3c**. The GCD lock rides
@@ -751,7 +752,7 @@ fn send_spell_cast(
// **One inflight id, every cast source** (`0xceca88`, written at `0x6e5026` for every commit).
// The item arm's provisional is shorter because `CMSG_USE_ITEM` has legs vmangos answers with
// `SMSG_INVENTORY_CHANGE_FAILURE` and no cast result at all —
// [`crate::ui_cast::PendingCast`]'s own doc, decision 0908.
// [`crate::spell::PendingCast`]'s own doc, decision 0908.
//
// **Every** class is recorded; only `normal_cast` and the item arm *guard* (1601). This used
// to be one thing: the record was armed only for the classes that refuse on it, so a ranged
@@ -862,10 +863,10 @@ mod tests {
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<Items>();
world.init_resource::<crate::ui_cast::PendingCast>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::cooldowns::Cooldowns>();
world.init_resource::<crate::spell_mods::SpellModifiers>();
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::Cooldowns>();
world.init_resource::<crate::spell::SpellModifiers>();
world.init_resource::<CastErrors>();
world.init_resource::<AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
@@ -1007,7 +1008,7 @@ mod tests {
let (mut world, _rx) = combat_world(false);
send_at(&mut world, SERPENT_STING, MOB);
let now = Instant::now();
let pending = world.resource::<crate::ui_cast::PendingCast>();
let pending = world.resource::<crate::spell::PendingCast>();
assert_eq!(
pending.committed(now),
Some(SERPENT_STING),
@@ -1026,7 +1027,7 @@ mod tests {
// An ordinary cast still does both, unchanged.
let (mut world, _rx) = combat_world(false);
send_at(&mut world, RAPTOR_STRIKE, MOB);
let pending = world.resource::<crate::ui_cast::PendingCast>();
let pending = world.resource::<crate::spell::PendingCast>();
assert_eq!(
pending.committed(Instant::now()),
Some(RAPTOR_STRIKE),
@@ -1098,7 +1099,7 @@ mod tests {
fn an_auto_repeat_press_stops_the_attack_and_takes_the_queued_strike_with_it() {
let (mut world, rx) = combat_world(true);
world
.resource_mut::<crate::ui_cast::QueuedMeleeSpell>()
.resource_mut::<crate::spell::QueuedMeleeSpell>()
.arm(RAPTOR_STRIKE);
send_at(&mut world, AUTO_SHOT, MOB);
@@ -1116,9 +1117,7 @@ mod tests {
"and then the shot itself commits"
);
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
world.resource::<crate::spell::QueuedMeleeSpell>().current(),
None,
"the queue is empty — the strike's checked ring goes dark"
);
@@ -1131,7 +1130,7 @@ mod tests {
fn an_auto_repeat_press_with_no_swing_running_stops_nothing() {
let (mut world, rx) = combat_world(false);
world
.resource_mut::<crate::ui_cast::QueuedMeleeSpell>()
.resource_mut::<crate::spell::QueuedMeleeSpell>()
.arm(RAPTOR_STRIKE);
send_at(&mut world, AUTO_SHOT, MOB);
@@ -1142,9 +1141,7 @@ mod tests {
);
assert!(rx.try_recv().is_err(), "no ATTACKSTOP, no CANCEL_CAST");
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
world.resource::<crate::spell::QueuedMeleeSpell>().current(),
Some(RAPTOR_STRIKE)
);
}
@@ -1179,9 +1176,7 @@ mod tests {
"the repeat survives: the reference cancels it via the attack it STARTS, and it started none"
);
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
world.resource::<crate::spell::QueuedMeleeSpell>().current(),
Some(RAPTOR_STRIKE),
"the strike still queues — only the attack-start was skipped"
);
@@ -1263,9 +1258,12 @@ mod tests {
category: 0,
category_cooldown_ms: 0,
};
world
.resource_mut::<crate::cooldowns::Cooldowns>()
.start_item(6948, &use_spell, None, Instant::now());
world.resource_mut::<crate::spell::Cooldowns>().start_item(
6948,
&use_spell,
None,
Instant::now(),
);
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
assert!(rx.try_recv().is_err(), "an item on cooldown never sends");
@@ -1279,7 +1277,7 @@ mod tests {
// record does NOT match it, so the press passes the rung and commits. (One store, two
// KEYS — no longer "one store keyed by spell id for both".)
world.resource_mut::<CastErrors>().0.clear();
world.insert_resource(crate::ui_cast::PendingCast::default());
world.insert_resource(crate::spell::PendingCast::default());
send(&mut world, HEARTHSTONE, CastCommit::Spell);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { .. })),
@@ -1308,7 +1306,7 @@ mod tests {
// `init_resource` would keep the armed guard — the point here is the commit, not the
// in-flight rung the test above owns.
world.insert_resource(crate::ui_cast::PendingCast::default());
world.insert_resource(crate::spell::PendingCast::default());
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
assert!(matches!(
rx.try_recv(),
@@ -1320,7 +1318,7 @@ mod tests {
})
));
world.insert_resource(crate::ui_cast::PendingCast::default());
world.insert_resource(crate::spell::PendingCast::default());
send(
&mut world,
HEARTHSTONE,
@@ -1427,9 +1425,12 @@ mod tests {
category: 0,
category_cooldown_ms: 0,
};
world
.resource_mut::<crate::cooldowns::Cooldowns>()
.start_item(0, &use_spell, None, Instant::now());
world.resource_mut::<crate::spell::Cooldowns>().start_item(
0,
&use_spell,
None,
Instant::now(),
);
// …and a DIFFERENT cast in flight.
send(&mut world, HEARTHSTONE, CastCommit::Spell);
assert!(matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { .. })));
@@ -1457,7 +1458,7 @@ mod tests {
const GO: u64 = 0xF110_000C_1F00_A3B2;
let (mut world, rx) = world();
let commit = |world: &mut World, commit: CastCommit, bound: TargetedBind| {
world.insert_resource(crate::ui_cast::PendingCast::default());
world.insert_resource(crate::spell::PendingCast::default());
world
.resource_mut::<super::super::targeting::SpellTargeting>()
// The lock word — the one that answers both the bag and the world seam.
@@ -1482,7 +1483,7 @@ mod tests {
);
assert!(
world
.resource::<crate::ui_cast::PendingCast>()
.resource::<crate::spell::PendingCast>()
.in_flight(Instant::now()),
"and arms the in-flight guard the click owes"
);
+1 -1
View File
@@ -149,7 +149,7 @@ pub(super) fn attack_target_binding(
/// same entry a button press uses, so a chained Auto Shot is range-checked, form-checked and
/// GCD-checked exactly like a pressed one, and refuses with the same red line.
pub(super) fn drain_chain_casts(
mut queue: ResMut<crate::ui_action::ChainCasts>,
mut queue: ResMut<crate::spell::ChainCasts>,
targeting: cast_target::CastTargeting,
mut ladder: CastLadder,
) {
+2 -26
View File
@@ -136,31 +136,10 @@ pub(crate) struct PlayerActions {
pub dirty: bool,
}
/// The live auto-repeat spell — the client's autorepeat key `0xceac30` (wow-re `wave-cast.md`:
/// written at the local cast-send for `AttributesEx2 & 0x20` spells, cleared by
/// `SMSG_CANCEL_AUTO_REPEAT`'s `0x6ea080` and by a matching cast-fail). Distinct from the sticky
/// `creature_anim::AutoRepeatArmed` (the Load/Hold idle gate, never cleared): THIS one is what
/// `IsAutoRepeatAction` and the button flash read, and it goes out when the shooting stops.
#[derive(Resource, Default)]
pub(crate) struct AutoRepeatActive(pub Option<u32>);
/// **The `modalNextSpell` chain's queue** — spells the *client* casts on its own, one per
/// `SMSG_CAST_RESULT` that names a spell with a non-zero `Spell.dbc` column 38
/// ([`benilla_formats::SpellDisplay::modal_next_spell`]). Written by the net drain's
/// `cast_result`, drained through the one cast path by [`drain::drain_chain_casts`].
///
/// It exists because the reference's chain runs *inside* `HandleCastResult 0x6e7330`
/// (`0x6e74aa call 0x6e5a90` → `TryCast`) and ours cannot: the net-apply drain and
/// [`CastLadder`] want the same half-dozen resources, so a direct call is a Bevy param conflict.
/// A one-frame queue is the seam — and it keeps the rule that nothing sends a cast except the
/// ladder.
#[derive(Resource, Default)]
pub(crate) struct ChainCasts(pub(crate) Vec<u32>);
/// **The world right-click's GameObject-opener queue** — the lock chain's resolved action, carried
/// one frame to the one cast path (decision 2199).
///
/// It exists for exactly the reason [`ChainCasts`] does, and no other: the right-click system
/// It exists for exactly the reason [`crate::spell::ChainCasts`] does, and no other: the right-click system
/// ([`crate::target::click::act_on_right_click`]) and [`CastLadder`] want the same half-dozen
/// resources, so the click cannot call the ladder in place — a resource reachable twice from one
/// system is a `B0002` panic on the first live frame. A one-frame queue is the seam, and it keeps
@@ -221,7 +200,7 @@ impl Spells {
/// row's minimum (row 1, the all-zero instant sentinel, resolves 0). The level term keys on
/// the `SpellRec+0x70` column ([`SpellDisplay::base_level`]). Spell-mod op `0xa`
/// (SPELLMOD_CASTING_TIME) is still unread here — the tables themselves are live
/// ([`crate::spell_mods`]), only this consumer is not wired to them, so a talent-shortened
/// (`crate::spell::mods`), only this consumer is not wired to them, so a talent-shortened
/// cast still shows its untalented length.
/// A missing row reads 0 (instant), like a failed catalog load everywhere else.
pub(crate) fn cast_time_ms(
@@ -374,9 +353,6 @@ impl Plugin for UiActionPlugin {
.init_resource::<PetTameFailures>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<AutoRepeatActive>()
.init_resource::<ChainCasts>()
.init_resource::<GoOpenerCasts>()
.init_resource::<cast_target::AutoSelfCast>()
.init_resource::<targeting::SpellTargeting>()
+16 -15
View File
@@ -33,13 +33,14 @@ use benilla_formats::{SpellDisplay, SpellRange};
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use benilla_ui::script::{ActionState, UiScript};
use crate::cooldowns::Cooldowns;
use crate::creature_anim::{Casting, Engaged};
use crate::items::Items;
use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::spell::Cooldowns;
use crate::target::Selection;
use super::{usable, AutoRepeatActive, PlayerActions, Spells};
use super::{usable, PlayerActions, Spells};
use crate::spell::AutoRepeatActive;
/// The feed's memory: what was last pushed, and the edge detectors.
#[derive(Default)]
@@ -328,7 +329,7 @@ pub(super) fn cast_moving_refusal(
// The verified 0x200f: ANY_MOVE (0xf) | FALLING (0x2000), in our identical wire layout.
const MOVING_MASK: u32 = move_flags::ANY_MOVE | move_flags::FALLING;
let Some(d) = spell else { return false };
d.interrupt_flags & crate::ui_cast::SPELL_INTERRUPT_MOVEMENT != 0
d.interrupt_flags & crate::spell::SPELL_INTERRUPT_MOVEMENT != 0
&& move_flags_word & MOVING_MASK != 0
&& !d.auto_repeat()
&& (cast_time_ms != 0
@@ -398,12 +399,12 @@ pub(super) fn feed_action_state(
// (decision 0983) and the talent spell-modifier tables that leg 12's cost reads through,
// both of which ride here for the same ceiling reason.
cast_state: (
Res<crate::ui_cast::PendingCast>,
Res<crate::ui_cast::QueuedMeleeSpell>,
Res<crate::ui_cast::ActiveChannel>,
Res<crate::spell::PendingCast>,
Res<crate::spell::QueuedMeleeSpell>,
Res<crate::spell::ActiveChannel>,
Res<super::SpellTargeting>,
Res<crate::ui_macro::MacroBoundSpells>,
Res<crate::spell_mods::SpellModifiers>,
Res<crate::spell::SpellModifiers>,
),
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
selection: Res<Selection>,
@@ -846,12 +847,12 @@ mod tests {
app.insert_resource(actions)
.insert_resource(bound)
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_cast::ActiveChannel>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::ui_action::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
@@ -951,12 +952,12 @@ mod tests {
radii: Default::default(),
})
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_cast::ActiveChannel>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::ui_action::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
@@ -46,7 +46,7 @@ fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> {
/// only** (slot 2 is never read by the client), the max with candidate 1 winning ties/NaN,
/// clamped to 20.0 (`0x4820f0`'s `[0x804478]` literal — `min`, NaN → 20). `0.0` = no radius
/// rows; the reticle then draws at its literal default size. Spell-mod op 6 (SPELLMOD_RADIUS) is
/// not read here — the tables are live ([`crate::spell_mods`]), this consumer is not wired to
/// not read here — the tables are live (`crate::spell::mods`), this consumer is not wired to
/// them (the 0792 residual, same as the range gate).
pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: u32) -> f32 {
let Some(spells) = spells else { return 0.0 };
@@ -126,7 +126,7 @@ pub(crate) fn drive_targeting_cursor(
&Transform,
)>,
// Read-only here: the ask-once template request is made at object stream-in
// (`net::apply::objects`), never by this hover path, so a cold cache is a one-frame transient
// (`net::objects`), never by this hover path, so a cold cache is a one-frame transient
// and not a permanent grey.
lock_inputs: crate::target::lock::GoLockInputs,
mut cursor: ResMut<WorldCursor>,
@@ -174,13 +174,13 @@ mod tests {
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::ui_cast::PendingCast>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::cooldowns::Cooldowns>();
world.init_resource::<crate::spell_mods::SpellModifiers>();
world.init_resource::<crate::spell::PendingCast>();
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::Cooldowns>();
world.init_resource::<crate::spell::SpellModifiers>();
world.init_resource::<crate::ui_action::CastErrors>();
world.init_resource::<crate::ui_action::UiErrorKeys>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<super::super::SpellTargeting>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
+3 -3
View File
@@ -26,10 +26,10 @@ use benilla_formats::{
SPELL_EFFECT_TRADE_SKILL,
};
use crate::cooldowns::Cooldowns;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, Reputations};
use crate::spell_mods::{SpellModifiers, OP_COST};
use crate::spell::Cooldowns;
use crate::spell::{SpellModifiers, OP_COST};
use crate::target::{can_attack, ring_reaction, Factions};
use super::Spells;
@@ -466,7 +466,7 @@ pub(crate) fn spell_usable(
/// 0948 chose per type (base mana for mana spells — the vmangos basis — max pool otherwise,
/// health included for negative types) — and then the talent cost cut, `SPELLMOD_COST` (op 14),
/// which `0x6e31b0` applies inside itself at `6e32e3` through the integer applier `0x6e6af0`
/// ([`crate::spell_mods`]). One law, every consumer: the usable walk's leg 12, the press-path
/// (`crate::spell::mods`). One law, every consumer: the usable walk's leg 12, the press-path
/// power gate (0948), and the tooltip's cost cell (1074) — mirroring the byte fn's own caller
/// set (`0x609657`/`0x60968d`/`0x4e5201`/`0x6e3fdb`/`0x52e8ad`/`0x507de3`). The reference's
/// per-school unit mods are still not modeled — what remains of 0948's standing gap.
+2 -2
View File
@@ -187,7 +187,7 @@ pub(crate) struct AuctionListSlot {
/// - a list result carrying **zero rows** changes nothing [`feed_auction`] can diff, so "the
/// auction house is empty" and "the query never came back" look identical from the snapshot;
/// - a *successful* `SMSG_AUCTION_COMMAND_RESULT` is consumed straight into a re-query
/// (`crate::net::apply::auction`) and leaves no record — only a *failed* one surfaces, and only
/// (`crate::ui_auction::net`) and leaves no record — only a *failed* one surfaces, and only
/// as an error line;
/// - a browse query the throttle refuses is dropped with **no event at all** (the §5-verified
/// silence this module's header describes), so "refused" and "sent" differ only in what went
@@ -207,7 +207,7 @@ pub(crate) struct AuctionWireLog {
pub(crate) last_command: Option<(u32, u32, u32)>,
}
/// The open auctioneer session, filled by the net bridge ([`crate::net::apply::auction`]) and read
/// The open auctioneer session, filled by the net bridge ([`crate::ui_auction::net`]) and read
/// by [`feed_auction`]. Cleared on a client-side close, on walking away, and on disconnect.
#[derive(Resource, Default)]
pub(crate) struct AuctionOpen {
File diff suppressed because it is too large Load Diff
@@ -32,53 +32,17 @@
//! **Nothing from the corpus is committed**, and the tests skip cleanly on a machine without it
//! (see [`corpus`]) — a checkout with no addon folder must never go red here.
use std::path::{Path, PathBuf};
use std::path::Path;
use benilla_ui::script::UiScript;
use super::event::{ChatEvent, ChatEventKind as K};
use super::frames::{route, ChatWindows};
/// Where the vanilla addon corpus might be, in order. `$BENILLA_ADDON_CORPUS` first so a machine
/// that keeps it elsewhere needs no patch; then the sibling checkout, resolved from this crate's
/// manifest rather than the cwd (a pool worktree's cwd is not stable across tool calls, and
/// `CARGO_MANIFEST_DIR` is).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
// …/<checkout>/crates/benilla-app → up to the checkout, then to its parent(s). A pool slot
// lives one level deeper than the primary checkout, so both hops are tried.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root, or `None`. **`None` is a skip, never a failure**: the corpus is third-party
/// content that is deliberately not in this repo, so a machine without it must still be green.
fn corpus() -> Option<PathBuf> {
corpus_candidates().into_iter().find(|c| c.is_dir())
}
macro_rules! corpus_or_skip {
() => {
match corpus() {
Some(root) => root,
None => {
eprintln!(
"skipping: no vanilla addon corpus — looked in {:?} (set $BENILLA_ADDON_CORPUS)",
corpus_candidates()
);
return;
}
}
};
}
/// The corpus, or a skip — `benilla_formats::addon_corpus_or_skip!`, the one resolver (its doc
/// carries the incident: five copies of a ladder that looked beside the checkout, and thirty
/// tests that skipped at every land once the pool moved).
use benilla_formats::addon_corpus_or_skip as corpus_or_skip;
/// FuBar's `.toc` load order for the four files the gate lives in (`FuBar/FuBar.toc:16-19`).
/// AceEvent is last because it needs all three under it — AceLibrary to register into, Compost for
@@ -27,7 +27,7 @@ use crate::ui_chat::{ChatEventKind, ChatLog};
use crate::ui_party::GroupState;
/// The classification inputs all three watchers share — the same six facts
/// [`crate::net::apply::combat_chat::ChatCtx`] carries, as a `SystemParam` so a watcher's own
/// [`crate::combat_log::chat::ChatCtx`] carries, as a `SystemParam` so a watcher's own
/// parameter list stays about *its* diff.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct WatchCtx<'w> {
@@ -54,7 +54,7 @@ impl WatchCtx<'_> {
)
}
/// [`crate::net::apply::combat_chat::ChatCtx::spell_name`]'s law, shared: the per-spell gates
/// [`crate::combat_log::chat::ChatCtx::spell_name`]'s law, shared: the per-spell gates
/// (`Attributes & 0x180`, an empty localized name) apply to every spell-driven line, and an
/// aura line is one.
fn spell_name(&self, spell_id: u32) -> Option<String> {
-2
View File
@@ -14,8 +14,6 @@ use crate::net::ChatKind;
/// The sendable chat types — `ChatTypeInfo`'s sendable keys, as the wire kind an addon's
/// `SendChatMessage` token maps to. `Whisper`/`Channel` carry their target in the call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)] // RaidLeader/BgLeader have no slash of their own (server-promoted sends);
// Channel is the P6 wiring's target — the enum is the full sendable law.
pub(crate) enum SendType {
Say,
Yell,
+1 -1
View File
@@ -1365,7 +1365,7 @@ fn needs_name(chat_type: u8) -> bool {
/// the net apply pass fills, and it is delivered as the event rather than a return value, because
/// that is how the API answers — `RequestTimePlayed()` itself returns nothing.
///
/// **This does NOT replace the chat breakdown beside it.** `net::apply::chat::played_time` prints
/// **This does NOT replace the chat breakdown beside it.** `ui_chat::net::played_time` prints
/// the TIME_PLAYED_TOTAL/LEVEL lines because we do not ship `ChatFrame_DisplayTimePlayed`, which is
/// what the reference's own `TIME_PLAYED_MSG` handler does. The two are the reference's two
/// consumers of one packet, not a doubling: an addon that registers the event does its own thing
+1 -1
View File
@@ -1674,7 +1674,7 @@ pub(super) fn drain_addon_chat_sends(
/// construction — it never reaches a chat frame, which is the entire point of `LANG_ADDON` — so
/// without a line here a broadcast that went out and one that never happened look identical from
/// our own logs, which is exactly how a silently-discarded wire body survives (method.md's rule
/// that new wire bodies are proved, not assumed). `net::apply::chat` already writes inbound addon
/// that new wire bodies are proved, not assumed). `ui_chat::net` already writes inbound addon
/// traffic to the `addon` trace tag; this writes the outbound half to the same tag, so
/// `WOW_MOVE_TRACE=<path> WOW_MOVE_TRACE_TAGS=addon` on a live run is the whole conversation in
/// one file, in order.
+10 -10
View File
@@ -903,13 +903,13 @@ mod tests {
.init_resource::<crate::ui_action::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::ui_action::targeting::SpellTargeting>()
.init_resource::<Items>()
@@ -1652,13 +1652,13 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_action::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::ui_action::targeting::SpellTargeting>()
.init_resource::<Items>()
+2 -2
View File
@@ -610,7 +610,7 @@ fn resolve_slot(
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
cooldowns: &crate::cooldowns::Cooldowns,
cooldowns: &crate::spell::Cooldowns,
spells: Option<&benilla_formats::SpellCatalog>,
names: &crate::names::NameCache,
// The petition record cache, for a charter slot's tooltip lines — a LAZY fill, so it is taken
@@ -857,7 +857,7 @@ pub(crate) fn feed_containers(
Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
),
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
cooldowns: Res<crate::spell::Cooldowns>,
spells: Option<Res<crate::ui_action::Spells>>,
// The refusals and where they land, as one param (the 16-SystemParam ceiling this signature
// already sits at): the queue, and `show_messages`' chat + sound sinks (1815).
+3 -3
View File
@@ -7,7 +7,7 @@
//! `SMSG_LOOT_CLEAR_MONEY` → the coin row becomes the same kind of gap
//! ([`LootState::clear_money`]); `SMSG_LOOT_RELEASE_RESPONSE` → the window closes
//! ([`LootState::clear`]); the error shape → a red line by GlobalStrings key, raised straight from
//! the bridge ([`crate::net::apply::loot::loot_error`] — it needs no queue on this side);
//! the bridge ([`crate::ui_loot::net::loot_error`] — it needs no queue on this side);
//! `SMSG_ITEM_PUSH_RESULT` → a queued "You receive loot" line ([`LootState::receives`]). A removal
//! that empties the window arms the client-authoritative **auto-close**
//! ([`LootState::auto_release`] — the real engine's close-on-last-slot), released by
@@ -516,7 +516,7 @@ impl Default for LootConfig {
/// vmangos replies `SendLoot(item guid, LOOT_CORPSE)`, i.e. wire type **1**, which a cold latch
/// refuses. 1477 read this arm as pose-only and left it out; the window stopped opening;
/// - `CGPlayer_C::OnLootResponse 0x5eb900`, through its **admission gate** — see
/// [`crate::net::apply::loot::loot_response`]. Not unconditional: a `loot_type == 1` response
/// [`crate::ui_loot::net::loot_response`]. Not unconditional: a `loot_type == 1` response
/// against a cold latch is *refused and bounced*.
///
/// Cleared on release/close (`0x48f2c9`/`0x5ec0d4`). Ours clears **guid-matched** (release
@@ -605,7 +605,7 @@ impl LootLatch {
/// reference's guard `0x60e990`, which every player-initiated movement-START emitter calls first
/// and whose tail (`arg2 == 0`) closes an open loot: forward/back, strafe, keyboard-turn and pitch
/// START, pitch STOP, `SetPitch`, and Jump. Mouse-look `SetFacing` passes `arg2 = 1` and does not.
/// Written by `player::control` beside the cast bar's [`crate::ui_cast::LocalMoveStart`] (a
/// Written by `player::control` beside the cast bar's [`crate::spell::LocalMoveStart`] (a
/// different mask — the cast's `0x10f0` excludes TURN, this one includes it), consumed and cleared
/// by [`drain_loot`] the next frame, which runs `CloseInteraction 0x48f200(cl=1, dl=1, 0)`: the
/// kneel latch clears, `CMSG_LOOT_RELEASE` goes out, the frame closes, and a dead corpse that is
+1 -1
View File
@@ -171,7 +171,7 @@ fn buy_error_key(reason: u8) -> Option<&'static str> {
Some(match reason {
// One arm (`0x5dcdee`) for both: vmangos calls 1 ALREADY_SOLD and 7 SOLD_OUT, and the
// reference tells them apart only by code 1 also zeroing the row
// ([`crate::net::apply::npc::vendor_buy_failed`]).
// ([`crate::ui_merchant::net::vendor_buy_failed`]).
buy_result::ITEM_ALREADY_SOLD | buy_result::ITEM_SOLD_OUT => "ERR_VENDOR_SOLD_OUT", // 0x23
buy_result::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
buy_result::SELLER_DONT_LIKE_YOU => "ERR_VENDOR_HATES_YOU", // 0x22
+2 -2
View File
@@ -592,7 +592,7 @@ pub(super) struct RaidSelf {
///
/// `NameCache::player_traits` is filled by `SMSG_NAME_QUERY_RESPONSE`, and **we never query
/// ourselves**: the login seeds our own name with `traits: None` precisely so `"player"` needs
/// no round trip (`net::apply::session::connected`). So for our own row that lookup is `None`
/// no round trip (`net::session::connected`). So for our own row that lookup is `None`
/// *forever*, which painted the local player's raid row with no class column and the plain
/// font colour instead of their class's — caught in a live `/partytest raid` run, invisible to
/// every unit test because the fixtures seed the traits by hand.
@@ -808,7 +808,7 @@ fn member_unit_state(
Some(store) => crate::ui_unit::snapshot(store, Some(m.name.clone()), 0, classes),
// Out of range: **the roster record** — which is not only the `PARTY_MEMBER_STATS` wire
// any more (decision 1640). It is seeded from the member's own live descriptor at the
// instant their object leaves the manager (`0x5f0880`, `net::apply::group::
// instant their object leaves the manager (`0x5f0880`, `ui_party::net::
// member_deactivated`), seated with the `1/1` placeholder when they join the roster
// unseen (`0x4e82d0`), and patched by the wire afterwards — so this leg never reads the
// `0/0` report B334 is about, and there is always a record to read.
+1 -1
View File
@@ -251,7 +251,7 @@ fn leader_changed(
/// shared-quest availability, so the questgiver sweep re-asks from here (0654).
///
/// **A roster entry is a sighting** (decision 1564): every member guid is warmed into the
/// [`NameCache`] here, the same ask-once discipline `net::apply::objects` applies the moment a unit
/// [`NameCache`] here, the same ask-once discipline `net::objects` applies the moment a unit
/// streams in. The roster wire carries a member's *name*, so this is not asked for the name — it is
/// asked for the `(race, class, gender)` triple that rides the same answer, and which is the ONLY
/// source of those three for a member we never see: their descriptor never arrives. Two surfaces
+1 -1
View File
@@ -41,8 +41,8 @@ use bevy::prelude::*;
use benilla_protocol::messages::PetSpells;
use crate::cooldowns::Cooldowns;
use crate::net::{GuidIndex, ObjectStore};
use crate::spell::Cooldowns;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
+1 -1
View File
@@ -183,7 +183,7 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
.join(" · ")
);
let now = Instant::now();
bar.cooldowns = crate::cooldowns::Cooldowns::default();
bar.cooldowns = crate::spell::Cooldowns::default();
for cd in &spells.cooldowns {
let display = catalog.and_then(|c| c.catalog.get(cd.spell_id));
bar.cooldowns.seed_pet(cd, display, now);
+2 -2
View File
@@ -732,8 +732,8 @@ mod tests {
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::AutoRepeatActive>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::net::FieldChanged>()
.insert_resource(NetCommands(tx))
+1 -1
View File
@@ -11,7 +11,7 @@
//! player** — the client's own membership gate at `0x4d5555`, where the add call sits inside the
//! accept block of the same loop that picks the base value. Whether the pane then *lists* it is
//! the single flag `VISIBLE`, off until the player first meets them — which is why
//! `SMSG_SET_FACTION_VISIBLE` has to be applied (`net::apply::session::reputation_visible`).
//! `SMSG_SET_FACTION_VISIBLE` has to be applied (`net::session::reputation_visible`).
//!
//! **Unlisted factions are pushed anyway, carrying `visible: false`.** They are how the pane's
//! headers learn their names: all five header factions carry `HEADER` (`0x08`) and only one of
@@ -16,53 +16,19 @@
//! The director saw the first (their `Bagnon_Core` loaded at startup, so `currentPlayer` was
//! captured nil). Fix only that and you get the second. So the tests below pin **both**, and the
//! headline is a quad count, not an error count: [`bagnon_draws_a_slot_for_every_bag_slot`] is
//! green only when 16 item-slot quads actually reach the render list.
//! green only when every live slot's quad actually reaches the render list — the backpack's 16
//! and, since the reference's own `ContainerFrame.lua` runs off the chain (1751), the keyring's 12
//! beside them (Bagnon's default bag set is `{-2, 0, 1, 2, 3, 4}`, `Bagnon.lua:25`).
//!
//! Nothing from the corpus is committed, and every test here skips cleanly on a machine without
//! it — the `ui_chat::ace_gate_tests` rule.
use std::path::{Path, PathBuf};
use std::path::Path;
use benilla_formats::addon_corpus_or_skip as corpus_or_skip;
use benilla_ui::script::{AddOnInfo, ContainerSlot, ContainerState, QuadContent, UiScript};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root, or `None` — **a skip, never a failure**: it is third-party content that is
/// deliberately not in this repo.
fn corpus() -> Option<PathBuf> {
corpus_candidates().into_iter().find(|c| c.is_dir())
}
macro_rules! corpus_or_skip {
() => {
match corpus() {
Some(root) => root,
None => {
eprintln!(
"skipping: no vanilla addon corpus — looked in {:?} (set $BENILLA_ADDON_CORPUS)",
corpus_candidates()
);
return;
}
}
};
}
/// The corpus **and** a client install — for the tests whose subject is a global that comes from
/// the reference file this client SOURCES off the player's own patch chain rather than shipping
/// ([`super::reference_ui`], decision 1234).
@@ -177,6 +143,29 @@ fn roster() -> crate::char_select::Roster {
crate::char_select::Roster::with_pending_pick(vec![row], 7)
}
/// The `"player"` snapshot the self descriptor builds when it lands — what `ui_unit::feed_units`
/// pushes (its `"player"` leg) and fires `PLAYER_ENTERING_WORLD` off. It is the roster seat plus
/// the fields only the descriptor can say: the unit EXISTS, at the roster's level, with health
/// and a live object. [`super::seat_from_roster`] deliberately leaves those at nil/0 (decision
/// 2263: the reference's `UnitExists("player")` is nil and `UnitLevel("player")` is 0 before the
/// object exists), so the seat can no longer stand in for the descriptor the way it did from 1230
/// until then — a fixture that never pushes this stays in the pre-object window for good, which
/// is a state the real client is never in when a player opens their bags.
fn descriptor_snapshot(seat: &benilla_ui::script::UnitState) -> benilla_ui::script::UnitState {
benilla_ui::script::UnitState {
exists: true,
has_object: true,
is_connected: true,
guid: 7,
level: 60,
health: 4_000,
max_health: 4_000,
power_type: 1, // a Warrior's rage
max_power: 1_000,
..seat.clone()
}
}
/// A VM shaped like the director's session: our whole interface, their installed addons walked in
/// dependency order the way `addons::load_third_party` walks them, and a real backpack.
fn seat(root: &Path, seat: Seat) -> UiScript {
@@ -191,6 +180,10 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
s.set_realm_name("Harness");
let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
if seat == Seat::BeforeAddons {
// The real entry load seeds the player RECORD beside the seat (`lifecycle.rs`, decisions
// 2261/2263); this one push does both, by `set_unit`'s keep-in-step rule — a `"player"`
// push that carries a name seeds the record — so `UnitName("player")` is "Harness" at
// addon file scope, which is what sends Bagnon down its LIVE path.
s.set_unit("player", Some(player.clone()));
}
let failures = super::load_default_ui(&s);
@@ -239,12 +232,14 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN"] {
s.fire_event(event, Vec::new());
}
// The self descriptor's arrival: `ui_unit::feed_units` pushes the player and fires
// PLAYER_ENTERING_WORLD off it. In the `AfterAddons` arm this is the FIRST time the VM has
// ever had a `"player"` — which is the state this file exists to reproduce.
if seat == Seat::AfterAddons {
s.set_unit("player", Some(player));
}
// The self descriptor's arrival: `ui_unit::feed_units` pushes the snapshot the descriptor
// builds and fires PLAYER_ENTERING_WORLD off it — in BOTH arms, because that is the world
// every real Bagnon open happens in. It matters: the reference's `GetKeyRingSize`
// (`ContainerFrame.lua:773-786`) reads `UnitLevel("player")`, and a fixture left in the
// pre-object window (level 0, the roster seat's answer since 2263) sizes the keyring at 4
// where the level-60 session sizes it at 12. In the `AfterAddons` arm this is the FIRST time
// the VM has ever had a `"player"` — which is the state this file exists to reproduce.
s.set_unit("player", Some(descriptor_snapshot(&player)));
s.fire_event("PLAYER_ENTERING_WORLD", Vec::new());
for _ in 0..10 {
s.tick(0.1);
@@ -381,17 +376,24 @@ fn bagnon_draws_a_slot_for_every_bag_slot() {
.unwrap(),
"the window must be on screen"
);
// 28 = the backpack's 16 + the keyring's 12. Bagnon's default bag set carries the keyring
// (`Bagnon.lua:25`, `{-2, 0, 1, 2, 3, 4}`), and its size is the reference's own
// `GetKeyRingSize` ladder off `UnitLevel("player")` (`ContainerFrame.lua:773-786`: 4, 8 at
// 40, 12 at 50, 16 only above 60) — 12 for the fixture's level-60 Warrior. The four bag slots
// hold nothing, so they add nothing.
assert_eq!(
s.eval::<i64>("return Bagnon.size").unwrap(),
16,
"Bagnon must size itself off the LIVE backpack (GetContainerNumSlots(0) == 16), not off \
Bagnon_Forever's empty offline cache — nil UnitName('player') is what sent it there"
28,
"Bagnon must size itself off the LIVE bags (GetContainerNumSlots(0) == 16 plus \
GetKeyRingSize() == 12 at level 60), not off Bagnon_Forever's empty offline cache — nil \
UnitName('player') is what sent it there"
);
assert_eq!(
bagnon_slot_quads(&mut s),
16,
"sixteen bag slots must actually be DRAWN — the director saw a title and a gold line and \
nothing else, while every column of the addon survey called this addon fine"
28,
"every live slot must actually be DRAWN (16 backpack + 12 keyring) — the director saw a \
title and a gold line and nothing else, while every column of the addon survey called \
this addon fine"
);
assert!(
s.errors().is_empty(),
@@ -859,8 +861,10 @@ fn the_whole_bagnon_window_survives_being_used() {
let mut s = open_bagnon(&root);
// 1 · Every slot, hovered and left again — the OnEnter/OnUpdate/OnLeave loop on both an
// occupied and an empty slot.
for i in 1..=16 {
// occupied and an empty slot, across every bag the window lays out (the keyring's slots
// come first in Bagnon's default order, the backpack's after them).
let size = s.eval::<i64>("return Bagnon.size").expect("Bagnon.size");
for i in 1..=size {
let name = format!("BagnonItem{i}");
if s.eval::<bool>(&format!("return getglobal(\"{name}\") ~= nil"))
.unwrap_or(false)
@@ -886,7 +890,8 @@ fn the_whole_bagnon_window_survives_being_used() {
// 3 · Bagnon's OWN bag buttons (the row it just revealed — `BagnonBags0..4` plus the
// keyring's `BagnonBags-2`, from `Bagnon.xml`'s `$parent<id>` naming): hover, click and
// drag each. `BagnonBag_OnClick` is `PutItemInBackpack`/`PutItemInBag`, `BagnonBag_OnDrag`
// is `PickupBagFromSlot` — three engine verbs, none of which existed here.
// is `PickupBagFromSlot` — three engine verbs that did not exist here when this test was
// written and are `script/cursor/bag_verbs.rs` now.
for id in ["0", "1", "2", "3", "4", "-2"] {
let name = format!("BagnonBags{id}");
if !s
@@ -906,8 +911,9 @@ fn the_whole_bagnon_window_survives_being_used() {
s.mouse_button(x + 12.0, y + 12.0, "LeftButton", false);
}
// 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, guarded in our own
// MoneyFrame.xml and *not* guarded in Bagnon's copy of the same handler.
// 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, which Bagnon's own
// `BagnonFrameMoney_OnClick` (`Frame.lua:537-543`) calls unguarded, exactly as the
// reference's `MoneyFrame.xml` coin buttons do.
for coin in ["Gold", "Silver", "Copper"] {
let name = format!("BagnonMoneyFrame{coin}Button");
if s.eval::<bool>(&format!(
@@ -949,32 +955,16 @@ fn the_whole_bagnon_window_survives_being_used() {
//
// To retire an entry: implement it and delete the line. To add one: you had better be able to
// say why, in the same sentence.
const KNOWN_GAPS: &[(&str, &str)] = &[
// The bag-BUTTON cursor trio (`Bag.lua:199-215` — Bagnon's own bag row, click and drag).
// Three 1.12 engine bindings benilla does not have: `PutItemInBackpack 0x4c8f70`,
// `PutItemInBag 0x4c8f00`, `PickupBagFromSlot 0x4c8fa0` (wow-re `system/ui/ledger.tsv`
// l.1962-1964 has the registrations verified; the DECOMPILATION on disk is partial —
// Ghidra removed unreachable blocks from two of the three — so what each one actually
// DOES is not read).
//
// **Deliberately not guessed.** The open question is whether `PutItemInBag(inv)`
// auto-stores the held item into that bag's CONTENTS or places it onto the equipment slot,
// and those are different opcodes. Writing a cursor transition on the wrong one moves a
// player's items to the wrong place, which is the most expensive kind of wrong here
// (the contract §4), so an RE cross-check is dispatched into wow-5875-re and these land on
// its verdict, not before.
("PutItemInBackpack", "Bagnon_Core\\core\\Bag.lua:202"),
("PutItemInBag", "Bagnon_Core\\core\\Bag.lua:204"),
("PickupBagFromSlot", "Bagnon_Core\\core\\Bag.lua:214"),
// Money on the cursor. `Frame.lua:537-543` — Bagnon's money frame calls this UNGUARDED
// where benilla's own `MoneyFrame.xml:411` guards its copy of the same reference handler.
// It is the head of a whole feature, not a missing line: the reference's
// `CoinPickupFrame.lua`/`.xml` need `GetCursorMoney`, `PickupPlayerMoney`, a MONEY cursor
// payload (wow-re `cursor-dragdrop-payload.md` row 2 — benilla's cursor has Item/Spell/
// Action only) and a keyboard-focused frame with `OnChar`/`OnKeyDown`, which this engine
// deliberately does not have (see `EnableKeyboard` in the same landing's report).
("OpenCoinPickupFrame", "Bagnon_Core\\core\\Frame.lua:543"),
];
//
// **Empty, and that is the list's best state, not a reason to delete it.** Four rows sat here
// when it was written, and each retired the way the rule above says: the bag-BUTTON cursor
// trio (`PutItemInBackpack`, `PutItemInBag`, `PickupBagFromSlot` — `Bag.lua:199-215`) once
// wow-5875-re's verdict landed and `script/cursor/bag_verbs.rs` bound all three; and money on
// the cursor (`OpenCoinPickupFrame`, `Frame.lua:537-543`) when the money kit went stock and
// the reference's own `CoinPickupFrame.xml` joined the manifest with the money cursor under
// it (decision 1962). With nothing on the list, "unexplained" is every error, which is the
// assertion this test was always reaching for.
const KNOWN_GAPS: &[(&str, &str)] = &[];
let raised = s.errors();
let unexplained: Vec<&String> = raised
@@ -1357,13 +1347,20 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
// The first-login scan (PLAYER_LOGIN, inside `seat`) recorded the live backpack: 16 slots,
// the pouch in slot 1, in Bagnon_Forever's own short-link shape.
//
// **`16,1,` — the 1 is the reference's, not a stray.** The recorder's count is
// `GetInventoryItemCount("player", ContainerIDToInventoryID(0))`, and `ContainerIDToInventoryID`
// has no backpack arm (`0x4f94e0`: `id - 1 < 4` → `+20`), so bag 0 asks inventory slot 19 —
// the tabard slot. `GetInventoryItemCount 0x4c8680` pushes **1** for an empty slot
// (`0x4c8797`; wow-re `inventory-item-count-law.md` §3, carved at 5a377f4bb), and 1 for a
// worn tabard's stack count too, so the real client writes this row as `16,1,` either way.
let record = "local r = BagnonForeverData[GetRealmName()][UnitName('player')] \
return tostring(r[0] and r[0].s), tostring(r[0] and r[0][1])";
let (size, item) = s
.eval::<(String, String)>(record)
.expect("the record reads");
assert_eq!(
size, "16,0,",
size, "16,1,",
"the login scan records the backpack's size row"
);
assert_eq!(
@@ -1422,7 +1419,7 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
.eval::<(String, String)>(record)
.expect("the record reads");
assert_eq!(
size, "16,0,",
size, "16,1,",
"the logout boundary must not erase the size row"
);
assert_eq!(item, "4496", "the logout boundary must not erase the slot");
@@ -1441,7 +1438,10 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
/// here with a real mouse click, an equipped bag in slot 2, and Bagnon fully hooked.
#[test]
fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
let root = corpus_or_skip!();
// The install too: "is a native bag window open" is asked of the reference's own `IsBagOpen`
// scan (`ContainerFrame.lua:177`, off the chain) — the windows are RECYCLED (1751), so there
// is no frame to name, and `bag_tests.rs` asks the same way.
let root = corpus_and_install_or_skip!();
let mut s = open_bagnon(&root);
// An equipped bag behind CharacterBag1Slot (bagId 2): the inventory surface (the bag ITEM in
@@ -1489,8 +1489,7 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
"the slot click must reach Bagnon's ToggleBag override and close its window"
);
assert!(
!s.eval::<bool>("return BenillaBagFrame2:IsVisible() or false")
.unwrap(),
!super::test_ui::bag_open(&s, 2),
"the native bag window must NOT open — the click belongs to the addon's override"
);
@@ -1508,8 +1507,7 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
"the second click re-opens Bagnon through the same override"
);
assert!(
!s.eval::<bool>("return BenillaBagFrame2:IsVisible() or false")
.unwrap(),
!super::test_ui::bag_open(&s, 2),
"the native window stays shut on the re-open too"
);
assert!(
@@ -1517,29 +1515,37 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
.unwrap(),
"the slot button stays unlit with an addon holding the bags (the ref scan is native-only)"
);
// Nothing is filtered out any more: the bag-button cursor trio the round-trip reaches
// (`PutItemInBag` on the click) is built (`script/cursor/bag_verbs.rs`), so a raise on it is
// a real one.
let raised = s.errors();
let unexplained: Vec<&String> = raised
.iter()
.filter(|e| !e.contains("PutItemIn") && !e.contains("PickupBagFromSlot"))
.collect();
assert!(
unexplained.is_empty(),
"the click round-trip raised: {unexplained:#?}"
raised.is_empty(),
"the click round-trip raised: {raised:#?}"
);
}
/// **Bug 7 — the director's unlit backpack button.** Bagnon lights `MainMenuBarBackpackButton`
/// from its window's OnShow/OnHide (`Bagnon.lua:33/39` — `SetChecked(1/0)`), and OnShow dispatch
/// is synchronous (wow-re `onshow-onhide-dispatch-order.md`), so whoever writes checked AFTER the
/// toggle wins. Two of our seats overwrote it: the button's old click tail re-derived checked
/// from the NATIVE backpack window (the ref's own scan — which leaves the button unlit on the
/// real client's click path too, a ref wart our stated divergence closes), and the `B` binding
/// ran the BUTTON handler instead of the ref's bare `ToggleBackpack()` body, dragging that tail
/// onto the one path the real client lights. Now: a real click on the button ends lit while
/// Bagnon's window is open and unlit when it closes, and the bare-global path (the binding's
/// body) does the same.
/// **Bug 7 — the director's unlit backpack button — and where it stands since the bag bar went
/// stock.** Bagnon lights `MainMenuBarBackpackButton` from its window's OnShow/OnHide
/// (`Bagnon.lua:33/39` — `SetChecked(1/0)`), and OnShow dispatch is synchronous (wow-re
/// `onshow-onhide-dispatch-order.md`), so whoever writes checked AFTER the toggle wins. On the
/// reference's own click path the stock `BackpackButton_OnClick` tail
/// (`MainMenuBarBagButtons.lua:55-69`) runs last: it re-derives checked by scanning the NATIVE
/// container frames, finds none for bag 0 while an addon holds the bags, and writes 0 — so the
/// real client leaves the button unlit after a click with Bagnon, a wart of its own. Our own
/// `BagFrame.xml` once closed that wart on the director's call (Bug 7: an auto-toggle undo in
/// place of the scan, a stated divergence); decision 1783 sourced the stock file unedited and the
/// divergence went with it, unrecorded — and this suite, skipping at every land from 08-30
/// (2329), never said so.
///
/// What this pins now is the client as it is, which is the reference: the bare
/// `ToggleBackpack()` path (the `B` binding's body) ends lit, because nothing runs after OnShow
/// there; a real click ends **unlit** on open as well as on close, because the stock tail is the
/// last word. Whether to reinstate Bug 7's divergence as an adapter shim is the director's call
/// (1751 §2 reserves shims for engine differences, and this is a look); if they say lit, the
/// second assertion below flips back and the shim carries the why.
#[test]
fn the_backpack_button_lights_while_bagnon_holds_the_bags() {
fn the_backpack_button_lit_state_with_bagnon_holding_the_bags() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
@@ -1568,7 +1574,9 @@ fn the_backpack_button_lights_while_bagnon_holds_the_bags() {
"closed ⇒ unlit (Bagnon's OnHide write is the last word)"
);
// And the second click: open again, lit again — the director's report, closed.
// And the second click: open again — and UNLIT, the stock tail's native scan overwriting
// Bagnon's OnShow write, as the reference does. Bug 7's "lit" is the assertion this replaces;
// it comes back with the shim if the director asks for it (the doc above).
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
@@ -1577,8 +1585,9 @@ fn the_backpack_button_lights_while_bagnon_holds_the_bags() {
"the second click re-opens Bagnon"
);
assert!(
s.eval::<bool>("return MainMenuBarBackpackButton:GetChecked() and true or false")
!s.eval::<bool>("return MainMenuBarBackpackButton:GetChecked() and true or false")
.unwrap(),
"open ⇒ lit (Bagnon's OnShow write is the last word)"
"open by click ⇒ unlit: the stock BackpackButton_OnClick tail's native scan is the last \
word, as on the reference (Bug 7's divergence retired with the stock bag bar, 1783)"
);
}
@@ -1410,7 +1410,7 @@ mod tests {
// …and the pair every `Instant`→`GetTime` conversion runs through moved with it: a
// 10-minute cooldown armed 30 s before the rebuild still derives its real start.
let clock = world.resource::<UiClock>();
let armed = crate::cooldowns::CooldownInfo {
let armed = crate::spell::cooldowns::CooldownInfo {
start: startup + Duration::from_secs(60),
remaining_ms: 570_000,
duration_ms: 600_000,
@@ -37,27 +37,12 @@ use std::path::{Path, PathBuf};
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root **and** a Quiver in it, or `None` — a skip, never a failure. Quiver is a live
/// third-party addon (`github.com/SabineWren/Quiver`) whose shipped file is a generated bundle, so
/// a machine that wants this test builds it into the corpus once.
/// a machine that wants this test builds it into the corpus once. The corpus itself resolves
/// through `benilla_formats::addon_corpus` — the one resolver, whose doc carries the incident.
fn quiver_root() -> Option<PathBuf> {
corpus_candidates()
benilla_formats::addon_corpus_candidates()
.into_iter()
.find(|c| c.join("Quiver").join("Quiver.toc").is_file())
}
@@ -67,10 +52,10 @@ macro_rules! quiver_or_skip {
match quiver_root() {
Some(root) => root,
None => {
eprintln!(
"skipping: no Quiver in the addon corpus — looked in {:?} \
(set $BENILLA_ADDON_CORPUS; the folder needs Quiver.toc + Quiver.bundle.lua)",
corpus_candidates()
benilla_formats::skipped(
"no Quiver in the addon corpus (set $BENILLA_ADDON_CORPUS; the folder needs \
Quiver.toc + Quiver.bundle.lua)",
&benilla_formats::addon_corpus_candidates(),
);
return;
}
@@ -288,7 +288,7 @@ fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() {
///
/// This is the regression the latch design could most easily have caused, so it is pinned rather
/// than argued. The obvious guard for the tail — "is there a player object?" — reads FALSE on a
/// `/logout` by the time the tail runs: `net::apply::session::logged_out` despawns our avatar in
/// `/logout` by the time the tail runs: `net::session::logged_out` despawns our avatar in
/// the same drain that writes `LoggedOutMessage`, `back_on_logout` sets `NextState` off that same
/// message, and `OnExit(InWorld)` does not run until the next frame's `StateTransition`. A
/// predicate would have silenced the event on every logout to fix a quit on a loading screen.
+2 -2
View File
@@ -63,9 +63,9 @@ use bevy::prelude::*;
use benilla_ui::script::{ShapeshiftFormView, UiScript};
use crate::cooldowns::Cooldowns;
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::spell::Cooldowns;
use crate::target::Selection;
use crate::ui_action::{cast_target, usable, CastCommit, CastLadder, PlayerActions, Spells};
use crate::ui_script::UiInput;
@@ -239,7 +239,7 @@ fn feed_shapeshift_bar(
items: Res<Items>,
commands: Res<NetCommands>,
clock: Res<crate::ui_script::UiClock>,
spell_mods: Res<crate::spell_mods::SpellModifiers>,
spell_mods: Res<crate::spell::SpellModifiers>,
mut memory: Local<crate::ui_script::VmMemo<StanceMemory>>,
) {
let Some(mut script) = script else {
+2 -2
View File
@@ -72,7 +72,7 @@ const GENERAL_TAB_ICON: &str = "Interface\\Icons\\Ability_Kick";
/// `LEARNED_SPELL_IN_TAB` (event 510; decision 2252).
///
/// Filled by the two net arms the reference reaches its announce block from
/// (`crate::net::apply::spells`' learn and rank-up), never by the `SMSG_INITIAL_SPELLS` bulk load:
/// (`crate::spell::net`'s learn and rank-up), never by the `SMSG_INITIAL_SPELLS` bulk load:
/// the reference gates all of this on `0x4b25b0`'s live-mutation flag, which the login drain
/// passes clear. Drained by [`feed_spellbook`].
///
@@ -154,7 +154,7 @@ fn feed_spellbook(
items: Res<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
cooldowns: Res<crate::spell::Cooldowns>,
clock: Res<crate::ui_script::UiClock>,
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
mut learned: ResMut<LearnedInTab>,
+1 -1
View File
@@ -418,7 +418,7 @@ mod tests {
/// them dropped before 1584. That is why the director's screenshot could show a 20-point
/// Discipline tree beside "Talent Points: 51": the points are a descriptor field and arrived,
/// the ranks are this set and did not. The arm that empties it has its own test beside it
/// (`net::apply::spells::a_removal_shrinks_the_book_and_dirties_the_feeds`).
/// (`spell::net::tests::a_removal_shrinks_the_book_and_dirties_the_feeds`).
#[test]
fn an_emptied_book_reads_as_a_wiped_tree() {
let t = talent(1, 201, 0, 0, &[14522, 14788, 14789]);
+1 -1
View File
@@ -1,7 +1,7 @@
//! The app-side **taxi feed/drain** (decision 0484 phases 1-2) — the two-way half of the taxi-map
//! seam around [`benilla_ui::script`]'s `taxi` module ([`crate::ui_trainer`]'s feed/drain shape).
//!
//! **Phase 1** (unchanged): the net bridge (`crate::net::apply::npc`) fills [`TaxiState`] from the
//! **Phase 1** (unchanged): the net bridge (`crate::ui_taxi::net`) fills [`TaxiState`] from the
//! wire — `SMSG_SHOWTAXINODES` opens the map (flight master + nearest node + known-node bitmask),
//! `SMSG_ACTIVATETAXIREPLY` stages the activate verdict, the first-visit "learn" pair stages the
//! discovery flag — and the standardized NPC-session range guard client-side-closes the map when
+2 -2
View File
@@ -61,7 +61,7 @@ struct ViewCtx<'a> {
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
/// The talent spell-modifier tables — the cost cell shows the RESOLVED cost, which since
/// `SPELLMOD_COST` landed means the modified one (`crate::ui_action::usable::power_cost`).
spell_mods: &'a crate::spell_mods::SpellModifiers,
spell_mods: &'a crate::spell::SpellModifiers,
/// The VM's own `GlobalStrings.lua` (decision 2045) — every cell this builder composes is a
/// key, and this is where they resolve. `text` is the `%d`-filling twin the `$`-engine's
/// keyed tokens take (`benilla_formats::TokenContext::text`).
@@ -444,7 +444,7 @@ fn feed_spell_tooltips(
// spell-modifier tables the cost cell resolves through.
lookups: (
Option<Res<crate::ui_items::ItemSubClasses>>,
Res<crate::spell_mods::SpellModifiers>,
Res<crate::spell::SpellModifiers>,
),
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
+2 -2
View File
@@ -21,7 +21,7 @@ struct TestCtx {
text: Box<Filler>,
/// Empty tables, which is the un-talented character every cell here is graded as: the cost
/// cell's modifier hop must be the identity when nothing has been sent.
spell_mods: crate::spell_mods::SpellModifiers,
spell_mods: crate::spell::SpellModifiers,
}
/// The two lookup shapes, named so the harness's fields read.
@@ -39,7 +39,7 @@ impl TestCtx {
commands: NetCommands(tx),
_rx: rx,
get: Box::new(move |key| benilla_ui::strings::global(for_get.lua(), key)),
spell_mods: crate::spell_mods::SpellModifiers::default(),
spell_mods: crate::spell::SpellModifiers::default(),
text: Box::new(move |key, args: &[i64]| {
let template = benilla_ui::strings::global(for_text.lua(), key)?;
let args: Vec<_> = args
+2 -2
View File
@@ -251,7 +251,7 @@ fn resolve_recipe(
store: &ObjectStore,
items: &Items,
commands: &NetCommands,
cooldowns: &crate::cooldowns::Cooldowns,
cooldowns: &crate::spell::Cooldowns,
now: Instant,
) -> Option<TradeSkillRecipe> {
let d = spells.catalog.get(spell_id)?;
@@ -377,7 +377,7 @@ fn feed_trade_skill(
self_store: Query<&ObjectStore, With<SelfPlayer>>,
items: Res<Items>,
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
cooldowns: Res<crate::spell::Cooldowns>,
mut last: Local<crate::ui_script::VmMemo<Option<TradeSkillState>>>,
) {
let Some(mut script) = script else {
+1 -1
View File
@@ -715,7 +715,7 @@ fn only_a_packet_that_opens_a_window_resets_the_filter() {
assert!(open.fresh_list, "a first list opens the window: reset");
open.fresh_list = false; // the feed consumes it
// The post-buy re-request (`net::apply::npc::trainer_buy_succeeded`) marks its own answer.
// The post-buy re-request (`ui_trainer::net::trainer_buy_succeeded`) marks its own answer.
open.refresh_pending = true;
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
+1 -1
View File
@@ -97,7 +97,7 @@ fn every_error_key_in_the_source_resolves_to_real_text() {
/// Keys whose row the shipped `GlobalStrings.lua` has **no string for**, so the reference
/// itself shows nothing when it raises them. Each is documented where it is raised —
/// `ui_items::equip_error` (errorId 362), `ui_action::cast_fail` (the pet-happiness arm), and
/// `net::apply::pet` (errorId 337, both pet handlers' NOPATH arm). A fourth entry here is a
/// `ui_pet::net` (errorId 337, both pet handlers' NOPATH arm). A fourth entry here is a
/// defect until someone proves otherwise.
///
/// `ERR_PET_SPELL_NOPATH` is the one worth reading twice, because it is a trap: 5875 *does*
+1 -5
View File
@@ -19,11 +19,7 @@ use crate::coords::wow_to_bevy;
mod anims;
mod pose;
// `PlayableAnim`/`ResolvedAnim` aren't re-exported further by `lib.rs` (nothing outside this crate
// 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, ClipEvent, ModelAnimations, PlayableAnim, ResolvedAnim};
pub use anims::{AnimClip, ClipEvent, ModelAnimations};
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
+33 -5
View File
@@ -285,11 +285,16 @@ impl<'a> DbcParser<'a> {
}
}
// `rc` comes straight from the header; a corrupt `record_size` of 0 would otherwise let it
// pass the size guard unbounded (0 * anything fits). Cap the reservation by what the record
// bytes could actually hold — a short body then fails at the bounds-checked read below, not
// in the allocator.
let mut records = Vec::with_capacity(capped(rc, rs, records_bytes.len()));
// `rc` comes straight from the header, and `record_size == 0` lets any `rc` pass the size
// guard (0 × anything fits) — while `rd_u32_at` zero-extends rather than fails, so nothing
// below would stop a loop over the header's count from pushing every one of those records
// as zeros. A zero-byte record holds no field: a header that claims records at that size
// is refused, and the loop runs over what the record bytes hold, never the header's count.
if rs == 0 && rc > 0 {
return Err(Error::Truncated("records claimed at record_size 0"));
}
let rc = capped(rc, rs, records_bytes.len());
let mut records = Vec::with_capacity(rc);
for r in 0..rc {
let base = r * rs;
let mut values = Vec::with_capacity(fc);
@@ -617,4 +622,27 @@ mod tests {
4,\"line1\nline2\"\n";
assert_eq!(csv, expected);
}
/// `record_size = 0` with a record count passes the size guard (0 × anything fits) and used
/// to run the record loop `record_count` times: `rd_u32_at` zero-extends rather than fails,
/// so a 20-byte file claiming `0xFFFFFFFF` records was 4 G pushes and an OOM kill — never
/// the error the old comment said the bounds-checked read would raise.
#[test]
fn zero_record_size_with_records_is_refused_not_iterated() {
let bytes = build_wdbc(u32::MAX, 2, 0, &[], &[]);
let parser = DbcParser::parse(&mut Cursor::new(bytes.as_slice()))
.expect("0 × anything fits the size guard")
.with_schema(id_name_schema())
.expect("schema matches");
assert!(matches!(parser.parse_records(), Err(Error::Truncated(_))));
// The honest empty file — no records, nothing to hold them — still decodes to nothing.
let bytes = build_wdbc(0, 2, 0, &[], &[]);
let rs = DbcParser::parse(&mut Cursor::new(bytes.as_slice()))
.unwrap()
.with_schema(id_name_schema())
.unwrap()
.parse_records()
.unwrap();
assert!(rs.records().is_empty());
}
}
+150 -3
View File
@@ -127,6 +127,9 @@ fn candidates_from(override_dir: Option<PathBuf>, exe_dir: Option<PathBuf>) -> V
/// (the contract: never commit Blizzard assets), so "no install" has always meant *pass without
/// asserting* rather than *fail*. That is deliberately unchanged; what changes is that the message
/// now names every path that was tried, so a machine where the tests silently do nothing says why.
///
/// **Except where the gate says the data is here** — see [`skipped`]: under
/// `BENILLA_REQUIRE_DATA=1` a skip is a failure.
#[macro_export]
macro_rules! wow_data_or_skip {
() => {
@@ -136,9 +139,104 @@ macro_rules! wow_data_or_skip {
match $crate::wow_data() {
Some(data) => data,
None => {
eprintln!(
"skipping: no WoW install found — looked in {:?}",
$crate::candidates()
$crate::skipped("no WoW install found", &$crate::candidates());
return $ret;
}
}
};
}
/// A data-gated test's skip, said out loud — and **refused when the gate says the data is here.**
///
/// libtest swallows a passing test's stderr, and `scripts/gates.sh` keeps no log of a green run,
/// so a skip is invisible at exactly the place it matters: a resolver that has drifted reads as
/// green. That is how the thirty addon-corpus tests skipped at every land for three weeks after
/// the pool moved drives (2026-08-30 → 09-22) — the ladder looked beside the checkout, the slots
/// had moved, and nothing said so. So the gate sets `BENILLA_REQUIRE_DATA=1` on the machine that
/// has the data, and there a skip is what it actually is: a broken resolver, a missing link, a
/// ladder that looks in the wrong place. A machine without the data keeps the skip — third-party
/// content is not on every machine, and never in this repo.
#[doc(hidden)]
pub fn skipped(what: &str, looked_in: &[PathBuf]) {
skipped_under(
std::env::var_os("BENILLA_REQUIRE_DATA").is_some_and(|v| !v.is_empty() && v != "0"),
what,
looked_in,
);
}
/// [`skipped`] with the switch passed in, so the refusal is testable without touching the
/// process environment (the same rule as [`candidates_from`]).
fn skipped_under(required: bool, what: &str, looked_in: &[PathBuf]) {
let msg = format!("skipping: {what} — looked in {looked_in:?}");
assert!(
!required,
"{msg} — and BENILLA_REQUIRE_DATA is set: the gate resolved this data on this machine, \
so a skip here is a broken resolver or a missing link, not a missing install"
);
eprintln!("{msg}");
}
/// The vanilla addon corpus — the third-party addons the UI engine's real-addon tests run
/// against — or `None` when it is not on this machine. Third-party content, never in this repo;
/// the tests that need it skip through [`addon_corpus_or_skip`] the way the install's do.
///
/// Two rungs, the install's own shape: **`$BENILLA_ADDON_CORPUS`**, then
/// **`<project folder>/wow-addons-vanilla`** — `dev` only, the same `CARGO_MANIFEST_DIR` hop as
/// the install's rung 2. That folder is a gitignored symlink `scripts/wt.sh` lays into every pool
/// slot and the primary (`link_wow`), exactly as it lays `WoW`; the corpus itself lives beside the
/// primary checkout.
///
/// **Why one rung and a link, not a walk up the tree.** Five test files carried their own copy of
/// this resolver, and every copy looked for a *sibling* of the checkout — the manifest's ancestors
/// two to four, joined with the folder name. That found the corpus from the primary and from the
/// old pool root beside it, and nothing once the pool moved to the external drive (2026-08-30):
/// from `/Volumes/SanDisk/benilla-wt/pool-N` the three hops name three folders that do not exist.
/// Every land gates in a slot, so thirty tests — a ratchet among them — skipped at every land for
/// three weeks. A resolver that looks *outside* the checkout answers according to where the
/// checkout happens to sit; the install learned this in 1175, and this is the same rule for the
/// second piece of external data the tests read.
pub fn addon_corpus() -> Option<PathBuf> {
addon_corpus_candidates().into_iter().find(|c| c.is_dir())
}
/// Every place [`addon_corpus`] looks, in order, whether or not it exists — for the skip message.
pub fn addon_corpus_candidates() -> Vec<PathBuf> {
addon_corpus_candidates_from(std::env::var_os("BENILLA_ADDON_CORPUS").map(PathBuf::from))
}
/// [`addon_corpus_candidates`] with the environment fact passed in — testable without touching
/// the process environment, like [`candidates_from`].
fn addon_corpus_candidates_from(override_dir: Option<PathBuf>) -> Vec<PathBuf> {
let mut out = Vec::with_capacity(2);
if let Some(over) = override_dir {
out.push(over);
}
// The project folder — dev builds only, the install's rung 2 verbatim: a player build has no
// business knowing where a test corpus was.
#[cfg(feature = "dev")]
if let Some(root) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) {
out.push(root.join("wow-addons-vanilla"));
}
out
}
/// The addon corpus, or **skip this test** — [`wow_data_or_skip`]'s twin for the second piece of
/// external data the tests read, with the same two spellings (`addon_corpus_or_skip!()` in a
/// `-> ()` test, `addon_corpus_or_skip!(None)` in a helper returning `Option`) and the same
/// `BENILLA_REQUIRE_DATA` refusal.
#[macro_export]
macro_rules! addon_corpus_or_skip {
() => {
$crate::addon_corpus_or_skip!(())
};
($ret:expr) => {
match $crate::addon_corpus() {
Some(root) => root,
None => {
$crate::skipped(
"no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)",
&$crate::addon_corpus_candidates(),
);
return $ret;
}
@@ -249,4 +347,53 @@ mod tests {
root.display()
);
}
/// The corpus ladder is the install's in shape: the override leads, and the project folder —
/// the checkout itself, never a sibling of it — is the only other rung a dev build has.
#[cfg(feature = "dev")]
#[test]
fn the_corpus_ladder_is_override_then_the_project_folder_and_nothing_outside_it() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.unwrap();
let c = addon_corpus_candidates_from(Some(PathBuf::from("/opt/corpus")));
assert_eq!(
c,
vec![
PathBuf::from("/opt/corpus"),
root.join("wow-addons-vanilla")
],
"{c:?}"
);
for c in addon_corpus_candidates_from(None) {
assert!(
c.starts_with(root),
"a corpus candidate outside the checkout ({}) — that is the resolver whose answer \
moved when the pool did",
c.display()
);
}
}
/// A player build's corpus ladder is the override alone — a shipped binary knows no test
/// corpus, exactly as it knows no source tree.
#[cfg(not(feature = "dev"))]
#[test]
fn a_player_build_has_no_corpus_rung_of_its_own() {
assert_eq!(addon_corpus_candidates_from(None), Vec::<PathBuf>::new());
}
/// Unrequired, a skip is a line on stderr and the test goes on.
#[test]
fn a_skip_is_a_skip_where_nothing_requires_the_data() {
skipped_under(false, "no WoW install found", &[PathBuf::from("/nowhere")]);
}
/// Required, the same skip is the failure it actually is.
#[test]
#[should_panic(expected = "BENILLA_REQUIRE_DATA is set")]
fn a_skip_is_refused_where_the_gate_says_the_data_is_present() {
skipped_under(true, "no WoW install found", &[PathBuf::from("/nowhere")]);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub use tga::tga_to_rgba;
/// Where the WoW install is — the one resolver (decision 1175). Paired with [`Chain`]: this says
/// *where*, that opens it.
mod install;
pub use install::{candidates, wow_data};
pub use install::{addon_corpus, addon_corpus_candidates, candidates, skipped, wow_data};
mod characters;
pub use characters::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, forearm_dressed,
+37 -3
View File
@@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::io::Cursor;
use anyhow::Result;
use benilla_bytes::capped;
use benilla_m2::parse_m2;
use super::{le_f32, le_u16, le_u32};
@@ -771,7 +772,11 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
position: [f32; 3],
times: Vec<u32>,
}
let mut model_events: Vec<EventRecord> = Vec::with_capacity(ev_count);
// The counts are raw header u32s and the loops below `break` on a short file — the
// reservations must not trust them either: u32::MAX events at ~48 B was a 200 GiB request,
// and the allocator aborts rather than unwinds.
let mut model_events: Vec<EventRecord> =
Vec::with_capacity(capped(ev_count, 44, b.len().saturating_sub(ev_ofs)));
for e in 0..ev_count {
let erec = ev_ofs + e * 44;
if erec + 44 > b.len() {
@@ -785,7 +790,7 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec<ModelAnimation> {
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);
let mut times = Vec::with_capacity(capped(nts, 4, b.len().saturating_sub(ots)));
for t in 0..nts {
let o = ots + t * 4;
if o + 4 > b.len() {
@@ -934,7 +939,8 @@ fn read_global_channel<T>(
if n <= 1 {
return None; // a lone key is a constant channel, not a loop
}
let mut keys = Vec::with_capacity(n);
// `n` is a raw count; the timestamp array alone bounds how many keys the file can hold.
let mut keys = Vec::with_capacity(capped(n, 4, b.len().saturating_sub(ts_o)));
for k in 0..n {
let (t_off, v_off) = (ts_o + k * 4, val_o + k * stride);
if t_off + 4 > b.len() || v_off + stride > b.len() {
@@ -1004,6 +1010,34 @@ pub fn parse_m2_global_sequence_bones(b: &[u8]) -> Vec<GlobalSeqBone> {
mod tests {
use super::*;
/// The three reservations in this file that took a raw M2 count — the event table (header
/// 0x114), an event's timestamp count, and a global-sequence track's key count. Every loop
/// under them already `break`s on a short file, but the reservation was requested first:
/// u32::MAX events at ~48 B is a 200 GiB request, and the allocator aborts rather than unwinds.
#[test]
fn hostile_event_and_key_counts_reserve_only_what_the_file_holds() {
let mut b = vec![0u8; 0x11c];
b[0..4].copy_from_slice(b"MD20");
// u32::MAX events, of which exactly one is present …
b[0x114..0x118].copy_from_slice(&u32::MAX.to_le_bytes());
b[0x118..0x11c].copy_from_slice(&0x11cu32.to_le_bytes());
// … itself claiming u32::MAX timestamps at offset 0.
let mut ev = [0u8; 44];
ev[36..40].copy_from_slice(&u32::MAX.to_le_bytes());
b.extend_from_slice(&ev);
assert!(
parse_m2_animations(&b).is_empty(),
"no sequences ⇒ no clips, and no abort"
);
// A global-sequence track claiming u32::MAX keys walks the 7 that fit its own 28 bytes.
let mut track = [0u8; 0x1c];
track[0x0c..0x10].copy_from_slice(&u32::MAX.to_le_bytes());
let ch = read_global_channel(&track, 0, 4, &|_: u16| Some(1000u32), le_u32)
.expect("more than one key on a live global sequence is a channel");
assert_eq!(ch.keys.len(), 7);
}
/// The character eye-blink, straight off the real `HumanMale.m2`: exactly one global-sequence bone
/// (75, the eyelid), a **scale** channel on a real global sequence, whose keys hold `0` (lid gone,
/// eye open) at the loop start and pop to `1` (lid full, eye shut) ~33 ms later — the blink. Guards
+23
View File
@@ -264,6 +264,14 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result<Vec<RibbonEmitterDef>> {
};
let count = le_u32(bytes, HDR_COUNT) as usize;
let base = le_u32(bytes, HDR_PTR) as usize;
// The particle twin's refusal (`particles.rs`), for the same reason: `count` is a raw header
// u32 that sizes the reservation below (~250 B per emitter — u32::MAX is a terabyte and an
// allocator abort), and the table must fit in the file. 256 is far past anything authored:
// the corpus scan cited below found 590 ribbons over 176 models, a handful per model. It is
// checked first so `count * STRIDE` cannot overflow.
if count == 0 || count > 256 || base + count * STRIDE > bytes.len() {
return Ok(Vec::new());
}
let rf_count = le_u32(bytes, HDR_RENDER_FLAGS) as usize;
let rf_base = le_u32(bytes, HDR_RENDER_FLAGS + 4) as usize;
// The first sequence's absolute time band — the keyed look tracks rebase onto it
@@ -345,6 +353,21 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result<Vec<RibbonEmitterDef>> {
mod tests {
use super::*;
/// The header's ribbon count (0x134) sized the reservation straight off the file — at ~250 B
/// per `RibbonEmitterDef`, u32::MAX is a terabyte request and an allocator abort, where the
/// particle twin had refused the same shape all along. Refused the same way now.
#[test]
fn a_hostile_ribbon_count_yields_nothing_not_an_abort() {
let mut b = vec![0u8; HDR_PTR + 4];
b[0..4].copy_from_slice(b"MD20");
b[HDR_COUNT..HDR_COUNT + 4].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(parse_m2_ribbon_emitters(&b).unwrap().is_empty());
// And a table the file cannot hold, at a plausible count, is refused too.
b[HDR_COUNT..HDR_COUNT + 4].copy_from_slice(&3u32.to_le_bytes());
b[HDR_PTR..HDR_PTR + 4].copy_from_slice(&((HDR_PTR + 4) as u32).to_le_bytes());
assert!(parse_m2_ribbon_emitters(&b).unwrap().is_empty());
}
/// [`RibbonVisibility::at`]'s sampling law, on a hand-built gate: STEP (nearest-previous, the
/// track's own `interp == 0`), the band-opening entry answers before the first in-band key,
/// and a sequence the model doesn't author falls back to `Stand`(0) and then to the
+1 -1
View File
@@ -1017,7 +1017,7 @@ impl SpellDisplay {
/// A [`SpellDisplay::learn_announcement`] verdict — which of the three `ERR_LEARN_*` chat lines
/// the client prints when the spell is learned. The key each one names, and whether the argText
/// carries the rank, is the caller's to map (`benilla::net::apply::spells`): the ids live in the
/// carries the rank, is the caller's to map (`benilla_app::spell::net`): the ids live in the
/// message catalog, which is a UI-layer table, not a `Spell.dbc` fact.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LearnAnnouncement {
+27 -3
View File
@@ -283,13 +283,17 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin
};
let letter = letter_b as char;
if !letter.is_ascii_alphabetic() {
out.push_str(&text[start..i + 1]);
i += 1;
// Not a token: keep `$` and the char after it, raw. `i + 1` would cut a multi-byte
// char in half (`$é` in a localized or patched Spell.dbc), so step by its width.
let ch_len = utf8_len(letter_b);
out.push_str(&text[start..i + ch_len]);
i += ch_len;
continue;
}
i += 1;
let slot = if i < bytes.len() && bytes[i].is_ascii_digit() {
let s = (bytes[i] - b'1') as usize;
// `$s0` is a 1-based slot below 1: saturate to slot 0 rather than wrap.
let s = bytes[i].saturating_sub(b'1') as usize;
i += 1;
s.min(2)
} else {
@@ -504,4 +508,24 @@ mod tests {
"Returns you to $z."
);
}
/// `$` before a non-ASCII char is not a token, and the raw pass-through must cut on the
/// char's boundary: `&text[start..i + 1]` ended inside the `é` and panicked on every tooltip
/// of a localized or private-server Spell.dbc that carried one.
#[test]
fn a_dollar_before_a_multibyte_char_passes_through_on_the_char_boundary() {
let durations = SpellDurationCatalog::default();
let radii = SpellRadiusCatalog::default();
let d = SpellDisplay {
effect_base_points: [13, 24, 0],
effect_base_dice: [1, 0, 0],
effect_die_sides: [9, 0, 0],
..Default::default()
};
let c = ctx(&durations, &radii, &none_lookup);
assert_eq!(substitute("coûte $é or $…!", &d, &c), "coûte $é or $…!");
assert_eq!(substitute("$é", &d, &c), "$é");
// A slot digit below the 1-based range (`$s0`) reads as slot 0 rather than wrapping.
assert_eq!(substitute("$s0", &d, &c), "<14..22>");
}
}
+20 -10
View File
@@ -288,11 +288,18 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
// record whose id/bone doesn't fit, or whose bone indexes past `bone_list` (now fully parsed
// above), is dropped rather than kept malformed — real attachment ids run ≤ 36, so this never
// drops legitimate data.
let att_count = attachments.0 as usize;
let att_avail = b.len().saturating_sub(attachments.1 as usize);
let mut attachment_list = Vec::with_capacity(capped(attachments.0 as usize, 48, att_avail));
// The whole table must fit the file before it is walked — the loop's `get(..)?` reaches that
// verdict anyway, but the per-record index vector below is sized from the count, and a raw
// u32::MAX there was an 8 GiB non-zero fill before the first bounds-checked read.
if capped(att_count, 48, att_avail) < att_count {
return Err(Error::Truncated);
}
let mut attachment_list = Vec::with_capacity(att_count);
// A dropped record shifts the emitted indices, and the AttachLookup below indexes the FILE's
// records — so the translation is carried here rather than reconstructed later.
let mut emitted_at = vec![0xffffu16; attachments.0 as usize];
let mut emitted_at = vec![0xffffu16; att_count]; // proven to fit above
for (i, emitted) in emitted_at.iter_mut().enumerate() {
let a = get(attachments.1 as usize + i * 48, 48)?;
let id = a.u32_at(0).ok_or(Error::Truncated)?;
@@ -397,20 +404,22 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
// alpha/weight values gate batch visibility (a constant 0 hides the batch — wow-re
// `m2-alpha-combine-cull`); the RGB is the per-batch tint multiplied into the vertex colour.
// `track_fix16`/`track_vec3_timed` bounds-check internally and return an empty key list for
// an out-of-range track (kept exactly — real art relies on that "no keys" tolerance); the
// reservation for the *outer* per-record `Vec` is capped the same way as the arrays above.
// an out-of-range track (kept exactly — real art relies on that "no keys" tolerance). That
// same tolerance is why the *outer* loops run over the whole records the file holds past the
// block's offset, never the raw count: a reader that cannot fail would otherwise push one
// empty track per claimed record, and a corrupt count claims billions.
let colors_avail = b.len().saturating_sub(colors_arr.1 as usize);
let colors_cap = capped(colors_arr.0 as usize, 0x38, colors_avail);
let mut color_alpha_tracks = Vec::with_capacity(colors_cap);
let mut color_rgb_tracks = Vec::with_capacity(colors_cap);
for i in 0..colors_arr.0 as usize {
for i in 0..colors_cap {
color_alpha_tracks.push(track_fix16(b, colors_arr.1 as usize + i * 0x38 + 0x1c));
color_rgb_tracks.push(track_vec3_timed(b, colors_arr.1 as usize + i * 0x38));
}
let transparency_avail = b.len().saturating_sub(transparency_arr.1 as usize);
let transparency_cap = capped(transparency_arr.0 as usize, 0x1c, transparency_avail);
let mut transparency_tracks = Vec::with_capacity(transparency_cap);
for i in 0..transparency_arr.0 as usize {
for i in 0..transparency_cap {
transparency_tracks.push(track_fix16(b, transparency_arr.1 as usize + i * 0x1c));
}
let tulookup_avail = b.len().saturating_sub(texture_unit_lookup_arr.1 as usize);
@@ -439,11 +448,12 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
// Each M2TextureTransform (header 0x74, stride 0x54) is 3 back-to-back M2Tracks — translation
// (C3Vector) @+0x00, rotation (quaternion) @+0x1c, scaling (C3Vector) @+0x38 (wow-re models.md
// element table). Same "no keys" tolerance as the colour/weight tracks above.
// element table). Same "no keys" tolerance as the colour/weight tracks above, and the same
// file-bounded loop for the same reason.
let ttf_avail = b.len().saturating_sub(tex_anim_arr.1 as usize);
let mut texture_transforms =
Vec::with_capacity(capped(tex_anim_arr.0 as usize, 0x54, ttf_avail));
for i in 0..tex_anim_arr.0 as usize {
let ttf_cap = capped(tex_anim_arr.0 as usize, 0x54, ttf_avail);
let mut texture_transforms = Vec::with_capacity(ttf_cap);
for i in 0..ttf_cap {
let base = tex_anim_arr.1 as usize + i * 0x54;
texture_transforms.push(M2TextureTransform {
translation: track_vec3_timed(b, base),
+36
View File
@@ -311,6 +311,42 @@ fn hostile_vertex_count_errs_cleanly_not_oom() {
assert!(matches!(parse(&b), Err(Error::Truncated)));
}
#[test]
fn hostile_attachment_count_errs_cleanly_not_oom() {
// The vertex case's sibling one block later: the per-record index vector `emitted_at` was
// sized from the raw attachment count (u32::MAX → an 8 GiB non-zero fill that touches every
// page) while the reservation beside it was capped. The table is now refused up front when
// the file cannot hold it — the verdict the loop's bounds-checked read reached one line later.
let mut b = header();
set_arr(&mut b, OFS_ATTACHMENTS, u32::MAX, HEADER_LEN as u32);
assert!(matches!(parse(&b), Err(Error::Truncated)));
}
#[test]
fn hostile_track_record_counts_walk_only_what_the_file_holds() {
// The colour (0x54), transparency (0x64) and texture-transform (0x74) readers are lenient by
// design — an out-of-range track is "no keys" — so a raw count of u32::MAX used to mean four
// billion empty tracks pushed one per iteration until the OS killed the process. The loop
// bound is now the number of whole records the file holds past the block's offset.
let mut b = header();
set_arr(&mut b, 0x54, u32::MAX, HEADER_LEN as u32);
set_arr(&mut b, 0x64, u32::MAX, HEADER_LEN as u32);
set_arr(&mut b, OFS_TEX_ANIM, u32::MAX, HEADER_LEN as u32);
let fmt = parse(&b).expect("lenient blocks never fail the parse");
let m = fmt.model();
assert!(m.color_alpha_tracks.is_empty());
assert!(m.color_rgb_tracks.is_empty());
assert!(m.transparency_tracks.is_empty());
assert!(m.texture_transforms.is_empty());
// A count that overshoots a partly-present block yields exactly the whole records in it.
let mut b = header();
let ofs = b.len() as u32;
b.extend(m2track(0, 0xffff, (0, 0), (0, 0))); // one whole 0x1c weight record …
b.extend(vec![0u8; 0x10]); // … and part of a second
set_arr(&mut b, 0x64, 5, ofs);
assert_eq!(parse(&b).unwrap().model().transparency_tracks.len(), 1);
}
#[test]
fn truncated_vertex_record_errs_cleanly() {
let mut b = header();
+112 -14
View File
@@ -271,18 +271,6 @@ impl Archive {
let compressed = block.flags & (FLAG_COMPRESS | FLAG_IMPLODE) != 0;
let implode_only = block.flags & FLAG_IMPLODE != 0 && block.flags & FLAG_COMPRESS == 0;
if !compressed {
// Stored: raw bytes, exactly file_size.
file.seek(SeekFrom::Start(file_pos))?;
let mut out = vec![0u8; file_size];
file.read_exact(&mut out)?;
return Ok(out);
}
// Sectored read. The offset table is `sector_count + 1` u32s at file_pos; sector offsets are
// absolute from file_pos, so a SECTOR_CRC table (present only on the patch archives) sits
// between the offset table and the first sector and is skipped for free.
//
// `file_size` (hence `sector_count`) and each sector's `comp_len` all come from the
// block-table entry / on-disk offset table — the same attacker-controllable-header shape as
// the hash/block tables (decision 0064). Cap every reservation below by what the archive file
@@ -290,6 +278,25 @@ impl Archive {
// allocator aborting.
let file_len = file.metadata()?.len();
let avail = avail_from(file_len, file_pos);
if !compressed {
// Stored: raw bytes, exactly file_size — which the archive must hold from `file_pos`
// on. A stored entry claiming more is a corrupt block table, refused before the
// buffer is sized rather than after a 4 GiB one comes back short.
if capped(file_size, 1, avail) < file_size {
return Err(Error::Corrupt(format!(
"{name}: stored size ({file_size}) larger than the archive"
)));
}
file.seek(SeekFrom::Start(file_pos))?;
let mut out = vec![0u8; file_size]; // proven <= avail above
file.read_exact(&mut out)?;
return Ok(out);
}
// Sectored read. The offset table is `sector_count + 1` u32s at file_pos; sector offsets are
// absolute from file_pos, so a SECTOR_CRC table (present only on the patch archives) sits
// between the offset table and the first sector and is skipped for free.
let sector_count = file_size.div_ceil(idx.sector_size);
let otab_len = sector_count.checked_add(1).ok_or_else(|| {
Error::Corrupt(format!("{name}: sector count overflow ({sector_count})"))
@@ -327,7 +334,14 @@ impl Archive {
"{name}: sector {i} length ({comp_len}) larger than the archive"
)));
}
// Every earlier sector yielded at most its own `want` (`decompress` is bounded to
// `expected`), so `out.len() <= file_size` and this cannot wrap; `want > 0` in-loop.
let want = (file_size - out.len()).min(idx.sector_size); // last sector may be short
// Two equal consecutive offsets are a mis-built table, not a sector: zero bytes can
// carry no codec byte and no payload, so refuse before anything indexes into them.
if comp_len == 0 {
return Err(Error::Corrupt(format!("{name}: sector {i} is empty")));
}
file.seek(SeekFrom::Start(file_pos + start))?;
let mut raw = vec![0u8; comp_len]; // proven <= avail above
@@ -342,7 +356,7 @@ impl Archive {
out.extend_from_slice(&decompress(0x08, &raw, want, name)?);
} else {
// COMPRESS: leading byte is the codec mask, rest is the payload.
let method = raw[0];
let method = raw[0]; // non-empty: refused above
out.extend_from_slice(&decompress(method, &raw[1..], want, name)?);
}
}
@@ -480,7 +494,11 @@ fn decompress(method: u8, data: &[u8], expected: usize, name: &str) -> Result<Ve
0x02 => {
use flate2::read::ZlibDecoder;
let mut out = Vec::with_capacity(expected);
// Bounded to `expected`: a stream that inflates past its sector's slot would push the
// caller's output past `file_size` and wrap the next sector's `want` — a malformed
// sector, not a shape 1.12 data has, and never the caller's arithmetic to absorb.
ZlibDecoder::new(data)
.take(expected as u64)
.read_to_end(&mut out)
.map_err(|e| Error::Decompress(format!("{name}: zlib: {e}")))?;
Ok(out)
@@ -589,6 +607,12 @@ mod tests {
/// stored `data`. Tables are encrypted with the real keys so the reader's decrypt round-trips
/// them — enough to exercise flag handling (delete-marker vs. a plain stored file).
fn archive_with_one_entry(name: &str, flags: u32, data: &[u8]) -> Vec<u8> {
archive_with_one_block(name, flags, data, data.len() as u32)
}
/// [`archive_with_one_entry`] with the block entry's `file_size` set independently of the
/// bytes actually appended — the shape of a lying block table.
fn archive_with_one_block(name: &str, flags: u32, data: &[u8], file_size: u32) -> Vec<u8> {
use crypto::{encrypt_block, hash_type};
const HASH_SLOTS: u32 = 4; // power of two — the reader masks with len-1
let hash_pos = 32u32;
@@ -605,7 +629,7 @@ mod tests {
encrypt_block(&mut hash, hash_string("(hash table)", hash_type::FILE_KEY));
// Block table (plaintext): one entry pointing at the trailing data.
let mut block = vec![data_pos, data.len() as u32, data.len() as u32, flags];
let mut block = vec![data_pos, data.len() as u32, file_size, flags];
encrypt_block(
&mut block,
hash_string("(block table)", hash_type::FILE_KEY),
@@ -663,4 +687,78 @@ mod tests {
assert_eq!(avail_from(100, 10), 90);
assert_eq!(avail_from(0, 0), 0);
}
/// A sector offset table with two equal consecutive offsets names a zero-length sector; the
/// codec-byte read (`raw[0]`) indexed that empty buffer and panicked on the first read of the
/// file. A private server's mis-built `patch-*.MPQ` is the realistic source.
#[test]
fn an_empty_sector_is_refused_not_indexed() {
// One 8-byte file, one sector: offsets [8, 8] — zero bytes for a sector that must yield 8.
let mut data = Vec::new();
data.extend_from_slice(&8u32.to_le_bytes());
data.extend_from_slice(&8u32.to_le_bytes());
let (arc, path) = open_temp_kept(
"empty_sector",
&archive_with_one_entry("a.bin", FLAG_EXISTS | FLAG_COMPRESS, &data),
);
match arc.read_file("a.bin") {
Err(Error::Corrupt(_)) => {}
other => panic!("expected Error::Corrupt for an empty sector, got {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
/// A sector that inflates past its own slot used to run the output past `file_size`, and the
/// next sector's `want = file_size - out.len()` wrapped (a panic in dev, a `usize::MAX`
/// reservation in release). The inflate is bounded to the sector's expected size instead.
#[test]
fn an_over_inflating_sector_cannot_run_the_output_past_file_size() {
use flate2::{write::ZlibEncoder, Compression};
use std::io::Write;
// A COMPRESS sector as stored: the 0x02 (zlib) codec byte, then the stream.
let zlib = |raw: &[u8]| {
let mut e = ZlibEncoder::new(vec![0x02u8], Compression::default());
e.write_all(raw).unwrap();
e.finish().unwrap()
};
// 612 bytes of file = one full 512-byte sector (shift 0) + a 100-byte tail. Sector 0's
// stream inflates to 1000 bytes, twice its slot.
let s0 = zlib(&[0u8; 1000]);
let s1 = zlib(&[7u8; 100]);
let otab_len = 12u32;
let mut data = Vec::new();
data.extend_from_slice(&otab_len.to_le_bytes());
data.extend_from_slice(&(otab_len + s0.len() as u32).to_le_bytes());
data.extend_from_slice(&(otab_len + (s0.len() + s1.len()) as u32).to_le_bytes());
data.extend_from_slice(&s0);
data.extend_from_slice(&s1);
let (arc, path) = open_temp_kept(
"over_inflate",
&archive_with_one_block("a.bin", FLAG_EXISTS | FLAG_COMPRESS, &data, 612),
);
let out = arc
.read_file("a.bin")
.expect("a bounded inflate reads cleanly");
assert_eq!(out.len(), 612);
assert!(out[..512].iter().all(|&b| b == 0));
assert!(out[512..].iter().all(|&b| b == 7));
let _ = std::fs::remove_file(&path);
}
/// A stored (uncompressed) entry's buffer was sized straight from the block table's
/// `file_size` — `vec![0u8; 4 GiB]` before the read that would have come back short. It is
/// now refused against what the archive holds from the entry's position, like the sectored
/// path's reservations; `Corrupt`, not the read's `Io`, pins that the guard fired first.
#[test]
fn a_stored_size_larger_than_the_archive_is_refused_before_allocating() {
let (arc, path) = open_temp_kept(
"stored_overflow",
&archive_with_one_block("a.txt", FLAG_EXISTS, b"hello", u32::MAX),
);
match arc.read_file("a.txt") {
Err(Error::Corrupt(_)) => {}
other => panic!("expected Error::Corrupt for a lying stored size, got {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
}
@@ -217,7 +217,7 @@ impl MovementBlock {
// The dictated final facing, in vmangos's own angle → target → point order
// (`packet_builder.cpp:162-167`). Parsed for alignment only: a unit still riding a
// path faces its travel tangent every frame, exactly as `MonsterMove`'s facing snap is
// overwritten while a path runs (`net::apply::objects::monster_move`).
// overwritten while a path runs (`net::objects::monster_move`).
if spline_flags & SPLINE_FLAG_FINAL_ANGLE != 0 {
let _ = read_f32_le(r)?;
} else if spline_flags & SPLINE_FLAG_FINAL_TARGET != 0 {
+42 -7
View File
@@ -151,9 +151,31 @@ impl<R: Read + Seek> WdtReader<R> {
}
}
/// One chunk's `size`-byte payload — refused before the buffer is sized when the stream
/// (ending at `stream_end`) cannot hold it, so a raw size never reaches the allocator
/// unchecked.
fn read_payload(&mut self, size: u64, stream_end: u64) -> std::io::Result<Vec<u8>> {
let remaining = stream_end.saturating_sub(self.reader.stream_position()?);
if size > remaining {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"WDT chunk runs past the end of the stream",
));
}
let mut buf = vec![0u8; size as usize];
self.reader.read_exact(&mut buf)?;
Ok(buf)
}
/// Parse the WDT, returning the tile grid and (on a WMO-only map) the global WMO placement.
/// Walks chunks until EOF; everything but `MPHD`/`MAIN`/`MWMO`/`MODF` is skipped.
pub fn read(&mut self) -> std::io::Result<WdtFile> {
// Chunk sizes are raw on-disk u32s: each payload buffer below is bounded by what the
// stream still holds (`read_payload`), so a corrupt size fails as the short read it is
// instead of asking the allocator for up to 4 GiB first.
let start = self.reader.stream_position()?;
let stream_end = self.reader.seek(SeekFrom::End(0))?;
self.reader.seek(SeekFrom::Start(start))?;
let mut has_adt: Option<Vec<bool>> = None;
let mut wmo_only = false;
let mut wmo_path: Option<String> = None;
@@ -169,8 +191,7 @@ impl<R: Read + Seek> WdtReader<R> {
// MPHD — only dword0 bit 0 is ever read: "this map has no terrain, its world is
// the global WMO below" (the reference reads exactly this one bit, @0x694810).
b"DHPM" if size >= 4 => {
let mut buf = vec![0u8; size as usize];
self.reader.read_exact(&mut buf)?;
let buf = self.read_payload(size, stream_end)?;
wmo_only = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) & 0x1 != 0;
}
b"NIAM" => {
@@ -194,8 +215,7 @@ impl<R: Read + Seek> WdtReader<R> {
// MWMO — one NUL-terminated path on a WMO-only map, and a zero-length stub on an
// ADT map (which is why the `wmo_only` flag, not this chunk, is the gate).
b"OMWM" if size > 0 => {
let mut buf = vec![0u8; size as usize];
self.reader.read_exact(&mut buf)?;
let buf = self.read_payload(size, stream_end)?;
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let path = String::from_utf8_lossy(&buf[..end]).into_owned();
if !path.is_empty() {
@@ -204,9 +224,7 @@ impl<R: Read + Seek> WdtReader<R> {
}
// MODF — exactly one 64-byte placement here (the ADT variant carries many).
b"FDOM" if size >= 64 => {
let mut buf = vec![0u8; size as usize];
self.reader.read_exact(&mut buf)?;
modf = Some(buf);
modf = Some(self.read_payload(size, stream_end)?);
}
_ => {
self.reader.seek(SeekFrom::Current(size as i64))?;
@@ -486,4 +504,21 @@ mod tests {
let err = parse(vec![b'N', b'I', b'A']).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
/// A chunk claiming more bytes than the stream holds is refused before its buffer is sized:
/// the `MPHD`/`MWMO`/`MODF` reads used to `vec![0; size]` off the raw u32 first (a 4 GiB
/// commit a lazy allocator hides and Windows aborts on) and only then hit EOF. The message
/// pins that it is the guard, not the read after it, that refuses.
#[test]
fn a_chunk_size_past_the_end_of_stream_is_refused_before_allocating() {
for magic in [b"DHPM", b"OMWM", b"FDOM"] {
let mut bytes = Vec::new();
bytes.extend_from_slice(magic);
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
bytes.extend_from_slice(&[0u8; 64]); // some payload, nowhere near the claim
let err = parse(bytes).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
assert_eq!(err.to_string(), "WDT chunk runs past the end of the stream");
}
}
}
-1
View File
@@ -36,7 +36,6 @@ benilla-wmo = { path = "../benilla-wmo" }
bevy.workspace = true
bytemuck = { version = "1", features = ["derive"] }
image = { version = "0.25", default-features = false, features = ["png"] }
libc = "0.2"
# macOS: `bgwin` demotes the app to Accessory for a background run and orders the window out of
# the way. Same pins as `benilla-app`'s — one objc2 in the graph.
+22 -12
View File
@@ -49,8 +49,8 @@ use interior::{track_area_interior, track_current_interior, track_unit_interiors
pub use interior::{
CurrentAreaInterior, CurrentWmoInterior, PlayerWmoRoom, UnitWmoRoom, WmoInteriorKeys,
};
use probe::TraceLog;
pub use probe::WmoCullProbe;
use probe::{TraceLog, PROBE_DUMP_PATH};
pub use room_vis::room_pvs_visible;
use seed::dominant_axes;
pub use seed::{down_ray_seeds, floor_z_at, DownRaySeeds};
@@ -389,16 +389,21 @@ fn compute_wmo_pvs(
// The terrain leg of the seed's down-ray, sampled once for the camera's column: the client casts the
// same segment at the ground and drops the WMO hit when the ground is nearer (`FUN_006821f0`).
let terrain = terrain_height_under(&streamer, &adt_tiles, eye_world);
// `WOW_CULLDUMP=1` re-requests the dump every frame (last writer wins): a headless capture has
// no panel button, and the first frames race asset residency, so a one-shot request from
// startup would photograph an empty world.
static ENV_DUMP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let dump = std::mem::take(&mut probe.dump_requested)
|| *ENV_DUMP.get_or_init(|| std::env::var_os("WOW_CULLDUMP").is_some());
// `WOW_CULLDUMP=<path>` re-requests the dump every frame (last writer wins): a headless
// capture has no panel button, and the first frames race asset residency, so a one-shot
// request from startup would photograph an empty world. A path, `WOW_MOVE_TRACE`'s shape —
// never a default this crate picks; [`WmoCullProbe::dump_to`] says why. (`WOW_CULLDUMP=1` is
// no longer a spelling: it wrote `target/wmo-cull-trace.txt` into whatever the cwd was.)
static ENV_DUMP: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
let dump_to = probe.dump_to.take().or_else(|| {
ENV_DUMP
.get_or_init(|| std::env::var_os("WOW_CULLDUMP").map(std::path::PathBuf::from))
.clone()
});
// The camera pose IN FULL: replaying a dump in the pin probe needs forward + fov + aspect, not
// just the eye — B65's first dump recorded only the eye and the replay had to reconstruct the
// look from the player's position.
let mut dump_text = dump.then(|| {
let mut dump_text = dump_to.is_some().then(|| {
let fwd = cam_t.forward();
let (fovy, aspect) = match proj {
Projection::Perspective(p) => (p.fov, p.aspect_ratio),
@@ -596,15 +601,20 @@ fn compute_wmo_pvs(
if *camera_windows != want_windows {
*camera_windows = want_windows;
}
if let Some(mut text) = dump_text {
if let Some((mut text, path)) = dump_text.zip(dump_to) {
// The frame's published verdict, after every placement has had its say: which room claimed
// the camera and what the open world is allowed to draw through. The per-placement traces
// above say what each flood found; only this says what the scene actually got.
text.push_str(&format!("CAMERA CLAIM: {:?}\n", camera_claim.0));
text.push_str(&format!("EXTERIOR WINDOWS: {:?}\n", *camera_windows));
match std::fs::write(PROBE_DUMP_PATH, &text) {
Ok(()) => info!("wmo cull trace written to {PROBE_DUMP_PATH}"),
Err(e) => warn!("wmo cull trace: cannot write {PROBE_DUMP_PATH}: {e}"),
// The folder is made at the moment there is a trace to put in it (the stall sampler's
// rule for `Diagnostics/`): an instrument never advertises itself with an empty folder.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&path, &text) {
Ok(()) => info!("wmo cull trace written to {}", path.display()),
Err(e) => warn!("wmo cull trace: cannot write {}: {e}", path.display()),
}
}
}
+7 -5
View File
@@ -16,8 +16,13 @@ use super::{
/// + per-portal verdicts land in a file the audit harness can replay as a fixture.
#[derive(Resource, Default)]
pub struct WmoCullProbe {
/// Set by the panel's dump button; the next compute writes the trace file and clears it.
pub dump_requested: bool,
/// Set by the panel's dump button: the file the next compute writes the trace to, then
/// clears. **A path handed in, never one this crate builds.** `benilla-world` has no
/// `local_state`, and the cwd-relative `target/wmo-cull-trace.txt` this used to write is the
/// install folder the moment a player launches the binary from inside it — the one write in
/// this crate, and the one 1486 forbids. The panel resolves the path under
/// `benilla-config/Diagnostics/`; `WOW_CULLDUMP=<path>` is the headless spelling.
pub dump_to: Option<std::path::PathBuf>,
/// **The eye the PVS, the interior claim and the exterior windows were computed from** —
/// the visibility authority's own pose, recorded so an instrument can compare it against the
/// pose the frame actually draws from. Ordinary movement makes the two identical to within a
@@ -25,9 +30,6 @@ pub struct WmoCullProbe {
pub eye: Vec3,
}
/// Where the dump button writes its trace — under `target/` so it never lands in the repo.
pub(super) const PROBE_DUMP_PATH: &str = "target/wmo-cull-trace.txt";
/// The probe dump's recorder: the seed's evidence (floor faces + portal crossings under the eye's
/// column) plus every hop verdict, as text.
pub(crate) struct TraceLog {