diff --git a/Cargo.lock b/Cargo.lock index f775f249..ab0e83e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 0552b3cc..55960f07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index aa6f4c9d..6a29ac0f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 18380dad..413fd5d5 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -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. diff --git a/crates/benilla-app/src/addon_harness/render_tests.rs b/crates/benilla-app/src/addon_harness/render_tests.rs index 10d6b839..ca9a578a 100644 --- a/crates/benilla-app/src/addon_harness/render_tests.rs +++ b/crates/benilla-app/src/addon_harness/render_tests.rs @@ -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 { - 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 { /// 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. diff --git a/crates/benilla-app/src/addon_harness/use_probe_tests.rs b/crates/benilla-app/src/addon_harness/use_probe_tests.rs index f2d3fecf..ea6d2728 100644 --- a/crates/benilla-app/src/addon_harness/use_probe_tests.rs +++ b/crates/benilla-app/src/addon_harness/use_probe_tests.rs @@ -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 { - 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 { /// 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(); diff --git a/crates/benilla-app/src/capture/probe_castcancel.rs b/crates/benilla-app/src/capture/probe_castcancel.rs index 7a195d04..8d995157 100644 --- a/crates/benilla-app/src/capture/probe_castcancel.rs +++ b/crates/benilla-app/src/capture/probe_castcancel.rs @@ -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` — 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 diff --git a/crates/benilla-app/src/chr_classes.rs b/crates/benilla-app/src/chr_classes.rs index 098754f6..c04dc721 100644 --- a/crates/benilla-app/src/chr_classes.rs +++ b/crates/benilla-app/src/chr_classes.rs @@ -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. //! diff --git a/crates/benilla-app/src/crash.rs b/crates/benilla-app/src/crash.rs index 96a5644e..7d898380 100644 --- a/crates/benilla-app/src/crash.rs +++ b/crates/benilla-app/src/crash.rs @@ -46,38 +46,10 @@ pub(crate) fn install(build: BuildId) { })); } -/// `WOW_CRASH_INJECT=` — 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-.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::().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, - time: bevy::prelude::Res>, -) { - 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=`, 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, diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index e448d4cb..66db6952 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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, - 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, 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, 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, + mut auto_repeat: ResMut, mut sheath: MessageWriter, net: Res| { start_attack_local( diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index 718c7c78..40ac6e8e 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -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, diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index cc429e62..404eb5be 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -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 { diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index aaa43d89..bc399c76 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -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= to name one" + ), + } } }); diff --git a/crates/benilla-app/src/entities/chain_beam.rs b/crates/benilla-app/src/entities/chain_beam.rs index 08bc9462..25427134 100644 --- a/crates/benilla-app/src/entities/chain_beam.rs +++ b/crates/benilla-app/src/entities/chain_beam.rs @@ -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 diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 33cd1ae2..1e4d87a1 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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 diff --git a/crates/benilla-app/src/hover_log.rs b/crates/benilla-app/src/hover_log.rs index af3cd490..75113601 100644 --- a/crates/benilla-app/src/hover_log.rs +++ b/crates/benilla-app/src/hover_log.rs @@ -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 { 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= to name one. Not recording." + ); + None + } + }, Ok(v) => Some(v), } } diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index bab9536a..4b314310 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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; diff --git a/crates/benilla-app/src/name_persist.rs b/crates/benilla-app/src/name_persist.rs index 8f395f90..4990ed5f 100644 --- a/crates/benilla-app/src/name_persist.rs +++ b/crates/benilla-app/src/name_persist.rs @@ -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()); diff --git a/crates/benilla-app/src/names.rs b/crates/benilla-app/src/names.rs index 11ea64c7..cb3c23fa 100644 --- a/crates/benilla-app/src/names.rs +++ b/crates/benilla-app/src/names.rs @@ -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 diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 9f0f7def..baa62eda 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -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, }, diff --git a/crates/benilla-app/src/net/handlers.rs b/crates/benilla-app/src/net/handlers.rs index 0c89c6bf..66fedeb2 100644 --- a/crates/benilla-app/src/net/handlers.rs +++ b/crates/benilla-app/src/net/handlers.rs @@ -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() diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index d93e0793..7a88c9ba 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -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::(); - // 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. diff --git a/crates/benilla-app/src/net/session.rs b/crates/benilla-app/src/net/session.rs index f9b55b19..59f8787e 100644 --- a/crates/benilla-app/src/net/session.rs +++ b/crates/benilla-app/src/net/session.rs @@ -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, ) { diff --git a/crates/benilla-app/src/perf/crash_inject.rs b/crates/benilla-app/src/perf/crash_inject.rs new file mode 100644 index 00000000..e2452935 --- /dev/null +++ b/crates/benilla-app/src/perf/crash_inject.rs @@ -0,0 +1,40 @@ +//! `WOW_CRASH_INJECT=` — 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-.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::().ok()) + { + app.insert_resource(CrashInject { at }); + app.add_systems(Update, crash_inject); + } +} + +#[derive(Resource)] +struct CrashInject { + at: f32, +} + +fn crash_inject(inject: Res, time: Res>) { + 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)"); + } +} diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index 7b198905..4345ad72 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -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=` — 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=` — which PHASE of a slow frame spent it (see the module doc). diff --git a/crates/benilla-app/src/perf/stall.rs b/crates/benilla-app/src/perf/stall.rs index aeb6cac5..3f8b08c2 100644 --- a/crates/benilla-app/src/perf/stall.rs +++ b/crates/benilla-app/src/perf/stall.rs @@ -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) { diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 361dd2f8..5bb855b3 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -60,7 +60,7 @@ pub(super) fn control( MessageWriter, // 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, + ResMut, // 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, @@ -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`, diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index ca5e38ab..62402771 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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") diff --git a/crates/benilla-app/src/sound/creature.rs b/crates/benilla-app/src/sound/creature.rs index 7f94e13a..1714fda7 100644 --- a/crates/benilla-app/src/sound/creature.rs +++ b/crates/benilla-app/src/sound/creature.rs @@ -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, diff --git a/crates/benilla-app/src/cooldowns.rs b/crates/benilla-app/src/spell/cooldowns.rs similarity index 99% rename from crates/benilla-app/src/cooldowns.rs rename to crates/benilla-app/src/spell/cooldowns.rs index 0400e5d7..69499131 100644 --- a/crates/benilla-app/src/cooldowns.rs +++ b/crates/benilla-app/src/spell/cooldowns.rs @@ -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 diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs new file mode 100644 index 00000000..7a166078 --- /dev/null +++ b/crates/benilla-app/src/spell/inflight.rs @@ -0,0 +1,1220 @@ +//! **The in-flight slot** — the spell's own cast state, in the reference's terms: the pending +//! cast request (`SPELLCAST` @`0xceac48` + the inflight id `0xceca88` → [`PendingCast`]), the +//! queued on-next-swing strike ([`QueuedMeleeSpell`], our second slot for what the reference +//! keeps in the one id — [`Inflight`] re-joins them), the running channel (`0xceac58` → +//! [`ActiveChannel`]), the auto-repeat key (`0xceac30` → [`AutoRepeatActive`]), the +//! `modalNextSpell` outbox ([`ChainCasts`]) — and the local abort that ends a cast the same +//! tick a move, a jump or Esc lands (`AbortCast 0x6e4940` → [`local_self_cancel`]). +//! +//! Written by the spell's packet handlers ([`super::net`]), the one cast path +//! (`crate::ui_action::CastLadder`) and the attack seams; read by the feeds — the cast bar +//! (`crate::ui_cast`), the action state, the target chain. Until decision 2328 this lived in +//! `ui_cast` and `ui_action`; it is the state 2265 §A7 named as having no owner. + +use std::time::{Duration, Instant}; + +use bevy::prelude::*; + +use benilla_ui::script::UiScript; + +use crate::creature_anim::{CastEvent, CastEventKind, Casting, PlaySeq}; +use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfGuid}; +use crate::ui_action::Spells; +use crate::ui_cast::{CastBarEdge, CastBarFeed}; + +/// Provisional in-flight window armed at send, before `SMSG_SPELL_START` names the real cast time. +/// It only has to outlast the send→START round trip; an instant, whose `SMSG_SPELL_GO` clears the +/// guard first, never reaches it. Generous on purpose — the resolution packets do the real clearing. +const SEND_PROVISIONAL: Duration = Duration::from_secs(5); + +/// The same provisional for an **item** use's cast ([`PendingCast::arm_item`]) — deliberately much +/// tighter, because the two arms have different worst cases *on this wire*. Every +/// `CMSG_CAST_SPELL` vmangos accepts is answered (`Spell::SendCastResult` on any rejection, +/// `SMSG_SPELL_START`/`GO` otherwise), so a spell's guard is always cleared by a packet and +/// [`SEND_PROVISIONAL`] never actually governs. `CMSG_USE_ITEM` has legs that answer with +/// `SMSG_INVENTORY_CHANGE_FAILURE` and **no cast result at all** — `HandleUseItemOpcode`'s +/// equip-error gates (`CanUseItem`, the in-combat non-combat-spell refusal, item-not-found) — +/// and there the provisional IS the safety net, so it has to be short enough that a silently +/// refused use cannot lock casting out for long. It only ever covers the send→`SMSG_SPELL_START` +/// window anyway: [`PendingCast::refine`] stretches the guard to the server's real cast time the +/// moment START names it, so a use that *does* become a cast is guarded for its whole length. +const ITEM_SEND_PROVISIONAL: Duration = Duration::from_millis(1_500); + +/// Pushback slack added to the server's cast time at `SMSG_SPELL_START`, so a cast the server +/// delays (`SMSG_SPELL_DELAYED`) still guards past its stretched end. +const CAST_SLACK: Duration = Duration::from_secs(2); + +/// Our own outstanding cast — the client's optimistic in-flight guard, and the fix for the +/// spam-cancel bug. wow-re `wave-cast.md`: `TryCast 0x6e4b60` refuses to send a second +/// `CMSG_CAST_SPELL` while `IsCasting 0x6e3d30` (the inflight-spell-id `0xceca88`) is set — the +/// same-spell press bails **silently** at `6e4d43`; a different spell hits the +/// `[SpellRec+0x18] & 0x404` gate at `6e4d97`: when the *inflight* rec lacks those bits (an +/// ordinary cast — Fireball is `0x10000`), it errors 0x61 without sending; when the inflight IS +/// on-next-swing (a queued Heroic Strike), the new cast passes and nests around it (mask and +/// operand §5-confirmed byte-exact, wow-re `combat-feel-law.md` @ c445713b — exactly `0x404`, on +/// the inflight rec, no channel leg: Slam mid-cast blocks Battle Shout). In our model +/// the on-next-swing class never occupies this guard at all — it arms [`QueuedMeleeSpell`] +/// instead — so this guard only ever holds ordinary casts and the gate needs no attribute test. +/// Ours was server-driven: every mashed key fired a *duplicate* cast, the +/// server rejected the dupe with `SMSG_CAST_RESULT` failure, and that turned the running cast's bar +/// red while the original completed later. This marker drops the duplicate at the source, exactly +/// as the client does. +/// +/// It is **optimistic** — armed the instant we send, not when the server's `SMSG_SPELL_START` +/// echoes back — because the spam lands during that round trip. Cleared by the cast's resolution +/// (`SMSG_SPELL_GO` / a failing `SMSG_CAST_RESULT` / `SMSG_SPELL_FAILED_OTHER`), spell-id-keyed like +/// the `Casting` reap — and by the local self-cancel directly ([`local_self_cancel`], the +/// client's `AbortCast 0x6e4940` mirrored since 0444). `deadline` is the safety net for a +/// resolution that goes missing on the wire: armed generously and tightened to the real cast +/// time once `SMSG_SPELL_START` names it. +#[derive(Resource, Default)] +pub(crate) struct PendingCast(Option); + +struct PendingCastState { + spell_id: u32, + deadline: Instant, + /// Does this record **guard** — i.e. does it refuse the next press, light the in-flight ring, + /// and gate the by-name walk? True for the classes benilla lets occupy the guard: ordinary + /// casts and item uses. + /// + /// The reference has no such flag, because it has no such split: `[0xceca88]` is written for + /// **every** committed cast (`0x6e5026 call 0x6e4ad0` inside TryCast) and the *gate* does the + /// discriminating instead, on the inflight rec's `Attributes & 0x404` at `6e4d97`. Ours grew + /// up the other way round — the guard is armed only for the classes it refuses on, so a + /// ranged shot was never recorded as committed **at all**. + /// + /// That gap is what shipped B280 broken twice: the `modalNextSpell` chain + /// (`super::net`'s `cast_result`) has to answer the reference's `0x6e7408 cmp + /// ecx,[0xceca88]` — "is this reply for the cast I just sent?" — and every hunter shot is + /// `Attributes & 0x2` ranged, so the answer was always no and the chain never fired in play. + /// The flag closes it without touching the three consumers that were right: the record is now + /// armed for every commit ([`PendingCast::committed`] reads it), and `guards` keeps + /// [`PendingCast::in_flight`] / [`PendingCast::current`] answering exactly what they did. + guards: bool, +} + +impl PendingCast { + /// Whether a **guarding** cast we sent is still outstanding (unresolved and inside its safety + /// deadline) — the refusal's own question. A ranged shot is committed but does not guard. + pub(crate) fn in_flight(&self, now: Instant) -> bool { + self.0 + .as_ref() + .is_some_and(|p| p.guards && now < p.deadline) + } + + /// **The reference's `[0xceca88]` proper** — the id of the cast we last committed and have not + /// yet had a resolution for, whatever its class. This is what `HandleCastResult 0x6e7330` + /// tests at `0x6e7408` before it reads `modalNextSpell`, and the only read that must see a + /// ranged shot. Everything else about the record is unchanged; see [`PendingCastState::guards`] + /// for why the two questions are separate fields here and one global there. + pub(crate) fn committed(&self, now: Instant) -> Option { + self.0 + .as_ref() + .filter(|p| now < p.deadline) + .map(|p| p.spell_id) + } + + /// The outstanding cast's spell id, if one is in flight — the app-side mirror of the client's + /// current-cast read (`0xceca88`), which `IsCurrentAction`'s checked state keys on (decision + /// 0137 phase 4). + pub(crate) fn current(&self, now: Instant) -> Option { + self.0 + .as_ref() + .filter(|p| p.guards && now < p.deadline) + .map(|p| p.spell_id) + } + + /// Arm on a fresh send — the optimistic `0xceca88` write at the client's cast-send. `guards` + /// says whether this class also occupies the refusal ([`PendingCastState::guards`]): ordinary + /// casts do, a ranged shot is recorded and does not. + pub(crate) fn arm(&mut self, spell_id: u32, now: Instant, guards: bool) { + self.0 = Some(PendingCastState { + spell_id, + deadline: now + SEND_PROVISIONAL, + guards, + }); + } + + /// Arm the guard on an **item** use's send. The reference has no separate item-cast slot: the + /// item-use dispatcher's cast tail (`CGItem::Use 0x5d8d00` @ `0x5d9258`) calls `0x6e5a90`, + /// whose entire 54-byte body is `call 0x6e4b60` — `TryCast` itself (VERIFIED at the bytes, + /// wow-re `disasm-full.txt`; corroborated by its `action-button-state-api.md` §"dispatcher + /// `0x6e5a90` (→ `CastSpell 0x6e4b60`)" and `cursor-system.md` §536). So an item use writes + /// the SAME inflight id (`0xceca88`) and is refused by the same IsCasting gate — which is why + /// [`crate::ui_items::send_item_use`] runs both. Differs from [`PendingCast::arm`] only in the + /// deadline ([`ITEM_SEND_PROVISIONAL`], whose doc is the why). + pub(crate) fn arm_item(&mut self, spell_id: u32, now: Instant) { + self.0 = Some(PendingCastState { + spell_id, + deadline: now + ITEM_SEND_PROVISIONAL, + guards: true, + }); + } + + /// Tighten the deadline to the server's real cast time once `SMSG_SPELL_START` names it. + pub(crate) fn refine(&mut self, cast_time_ms: u32, now: Instant) { + if let Some(p) = &mut self.0 { + p.deadline = now + Duration::from_millis(u64::from(cast_time_ms)) + CAST_SLACK; + } + } + + /// Push the deadline out by a pushback (`SMSG_SPELL_DELAYED`), so the guard keeps holding past + /// the stretched cast end. Extends from whichever is later (the current deadline or now), so a + /// deadline already lapsed from an under-estimate still re-arms. + pub(crate) fn delay(&mut self, delay_ms: u32, now: Instant) { + if let Some(p) = &mut self.0 { + p.deadline = p.deadline.max(now) + Duration::from_millis(u64::from(delay_ms)); + } + } + + /// Clear the guard when the resolving spell is our outstanding one (spell-id-keyed, like the + /// `Casting` reap — a triggered proc's `SMSG_SPELL_GO` mid-cast must not open the gate early). + pub(crate) fn clear_if(&mut self, spell_id: u32) { + if self.0.as_ref().is_some_and(|p| p.spell_id == spell_id) { + self.0 = None; + } + } +} + +/// Our own queued **on-next-swing** spell (Heroic Strike, Cleave — `Attributes & 0x404`) — the +/// melee-slot half of the client's cast tracking. In the reference the queued spell *occupies* +/// the inflight id (`0xceca88`) until the swing fires it, and the already-casting refusal at +/// `6e4d97` exempts new casts because the inflight rec has the `0x404` bits — so a queued Heroic +/// Strike never blocks Rend, and the new cast nests around it (`PushPopNestedCast 0x6e4ad0` +/// restores the queued id when the nested cast resolves; wow-re `wave-cast.md`). We model the +/// same observable with a second slot instead of the push/pop pair: [`PendingCast`] keeps +/// ordinary casts, this keeps the melee queue, and the checked ring reads both (the ref's +/// `IsCurrentAction` C2 leg — spell == inflight — holds through the nesting either way, minus +/// the ref's one-RTT un-check blink while the nested cast displaces the slot). +/// +/// **Deadline-less, wire-cleared** — like the ref's inflight id, which no timer touches. The +/// clear set is deliberately wider than the ref's: the §5 (`combat-feel-law.md` @ c445713b) +/// pinned that the ref clears inflight/saved ONLY on a matching `SMSG_CAST_RESULT` (`0x6e7330`; +/// the GO handler never touches `0xceca88`) — but vmangos never sends an OK `CAST_RESULT` at +/// all, so on our wire the resolution is `SMSG_SPELL_GO` when the swing fires the strike, and a +/// failing `CAST_RESULT` + `SPELL_FAILED_OTHER` when it dies (vmangos `Spell::cancel` on the +/// PREPARING melee slot sends both — target death, manual cancel, replacement). We clear on any +/// of the three, id-keyed: identical observable where `0x130` does arrive, correct where it +/// never does. Re-arming replaces silently: the server holds a single `CURRENT_MELEE_SPELL` +/// slot. Re-pressing the queued spell itself is the ref's silent same-spell bail — §5-CONFIRMED +/// (`6e4d43`: debug-log, `xor al,al`, no CMSG, no error): 1.12 has no re-press-to-unqueue. The +/// real un-queue is the StopAttack chain (`0x5ecac0` → `CancelQueuedCast 0x6e6f30`, sending +/// `CMSG_ATTACKSTOP` + `CMSG_CANCEL_CAST`), reached by /stopattack, the Attack-button toggle, +/// **an auto-repeat press** (`0x6e5976`), target change/death, mount, interact — never movement. +/// That chain is [`crate::creature_anim::stop_attack_local`], and the auto-repeat arm inside +/// [`crate::ui_action::cast_send`]'s commit runs it whole: starting Auto Shot un-queues the +/// strike, which is what darkens its checked ring. Every attack edge reaches that seam since +/// 1044 — the Attack toggle, a target switch, a click-off, the ring's death teardown. +/// +/// **Escape is the other route, and it is not that chain at all** (1049). In the reference a +/// queued strike simply *is* the inflight spell, so `Script::SpellStopCasting 0x6e6e80`'s plain +/// `IsCasting 0x6e3d30` branch cancels it like any cast — no melee-specific code anywhere in it. +/// [`Inflight`] is where our two slots are re-joined for that reader. +#[derive(Resource, Default)] +pub(crate) struct QueuedMeleeSpell(Option); + +impl QueuedMeleeSpell { + /// The queued spell's id, if one is waiting on the next swing — what the checked ring and + /// the re-press bail read. + pub(crate) fn current(&self) -> Option { + self.0 + } + + /// Queue a fresh on-next-swing send (replacing any prior queue — the server's single slot). + pub(crate) fn arm(&mut self, spell_id: u32) { + self.0 = Some(spell_id); + } + + /// Clear on the wire resolution (`SMSG_SPELL_GO` / a failing `SMSG_CAST_RESULT` / + /// `SMSG_SPELL_FAILED_OTHER`), spell-id-keyed like every reap. + pub(crate) fn clear_if(&mut self, spell_id: u32) { + if self.0 == Some(spell_id) { + self.0 = None; + } + } +} + +/// What the reference's single inflight spell id (`0xceca88`) holds — as our **two** slots see it. +/// +/// The client has one slot, with one save beneath it for the nesting. An ordinary cast and a +/// queued on-next-swing strike both live in `0xceca88`, which is exactly why `IsCasting 0x6e3d30` +/// — a bare `ceca88 != 0` — answers true for either, and why `AbortCast 0x6e4940` cancels +/// whichever is there without ever asking which kind it was. [`PendingCast`] and +/// [`QueuedMeleeSpell`] split that one slot in two (its doc says why), which leaves every reader +/// of *"what is in flight"* to re-join them — and the one reader that forgot was the Esc ladder, +/// which is the bug decision 1049 fixes. So the join gets a name, once, here. +/// +/// **The order is the reference's nesting, not a preference.** An ordinary cast started over a +/// queued strike is allowed through `TryCast`'s already-casting refusal precisely because the +/// inflight rec has `Attributes & 0x404` (`0x6e4d97`/`0x6e4dbe`), and it *pushes* +/// (`PushPopNestedCast 0x6e4ad0` @ `0x6e5026`): the cast becomes `0xceca88`, the strike the save +/// in `0xcecaa8`. `AbortCast` cancels the cast and pops the strike back up — so the cast dies on +/// the first Esc and the strike on the second. Reading `PendingCast` first reproduces that +/// without our needing the push/pop pair. +/// +/// One knowing divergence there, pinned by wow-re's `spell/scratch/esc-queued-strike.md` §Q(c) +/// and weighed in decision 1058: the ref's pop *reads* `0xcecaa8` and never writes it, so a +/// second Esc re-asserts the same strike locally and only the server echo converges it. We have +/// no separate save slot to go stale, so ours clears. The two agree everywhere it shows: in the +/// un-nested case the ref's pop reads a zero save and clears too, and in the nested case vmangos +/// drops the melee slot on press **1** regardless (`HandleCancelCastOpcode` is spellId-blind +/// about `CURRENT_MELEE_SPELL`), so both clients converge off that echo. Reproducing the +/// re-assert would mean modelling `0xcecaa8` as a third slot to hold a value that is stale by +/// construction, for a window one localhost round trip wide. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Inflight { + /// An ordinary cast — ours to send-guard, to paint a bar for, and to reap. + Cast(u32), + /// A queued on-next-swing strike waiting for the swing. No bar ever opened for it and it + /// holds no [`Casting`], so cancelling it is the wire message and the slot, nothing else. + Strike(u32), +} + +impl Inflight { + /// The spell id in the slot — what a cancel names on the wire, either way. + pub(crate) fn spell_id(self) -> u32 { + match self { + Self::Cast(id) | Self::Strike(id) => id, + } + } +} + +/// Resolve the reference's inflight slot from our two — see [`Inflight`] for the order and the +/// why. `started` is the self [`Casting`] spell id, which covers a cast that never armed the send +/// guard (an item use) exactly as the ref's one id covers it. +pub(crate) fn inflight( + pending: &PendingCast, + queued_melee: &QueuedMeleeSpell, + started: Option, + now: Instant, +) -> Option { + pending + .current(now) + .or(started) + .map(Inflight::Cast) + .or_else(|| queued_melee.current().map(Inflight::Strike)) +} + +/// Slack past the server-named channel end, so a lost/late `MSG_CHANNEL_UPDATE(0)` can't pin the +/// checked ring on forever — the natural end clears through the deadline instead. +const CHANNEL_SLACK: Duration = Duration::from_secs(2); + +/// Our own running channel — the app-side mirror of the client's current-channel id (`0xceac58`, +/// read live-gated by the channeling word: the §5's `IsCurrentAction` channel leg, +/// `action-button-state-api.md` §3 — a channeled spell's button stays checked while its channel +/// runs). Set at `MSG_CHANNEL_START`, refreshed by nonzero `MSG_CHANNEL_UPDATE`s, cleared by the +/// `UPDATE(0)` that ends both the natural finish and the interrupt. +#[derive(Resource, Default)] +pub(crate) struct ActiveChannel(Option<(u32, Instant)>); + +impl ActiveChannel { + /// The running channel's spell id, if one is live. + pub(crate) fn current(&self, now: Instant) -> Option { + self.0 + .filter(|&(_, until)| now < until) + .map(|(spell, _)| spell) + } + + /// `MSG_CHANNEL_START`: open the window for the named duration (+ slack). + pub(crate) fn start(&mut self, spell_id: u32, duration_ms: u32, now: Instant) { + self.0 = Some(( + spell_id, + now + Duration::from_millis(u64::from(duration_ms)) + CHANNEL_SLACK, + )); + } + + /// `MSG_CHANNEL_UPDATE`: `0` ends the channel; anything else re-times the window (pushback + /// shortens a channel, so the deadline moves in as well as out). + pub(crate) fn update(&mut self, remaining_ms: u32, now: Instant) { + if remaining_ms == 0 { + self.0 = None; + } else if let Some((_, until)) = &mut self.0 { + *until = now + Duration::from_millis(u64::from(remaining_ms)) + CHANNEL_SLACK; + } + } +} + +/// 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); + +/// **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 [`super::net`]'s +/// `cast_result`, drained through the one cast path by `ui_action`'s `drain_chain_casts`. +/// +/// It exists because the reference's chain runs *inside* `HandleCastResult 0x6e7330` +/// (`0x6e74aa call 0x6e5a90` → `TryCast`) and ours cannot: the net drain and +/// [`crate::ui_action::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); + +/// ── The local self-cancel (decisions 0256 open item 2 / 0444 / 0445): move/jump/Esc mid-cast +/// ends the cast **locally**, the same client tick — the app-side mirror of `AbortCast +/// 0x6e4940`. The whole trigger chain is **VERIFIED** (wow-re `move-selfcancel.md`, the +/// 2026-07-17 §5): the `Script::Move*` keybind handlers funnel into the shared dispatcher +/// `0x515090`, whose interrupt mask `0x10f0` = {forward, backward, strafe L/R, autorun-toggle} +/// — turn (`0x100/0x200`) and pitch (`0x400/0x800`) are OUTSIDE the mask and never cancel — +/// and `Script::Jump 0x513bd0` inlines the same gate. Both call `AbortCast(cl=0, dl=1, +/// reason=0x1c)`: event **0x152 SPELLCAST_STOP** (the silent close — `cl=0` selects it; +/// `0x153/0x154` need `cl!=0`) + `CMSG_CANCEL_CAST`. The server stays the safety net: vmangos's +/// own movement interrupt (`Spell::update`'s 0.5-yd position delta) still kills anything we +/// miss. The per-spell gates, byte-verified at the call sites (`f6 46 54 01` @`0x51511e`, +/// `f6 42 5c 08` @`0x515175`; identities corroborated by vmangos `SpellDefines.h`): +/// (Also the entry test of the cast-INITIATION moving gate — `ui_action::state`'s +/// `cast_moving_refusal`, decision 0862 — which is a different mechanism from this self-cancel.) +pub(crate) const SPELL_INTERRUPT_MOVEMENT: u32 = 0x1; // SpellRec+0x54 InterruptFlags (SPELL_INTERRUPT_FLAG_MOVEMENT) +const CHANNEL_INTERRUPT_MOVING: u32 = 0x8; // SpellRec+0x5c ChannelInterruptFlags (AURA_INTERRUPT_MOVING_CANCELS) + +/// The controller's report that a cancel-worthy local move edge happened this frame — a +/// *directional* start (forward/back/strafe: our wire-axis mirror of the verified `0x10f0` +/// mask's flags) or a jump takeoff. The mask's fifth member, the autorun toggle, is dormant +/// until benilla grows an autorun (0445). Written by `player::control` at the same spot the +/// wire-axis transitions are computed, consumed (and cleared) by [`local_self_cancel`] next +/// frame — one engine frame (~16 ms) from key to bar-kill, vs the server round trip (~150 ms+: +/// the 0.5-yd delta has to accumulate before vmangos even notices). +#[derive(Resource, Default)] +pub(crate) struct LocalMoveStart(pub(crate) bool); + +/// Move/jump/Esc mid-cast: run the client's local cancel. One system for both triggers so the +/// resolution can't drift between them: +/// +/// - **the auto-repeat half (Esc only)** — the ref's branch order inside +/// `Script::SpellStopCasting 0x6e6e80` (§5-verified whole, wow-re `esc-stopcasting.md`): a +/// running auto-repeat dies FIRST — [`crate::creature_anim::cancel_auto_repeat_local`], the +/// byte-verified `0x6ea080` (key + idle gates + nocked ammo + `CMSG_CANCEL_AUTO_REPEAT_SPELL`) +/// — and SPENDS the press: one press stops one thing, the cast survives to the next. +/// Movement never touches auto-repeat. +/// - **the cast half** — clear [`PendingCast`], ship `CMSG_CANCEL_CAST`, reap the self +/// `Casting` state + fire the `Fail` cast event (the anim/visual teardown `AbortCast` does via +/// its stop-cast-anim tail), and push the **`Stop` + `Interrupted`** bar edges — the ref's +/// own two-step (its local STOP, then the echo's red repaint) at RTT→0: red "Interrupted", +/// held 1 s, the armed flash bursting white, then the fade (the transcribed `CastingBar.xml` +/// machinery runs the rest). Esc bypasses the movement flags gate (the ref's +/// `SpellStopCasting → AbortCast` has no `InterruptFlags` test — the gate belongs to the +/// movement path alone). +/// - **the channel half — MOVEMENT-ONLY** — ship `CMSG_CANCEL_CHANNELLING` and NOTHING else: +/// the verified asymmetry (0445). Esc never reaches a channel — `0x6e6e80`'s whole callee +/// closure never calls the channel canceler `0x6e9b70`, and its inflight gate (`0xceca88`) +/// is already 0 mid-channel (the launch `CAST_RESULT(OKAY)` clears it at `0x6e7408`): the +/// vanilla "/stopcasting can't stop a channel" quirk, kept faithfully (0454; wow-re +/// `esc-stopcasting.md`). The ref's `0x6e9b70` fires no FrameScript event and clears no +/// local state — the channel bar closes on the server's `SMSG_CHANNEL_UPDATE(0)` +/// (`0x6e75f0`), which also clears [`ActiveChannel`] through the normal wire path (its slack +/// deadline self-heals a lost packet). +/// +/// Why `Interrupted` when the client's own event is VERIFIED as the silent `0x152` STOP (every +/// self-cancel caller passes `cl=0`)? Because the REF'S NET OBSERVABLE is the red bar — the +/// director's falsifier, decision 0449, now pinned end to end (0454): vmangos `Spell::cancel` +/// answers the cancel with `SMSG_SPELL_FAILED_OTHER` and then a failing `SMSG_CAST_RESULT` +/// reason 0x23 (`SPELL_FAILED_INTERRUPTED`, counted against the 1.12.1 build guards), the +/// client routes a failing result through `HandleCastFailed 0x6e1a00`, and reasons +/// {0x23,0x24} select FrameScript **0x154 SPELLCAST_INTERRUPTED** — red, "Interrupted" +/// (GlobalStrings-exact), `holdTime = now + 1 s`. Our bar-edge producers are KEYED to the +/// self `Casting` (the 0137/0256 law that stops a proc's failure repainting the bar), and +/// this cancel's reap removes it — so the echo can't do the repainting here. The local +/// `Stop` + `Interrupted` pair replays the ref's exact sequence at RTT→0 through our keyed +/// architecture; the reap then keeps the echo idempotent (re-clears open guards, shows the +/// red error line, touches no bar). On-screen: the 1 s hold + ~25 bar ticks of flash+fade — +/// ~1.9 s in the reference bottle (whose `Config.wtf` caps `maxfps` at 30, and the ref steps +/// alpha PER TICK), the director's observed "2–3 s"; our transcription normalizes those steps +/// to the same 30 Hz reference tick so the tail matches at any render rate (`CastingBar.xml`). +pub(super) fn local_self_cancel( + script: Option>, + mut moved: ResMut, + mut pending: ResMut, + mut queued_melee: ResMut, + channel: Res, + mut auto_repeat: ResMut, + mut feed: ResMut, + spells: Option>, + net: Res, + self_guid: Res, + index: Res, + casting: Query<&Casting>, + mut cast_events: MessageWriter, + mut play_seq: ResMut, + mut ecs: Commands, +) { + let esc = match script { + Some(mut s) => s.take_spell_stop(), + None => false, + }; + let moved = std::mem::take(&mut moved.0); + if !esc && !moved { + return; + } + let now = Instant::now(); + let self_e = self_guid.0.as_ref().and_then(|g| index.0.get(g)).copied(); + // The Esc press stops ONE thing, auto-repeat first — the ref's branch order (`0x6e6e80`). + let esc = if esc && auto_repeat.0.is_some() { + if *crate::net::CAST_TRACE { + info!("cast-trace: LOCAL auto-repeat self-cancel (esc), CMSG_CANCEL_AUTO_REPEAT_SPELL"); + } + crate::creature_anim::cancel_auto_repeat_local(self_e, &mut auto_repeat, &mut ecs, &net); + false // the press is spent + } else { + esc + }; + if !esc && !moved { + return; + } + let flags_open = |pick: fn(&benilla_formats::SpellDisplay) -> bool, spell_id: u32| { + // An uncataloged spell cancels: the gate exists to spare the rare cast-while-moving + // spell, and failing open matches the server's own verdict for everything ordinary. + spells + .as_ref() + .and_then(|s| s.catalog.get(spell_id)) + .is_none_or(pick) + }; + // The cast half — the ref's ONE inflight slot ([`Inflight`]), which is an ordinary cast or a + // queued on-next-swing strike. `AbortCast` doesn't distinguish, so neither does the send or + // the movement gate; only the local teardown differs, because only a cast ever had a bar. + let started = self_e.and_then(|e| casting.get(e).ok()).map(|c| c.spell_id); + if let Some(slot) = inflight(&pending, &queued_melee, started, now) { + let spell_id = slot.spell_id(); + // Esc bypasses the flags gate; movement consults the slot's own `InterruptFlags`, which + // is what spares a queued strike (Heroic Strike 78, Cleave 845, Raptor Strike 2973 all + // ship `InterruptFlags = 0x0` — verified against Spell.dbc, vs Fireball's `0xf`). So + // charging in with a strike queued keeps it queued, and only Esc takes it. + if esc + || flags_open( + |d| d.interrupt_flags & SPELL_INTERRUPT_MOVEMENT != 0, + spell_id, + ) + { + if *crate::net::CAST_TRACE { + info!( + "cast-trace: LOCAL self-cancel — {slot:?} ({}), CMSG_CANCEL_CAST", + if esc { "esc" } else { "moved" } + ); + } + let _ = net.0.send(ClientCommand::CancelCast { spell_id }); + match slot { + Inflight::Cast(_) => { + pending.clear_if(spell_id); + // The ref's two-step at RTT→0: STOP arms the bar's flash overlay, the echo's + // INTERRUPTED repaints it red and starts the hold — so after the hold the + // flash BURSTS white over the red bar, then the fade runs. One edge alone + // misses the burst (which the enemy-interrupt path — no prior STOP — + // correctly lacks). + feed.0.push(CastBarEdge::Stop); + feed.0.push(CastBarEdge::Interrupted); + // The reap — `spell_failed_other`'s self arm, run early so the echo finds it + // done. + if let Some(e) = self_e { + if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) { + ecs.entity(e).remove::(); + } + cast_events.write(CastEvent { + entity: e, + spell_id, + kind: CastEventKind::Fail, + seq: play_seq.next(), + }); + } + } + Inflight::Strike(_) => { + // Clearing the slot IS the un-toggle: the checked ring reads it + // (`ui_action::state`), so the button darkens this frame instead of waiting + // for vmangos's FAILED_OTHER + CAST_RESULT echo (which then finds the slot + // already open and is idempotent). `AbortCast` fires its `0x152` + // SPELLCAST_STOP here too — inert on a bar that never opened + // (`CastingBar.xml` guards every arm on `IsShown`), so it is fired for + // fidelity, not effect. No red INTERRUPTED: there is nothing to repaint. + queued_melee.clear_if(spell_id); + feed.0.push(CastBarEdge::Stop); + } + } + } + } + // The channel half — MOVEMENT-ONLY (Esc can't stop a channel: the doc above / 0454), and + // the send and nothing else (the verified asymmetry: the ref's `0x6e9b70` fires no event + // and clears no state; the bar and [`ActiveChannel`] both close on the server's + // `SMSG_CHANNEL_UPDATE(0)`, ~one localhost frame later). + if moved { + if let Some(spell_id) = channel.current(now) { + if flags_open( + |d| d.channel_interrupt_flags & CHANNEL_INTERRUPT_MOVING != 0, + spell_id, + ) { + if *crate::net::CAST_TRACE { + info!("cast-trace: LOCAL channel self-cancel — spell {spell_id}, CMSG_CANCEL_CHANNELLING"); + } + let _ = net.0.send(ClientCommand::CancelChannelling { spell_id }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIREBALL: u32 = 133; + + /// The suppressed channel still arms [`ActiveChannel`], so its action button stays lit: the + /// reference's channel mirror is `SPELLCAST+0x10` (`0xceac58`), written at the cast SEND + /// (`0x6e4f88`) and never touched by `0x6e7550`. That is why all four of the handler's gates + /// live in the DRAIN and none of them in `super::net`'s `channel_start`, which arms this. + #[test] + fn suppressing_the_channel_bar_does_not_clear_the_channel_mirror() { + let t0 = Instant::now(); + // What `MSG_CHANNEL_START` does for Blood Siphon 24322 — the suppressed row. + let mut channel = ActiveChannel::default(); + channel.start(24322, 8_000, t0); + assert_eq!( + channel.current(t0 + Duration::from_secs(1)), + Some(24322), + "the wire edge arms the mirror regardless of what the bar does with it" + ); + } + + #[test] + fn a_fresh_guard_is_open() { + let g = PendingCast::default(); + assert!( + !g.in_flight(Instant::now()), + "nothing sent yet — casting allowed" + ); + } + + #[test] + fn arming_closes_the_guard_and_a_matching_resolution_opens_it() { + let t0 = Instant::now(); + let mut g = PendingCast::default(); + g.arm(FIREBALL, t0, true); + assert!( + g.in_flight(t0), + "a cast is in flight — a second one is refused" + ); + g.clear_if(FIREBALL); // SMSG_SPELL_GO / a failing SMSG_CAST_RESULT for our cast + assert!( + !g.in_flight(t0), + "our cast resolved — casting allowed again" + ); + } + + #[test] + fn a_different_spells_resolution_leaves_the_guard_closed() { + let t0 = Instant::now(); + let mut g = PendingCast::default(); + g.arm(FIREBALL, t0, true); + g.clear_if(FIREBALL + 1); // a triggered proc's GO for some other spell mid-cast + assert!( + g.in_flight(t0), + "a different spell's resolution must not open the guard on our cast" + ); + } + + #[test] + fn the_guard_opens_when_its_safety_deadline_lapses() { + // The net that saves us if a cast resolves with no packet (a local move-cancel we don't + // model): the guard cannot wedge casting shut forever. + let t0 = Instant::now(); + let mut g = PendingCast::default(); + g.arm(FIREBALL, t0, true); + assert!(g.in_flight(t0 + SEND_PROVISIONAL - Duration::from_secs(1))); + assert!(!g.in_flight(t0 + SEND_PROVISIONAL + Duration::from_secs(1))); + } + + #[test] + fn a_pushback_extends_the_guard_so_it_holds_past_the_stretched_cast() { + let t0 = Instant::now(); + let mut g = PendingCast::default(); + g.arm(FIREBALL, t0, true); + g.refine(1_000, t0); // a 1 s cast: deadline t0 + 1s + 2s slack = t0 + 3s + g.delay(500, t0); // a hit at t0 pushes it +0.5s ⇒ t0 + 3.5s + assert!( + g.in_flight(t0 + Duration::from_millis(3_200)), + "the pushback kept the guard shut past the original refined deadline" + ); + } + + #[test] + fn spell_start_tightens_the_deadline_to_the_real_cast_time() { + let t0 = Instant::now(); + let mut g = PendingCast::default(); + g.arm(FIREBALL, t0, true); // provisional 5s window + g.refine(1_500, t0); // SMSG_SPELL_START: a 1.5s cast (+ 2s pushback slack ⇒ 3.5s) + let at_four = t0 + Duration::from_secs(4); + assert!( + !g.in_flight(at_four), + "refined to 1.5s + slack, the guard has opened by t+4s (it would still be shut at the \ + 5s provisional)" + ); + } + + /// The queued on-next-swing slot: deadline-less (no clock in its API at all), keyed clears + /// only, and a re-arm replaces — the server's single `CURRENT_MELEE_SPELL` slot. + #[test] + fn queued_melee_spell_is_wire_cleared_and_single_slot() { + const HEROIC_STRIKE: u32 = 78; + const CLEAVE: u32 = 845; + let mut q = QueuedMeleeSpell::default(); + assert_eq!(q.current(), None); + + q.arm(HEROIC_STRIKE); + assert_eq!(q.current(), Some(HEROIC_STRIKE), "queued and checked"); + + // A different spell's resolution (Rend's GO while HS waits) must not open the queue. + q.clear_if(772); + assert_eq!(q.current(), Some(HEROIC_STRIKE)); + + // A second on-next-swing cast replaces the queue (vmangos holds ONE melee slot). + q.arm(CLEAVE); + assert_eq!(q.current(), Some(CLEAVE)); + + // The swing fired it (SPELL_GO) — or the slot died (failing CAST_RESULT / + // SPELL_FAILED_OTHER): the matching id clears. + q.clear_if(CLEAVE); + assert_eq!(q.current(), None, "the wire resolution opened the queue"); + } + + /// The local self-cancel harness: a mini-App with the real [`local_self_cancel`] system, a + /// self player mid-cast/mid-channel, and the net channel's receive half to assert the wire. + /// The harness registers no `UiScript`, so the ESC trigger reads `false` by default; the + /// ESC-leg tests insert a real VM and drive the actual `SpellStopCasting()` binding (the + /// ladder's 1/nil semantics are pinned Lua-side in `ui_script::escape_tests`). + mod local_cancel { + use super::*; + use crate::creature_anim::PlaySeq; + use crate::net::{Guid, GuidIndex, NetCommands, SelfGuid, SelfPlayer}; + use crate::ui_cast::feed_cast_bar; + use benilla_formats::{SpellCatalog, SpellDisplay}; + use bevy::ecs::system::RunSystemOnce; + use std::collections::HashMap; + + const ARCANE_MISSILES: u32 = 5143; + const HEROIC_STRIKE: u32 = 78; + + fn harness( + displays: HashMap, + ) -> (App, crossbeam_channel::Receiver) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_message::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(displays), + ..crate::ui_action::Spells::empty_for_tests() + }); + let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(10, self_e); + app.world_mut().resource_mut::().0 = Some(10); + (app, rx) + } + + /// Mark the self player mid-cast on `spell_id` (the `SMSG_SPELL_START` echo's `Casting`). + fn mark_casting(app: &mut App, spell_id: u32) { + let self_e = app.world().resource::().0[&10]; + app.world_mut().entity_mut(self_e).insert(Casting { + spell_id, + until: None, + }); + } + + fn run(app: &mut App, moved: bool) { + app.world_mut().resource_mut::().0 = moved; + app.world_mut().run_system_once(local_self_cancel).unwrap(); + } + + /// The headline feel fix: a directional start / jump mid-cast kills the cast locally — + /// wire cancel out, guard open, the STOP + INTERRUPTED bar-edge pair (the ref's own + /// two-step: its silent 0x152 STOP arms the flash, the server echo's repaint holds the + /// red "Interrupted"; our keyed reap suppresses the real echo, so the local pair + /// replays the sequence at RTT→0 — decisions 0449/0454), `Casting` reaped — instead of + /// waiting ~150 ms+ for the server's 0.5-yd position-delta interrupt. + #[test] + fn a_move_edge_mid_cast_cancels_locally() { + let (mut app, rx) = harness(HashMap::from([( + FIREBALL, + SpellDisplay { + interrupt_flags: 0xf, // the ordinary timed-cast mask (movement bit set) + ..Default::default() + }, + )])); + app.world_mut() + .resource_mut::() + .arm(FIREBALL, Instant::now(), true); + mark_casting(&mut app, FIREBALL); + + run(&mut app, true); + + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) + ), + "the move edge ships CMSG_CANCEL_CAST for the in-flight cast" + ); + assert!( + !app.world() + .resource::() + .in_flight(Instant::now()), + "the in-flight guard opens with the local cancel" + ); + let feed = &app.world().resource::().0; + assert_eq!(feed.len(), 2); + assert!( + matches!( + (&feed[0], &feed[1]), + (CastBarEdge::Stop, CastBarEdge::Interrupted) + ), + "the bar edges are the ref's own two-step at RTT→0 — STOP (arms the flash \ + overlay) then the red INTERRUPTED (the echo's repaint: hold, burst, fade); \ + the keyed reap silences the real echo (0449/0454)" + ); + let self_e = app.world().resource::().0[&10]; + assert!( + app.world().entity(self_e).get::().is_none(), + "the self `Casting` state is reaped, so the server's echo (FAILED_OTHER + \ + CAST_RESULT) finds nothing to repaint" + ); + assert!( + !app.world().resource::().0, + "the edge is consumed" + ); + } + + /// The per-spell gate (VERIFIED — `test [SpellRec+0x54],1` at both call sites): a spell + /// whose `InterruptFlags` lacks the movement bit keeps casting while we move — no wire + /// cancel, no bar edge. + #[test] + fn the_interrupt_flags_gate_spares_a_movement_castable_spell() { + let (mut app, rx) = harness(HashMap::from([( + FIREBALL, + SpellDisplay { + interrupt_flags: 0xe, // movement bit CLEAR (pushback/stun/combat only) + ..Default::default() + }, + )])); + app.world_mut() + .resource_mut::() + .arm(FIREBALL, Instant::now(), true); + + run(&mut app, true); + + assert!(rx.try_recv().is_err(), "no wire cancel for a spared spell"); + assert!( + app.world() + .resource::() + .in_flight(Instant::now()), + "the guard keeps holding — the cast runs on" + ); + assert!(app.world().resource::().0.is_empty()); + } + + /// A spell the catalog doesn't know fails OPEN (cancels): the gate exists to spare the + /// rare cast-while-moving spell, and the server's own verdict for everything ordinary is + /// to interrupt. + #[test] + fn an_uncataloged_spell_fails_open() { + let (mut app, rx) = harness(HashMap::new()); + app.world_mut() + .resource_mut::() + .arm(FIREBALL, Instant::now(), true); + + run(&mut app, true); + + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) + ), + "an unknown spell's cast still cancels on movement" + ); + } + + /// The union's other half: an ITEM use's cast (a hearthstone) never arms the send + /// guard — the started self `Casting` (the `SMSG_SPELL_START` echo) alone makes it + /// move-cancelable, mirroring the ref's ONE inflight id (`0xceca88`), which item casts + /// set like any spell cast. + #[test] + fn a_started_item_cast_cancels_without_the_send_guard() { + let (mut app, rx) = harness(HashMap::from([( + FIREBALL, + SpellDisplay { + interrupt_flags: 0xf, + ..Default::default() + }, + )])); + mark_casting(&mut app, FIREBALL); // PendingCast stays empty — item casts never arm it + + run(&mut app, true); + + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) + ), + "the started cast cancels through the `Casting` half of the union" + ); + let self_e = app.world().resource::().0[&10]; + assert!(app.world().entity(self_e).get::().is_none()); + } + + /// No move edge, cast in flight: nothing happens — the system is edge-triggered, never + /// level-triggered (holding W through a whole cast is one cancel at the start, not one + /// per frame; and standing still cancels nothing). + #[test] + fn no_edge_means_no_cancel() { + let (mut app, rx) = harness(HashMap::from([( + FIREBALL, + SpellDisplay { + interrupt_flags: 0xf, + ..Default::default() + }, + )])); + app.world_mut() + .resource_mut::() + .arm(FIREBALL, Instant::now(), true); + + run(&mut app, false); + + assert!(rx.try_recv().is_err()); + assert!(app + .world() + .resource::() + .in_flight(Instant::now())); + assert!(app.world().resource::().0.is_empty()); + } + + /// Esc CANNOT stop a channel — the §5-verified `0x6e6e80` never reaches the channel + /// canceler `0x6e9b70`, and its inflight gate is already 0 mid-channel (wow-re + /// `esc-stopcasting.md`, 0454): the mirror answers `SpellStopCasting()` nil (the + /// ladder falls through to the next rung) and the drain ships nothing. + #[test] + fn esc_cannot_stop_a_channel() { + let (mut app, rx) = harness(HashMap::new()); + app.insert_non_send_resource(UiScript::new().unwrap()); + let now = Instant::now(); + app.world_mut() + .resource_mut::() + .start(ARCANE_MISSILES, 5_000, now); + + app.world_mut().run_system_once(feed_cast_bar).unwrap(); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return SpellStopCasting()") + .unwrap(), + None, + "mid-channel the binding answers nil — the vanilla /stopcasting quirk" + ); + app.world_mut().run_system_once(local_self_cancel).unwrap(); + assert!( + rx.try_recv().is_err(), + "no wire cancel — channels break only on the movement path" + ); + assert_eq!( + app.world().resource::().current(now), + Some(ARCANE_MISSILES) + ); + } + + /// The ref's branch order inside `SpellStopCasting` (`0x6e6e80`): a running auto-repeat + /// dies FIRST — `CMSG_CANCEL_AUTO_REPEAT_SPELL`, no bar edge — and SPENDS the press; + /// the in-flight cast survives to the next one. One press stops one thing. + #[test] + fn esc_cancels_the_auto_repeat_first_and_the_cast_on_the_next_press() { + const AUTO_SHOT: u32 = 75; + let (mut app, rx) = harness(HashMap::new()); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.world_mut().resource_mut::().0 = Some(AUTO_SHOT); + app.world_mut() + .resource_mut::() + .arm(FIREBALL, Instant::now(), true); + mark_casting(&mut app, FIREBALL); + + // Press 1: truthy mirror (auto-repeat ∪ cast) → trigger queued → the drain stops + // the auto-repeat ONLY. + app.world_mut().run_system_once(feed_cast_bar).unwrap(); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return SpellStopCasting()") + .unwrap(), + Some(1) + ); + app.world_mut().run_system_once(local_self_cancel).unwrap(); + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelAutoRepeat) + ), + "the auto-repeat cancel ships first — the ref's priority branch" + ); + assert!(rx.try_recv().is_err(), "the cast survives the first press"); + assert!(app.world().resource::().0.is_none()); + assert!( + app.world() + .resource::() + .in_flight(Instant::now()), + "the send guard still holds — one press stopped one thing" + ); + assert!( + app.world().resource::().0.is_empty(), + "an auto-repeat cancel paints no bar edge" + ); + + // Press 2: the auto-repeat is gone — now the cast dies, the ordinary Esc cancel. + app.world_mut().run_system_once(feed_cast_bar).unwrap(); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return SpellStopCasting()") + .unwrap(), + Some(1) + ); + app.world_mut().run_system_once(local_self_cancel).unwrap(); + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) + ), + "the second press reaches the cast half" + ); + } + + /// **The director's report, decision 1049: Esc with Heroic Strike queued un-toggles the + /// STRIKE and keeps the target.** In the reference a queued on-next-swing spell simply + /// *is* the inflight spell (`0xceca88`), so `SpellStopCasting 0x6e6e80` takes its plain + /// `IsCasting 0x6e3d30` branch — `AbortCast` → `CMSG_CANCEL_CAST` — and returns `1`, + /// which EATS the press so `ToggleGameMenu`'s ladder never reaches `ClearTarget()`. We + /// split that one slot in two and the Esc reader saw only the cast half, so the binding + /// answered nil: the strike stayed lit *and* the target dropped, both from one cause. + #[test] + fn esc_unqueues_a_strike_and_eats_the_press() { + let (mut app, rx) = harness(HashMap::from([( + HEROIC_STRIKE, + SpellDisplay { + attributes: 0x5_0014, // the shipped row: on-next-swing (`& 0x404`) + interrupt_flags: 0, // …and no movement bit, also shipped + ..Default::default() + }, + )])); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.world_mut() + .resource_mut::() + .arm(HEROIC_STRIKE); + + app.world_mut().run_system_once(feed_cast_bar).unwrap(); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return SpellStopCasting()") + .unwrap(), + Some(1), + "the queued strike makes IsCasting true, so the binding EATS the press — which \ + is what spares the target three rungs further down the ladder" + ); + app.world_mut().run_system_once(local_self_cancel).unwrap(); + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { + spell_id: HEROIC_STRIKE + }) + ), + "AbortCast names the queued strike on the wire, exactly as it would a cast" + ); + assert_eq!( + app.world().resource::().current(), + None, + "the slot opens locally — the checked ring darkens this frame, not one RTT later" + ); + assert!( + matches!( + app.world().resource::().0[..], + [CastBarEdge::Stop] + ), + "the ref's `0x152` STOP and nothing else: no red INTERRUPTED for a spell that \ + never opened a bar" + ); + } + + /// …but MOVEMENT does not take it. The movement path consults the inflight spell's own + /// `InterruptFlags`, and Heroic Strike 78 / Cleave 845 / Raptor Strike 2973 all ship + /// `InterruptFlags = 0x0` (read off the shipped `Spell.dbc`; Fireball is `0xf`). So + /// charging in with a strike queued keeps it queued — Esc is the only local taker. + #[test] + fn a_move_edge_leaves_a_queued_strike_queued() { + let (mut app, rx) = harness(HashMap::from([( + HEROIC_STRIKE, + SpellDisplay { + interrupt_flags: 0, + ..Default::default() + }, + )])); + app.world_mut() + .resource_mut::() + .arm(HEROIC_STRIKE); + + run(&mut app, true); + + assert!(rx.try_recv().is_err(), "running at the mob cancels nothing"); + assert_eq!( + app.world().resource::().current(), + Some(HEROIC_STRIKE) + ); + } + + /// The nesting order (`Inflight`'s doc): an ordinary cast started over a queued strike + /// pushes it down (`PushPopNestedCast 0x6e4ad0`), so `0xceca88` holds the CAST. One Esc + /// takes the cast; `AbortCast`'s pop restores the strike, and the next Esc takes that. + /// One press, one thing — the ladder's law all the way down. + /// + /// This is the **local** law, which is all a harness with no server can show, and it is + /// the ref's local law too (wow-re `esc-queued-strike.md` §Q(c)) — with the one knowing + /// difference `Inflight`'s doc names: the ref's press 2 re-asserts the strike instead of + /// clearing it. On a live wire neither client gets this far, because vmangos drops the + /// melee slot on press **1** regardless of the id in the packet, and both converge off + /// that echo. So what this pins is the ordering, not a two-press ritual a player sees. + #[test] + fn esc_takes_the_cast_first_and_the_strike_on_the_next_press() { + let (mut app, rx) = harness(HashMap::new()); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.world_mut() + .resource_mut::() + .arm(HEROIC_STRIKE); + app.world_mut() + .resource_mut::() + .arm(FIREBALL, Instant::now(), true); + mark_casting(&mut app, FIREBALL); + + app.world_mut().run_system_once(feed_cast_bar).unwrap(); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return SpellStopCasting()") + .unwrap(), + Some(1) + ); + app.world_mut().run_system_once(local_self_cancel).unwrap(); + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) + ), + "the nested cast is what sits in the inflight slot — it dies first" + ); + assert!(rx.try_recv().is_err()); + assert_eq!( + app.world().resource::().current(), + Some(HEROIC_STRIKE), + "the strike is the SAVE underneath, untouched by the first press" + ); + + // Press 2 — the pop put the strike back in the slot. + app.world_mut().run_system_once(feed_cast_bar).unwrap(); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return SpellStopCasting()") + .unwrap(), + Some(1), + "still stoppable — so this press is eaten too, and the target still survives" + ); + app.world_mut().run_system_once(local_self_cancel).unwrap(); + assert!(matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelCast { + spell_id: HEROIC_STRIKE + }) + )); + assert_eq!(app.world().resource::().current(), None); + } + + /// The channel half is the SEND and nothing else — the verified asymmetry (0445): the + /// ref's `0x6e9b70` ships CMSG_CANCEL_CHANNELLING but fires no FrameScript event and + /// clears no state; the channel bar and the mirror both close on the server's + /// `SMSG_CHANNEL_UPDATE(0)` through the normal wire path. + #[test] + fn a_move_edge_mid_channel_sends_the_cancel_and_touches_nothing_local() { + let (mut app, rx) = harness(HashMap::from([( + ARCANE_MISSILES, + SpellDisplay { + channel_interrupt_flags: 0x7c0c, // the real 5143 mask (moving bit 0x8 set) + ..Default::default() + }, + )])); + let now = Instant::now(); + app.world_mut() + .resource_mut::() + .start(ARCANE_MISSILES, 5_000, now); + + run(&mut app, true); + + assert!( + matches!( + rx.try_recv(), + Ok(crate::net::ClientCommand::CancelChannelling { + spell_id: ARCANE_MISSILES + }) + ), + "the move edge ships CMSG_CANCEL_CHANNELLING" + ); + assert_eq!( + app.world().resource::().current(now), + Some(ARCANE_MISSILES), + "the mirror stays live — the server's UPDATE(0) is what clears it" + ); + assert!( + app.world().resource::().0.is_empty(), + "no local bar edge — the bar closes on the wire's SPELLCAST_CHANNEL_STOP" + ); + } + } + + /// The channel mirror's lifecycle: START opens the window for the whole duration, a nonzero + /// UPDATE re-times it (pushback shortens channels), UPDATE(0) ends it — and the slack deadline + /// self-clears a channel whose closing packet never arrived. + #[test] + fn active_channel_tracks_the_wire_lifecycle() { + let t0 = Instant::now(); + let mut ch = ActiveChannel::default(); + assert_eq!(ch.current(t0), None); + + ch.start(10, 5_000, t0); // Arcane Missiles-shaped: 5 s channel + assert_eq!(ch.current(t0), Some(10)); + assert_eq!( + ch.current(t0 + Duration::from_secs(4)), + Some(10), + "still live near the end" + ); + assert_eq!( + ch.current(t0 + Duration::from_secs(8)), + None, + "the slack deadline self-clears a lost UPDATE(0)" + ); + + // Pushback: an update naming LESS time moves the deadline in. + ch.start(10, 5_000, t0); + ch.update(1_000, t0); + assert_eq!(ch.current(t0 + Duration::from_secs(4)), None); + + // The closing update ends it immediately. + ch.start(10, 5_000, t0); + ch.update(0, t0); + assert_eq!(ch.current(t0), None); + } +} diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 5471c88b..f0f1fbfa 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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); } } diff --git a/crates/benilla-app/src/spell_mods.rs b/crates/benilla-app/src/spell/mods.rs similarity index 95% rename from crates/benilla-app/src/spell_mods.rs rename to crates/benilla-app/src/spell/mods.rs index cfb2cede..00d00938 100644 --- a/crates/benilla-app/src/spell_mods.rs +++ b/crates/benilla-app/src/spell/mods.rs @@ -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) { +pub(super) fn clear_on_world_enter(mut mods: ResMut) { mods.clear(); } @@ -299,7 +298,7 @@ fn clear_on_world_enter(mut mods: ResMut) { /// 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, classes: Option>, self_q: Query<&ObjectStore, With>, @@ -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::() - .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::() + .add_systems(OnEnter(ClientState::InWorld), clear_on_world_enter); { let mut mods = app.world_mut().resource_mut::(); diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 04b94f4f..45e01b10 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -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, 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, 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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::(); @@ -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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::(); 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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::(); 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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::(); @@ -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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::(); 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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::(); 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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() - .init_resource::(); + .init_resource::(); let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id(); app.world_mut() .resource_mut::() @@ -3018,7 +3019,7 @@ mod tests { mut queued_melee: ResMut, mut cooldowns: ResMut, mut auto_repeat: ResMut, - mut chain: ResMut| { + mut chain: ResMut| { let net = crate::net::NetCommands(tx.clone()); cast_result( spell_id, @@ -3044,11 +3045,7 @@ mod tests { }, ) .unwrap(); - let queued = app - .world() - .resource::() - .0 - .clone(); + let queued = app.world().resource::().0.clone(); let still_armed = app .world() .resource::() diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index d0d18a2c..3cc813ae 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -521,7 +521,7 @@ pub(super) fn follow_requests( group: Res, index: Res, stores: Query<&ObjectStore>, - cast: Res, + cast: Res, mut errors: ResMut, mut follow: ResMut, ) { diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 89fce85e..72090c5c 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -2175,8 +2175,8 @@ mod tests { let mut world = World::new(); world.insert_resource(NetCommands(tx)); world.init_resource::(); - world.init_resource::(); - world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -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::(); - world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::>(); world.init_resource::(); world.init_resource::(); @@ -2352,8 +2352,8 @@ mod tests { world.init_resource::>(); world.init_resource::(); world.init_resource::(); - world.init_resource::(); - world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::>(); world.init_resource::>(); world.init_resource::(); diff --git a/crates/benilla-app/src/target/reticle.rs b/crates/benilla-app/src/target/reticle.rs index 7a834077..d7962ae8 100644 --- a/crates/benilla-app/src/target/reticle.rs +++ b/crates/benilla-app/src/target/reticle.rs @@ -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; diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 9260fbf3..1d0e8e0b 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -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::(); - world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::>(); world.init_resource::>(); world.init_resource::(); @@ -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::() + .resource_mut::() .arm(78); - world.resource_mut::().0 = Some(75); + world.resource_mut::().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::() - .current(), + world.resource::().current(), None, "the switch un-queued Heroic Strike" ); assert_eq!( - world.resource::().0, + world.resource::().0, None, "and the re-swing killed Auto Shot" ); diff --git a/crates/benilla-app/src/ui_action/cast_send.rs b/crates/benilla-app/src/ui_action/cast_send.rs index 39bacc0f..8134dfb5 100644 --- a/crates/benilla-app/src/ui_action/cast_send.rs +++ b/crates/benilla-app/src/ui_action/cast_send.rs @@ -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, 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::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -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::(); + let pending = world.resource::(); 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::(); + let pending = world.resource::(); 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::() + .resource_mut::() .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::() - .current(), + world.resource::().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::() + .resource_mut::() .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::() - .current(), + world.resource::().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::() - .current(), + world.resource::().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::() - .start_item(6948, &use_spell, None, Instant::now()); + world.resource_mut::().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::().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::() - .start_item(0, &use_spell, None, Instant::now()); + world.resource_mut::().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::() // The lock word — the one that answers both the bag and the world seam. @@ -1482,7 +1483,7 @@ mod tests { ); assert!( world - .resource::() + .resource::() .in_flight(Instant::now()), "and arms the in-flight guard the click owes" ); diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index a8d6a5b7..2cefc908 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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, + mut queue: ResMut, targeting: cast_target::CastTargeting, mut ladder: CastLadder, ) { diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 1d82cd46..96b2900d 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -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); - -/// **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); - /// **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::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 088f7d33..88989cc3 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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, - Res, - Res, + Res, + Res, + Res, Res, Res, - Res, + Res, ), self_q: Query<(&ObjectStore, &Transform, Has, Option<&Casting>), With>, selection: Res, @@ -846,12 +847,12 @@ mod tests { app.insert_resource(actions) .insert_resource(bound) .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -951,12 +952,12 @@ mod tests { radii: Default::default(), }) .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_action/targeting/cursor.rs b/crates/benilla-app/src/ui_action/targeting/cursor.rs index 24f06b1b..9aa9c2e9 100644 --- a/crates/benilla-app/src/ui_action/targeting/cursor.rs +++ b/crates/benilla-app/src/ui_action/targeting/cursor.rs @@ -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, diff --git a/crates/benilla-app/src/ui_action/targeting/world.rs b/crates/benilla-app/src/ui_action/targeting/world.rs index 32aa41df..3b637a64 100644 --- a/crates/benilla-app/src/ui_action/targeting/world.rs +++ b/crates/benilla-app/src/ui_action/targeting/world.rs @@ -174,13 +174,13 @@ mod tests { let mut world = World::new(); world.insert_resource(NetCommands(tx)); world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); - world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::>(); diff --git a/crates/benilla-app/src/ui_action/usable.rs b/crates/benilla-app/src/ui_action/usable.rs index c6889b16..584644b1 100644 --- a/crates/benilla-app/src/ui_action/usable.rs +++ b/crates/benilla-app/src/ui_action/usable.rs @@ -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. diff --git a/crates/benilla-app/src/ui_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs index ce89f0fc..3f73dd53 100644 --- a/crates/benilla-app/src/ui_auction/mod.rs +++ b/crates/benilla-app/src/ui_auction/mod.rs @@ -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 { diff --git a/crates/benilla-app/src/ui_cast.rs b/crates/benilla-app/src/ui_cast.rs index c8b30405..2b934054 100644 --- a/crates/benilla-app/src/ui_cast.rs +++ b/crates/benilla-app/src/ui_cast.rs @@ -1,20 +1,26 @@ -//! The cast-bar feed: our own cast lifecycle, wire → FrameXML events (decision 0137 phase 1). +//! The cast-bar feed: the wire's cast edges → FrameXML events (decision 0137 phase 1). //! -//! The net bridge queues [`CastBarEdge`]s (self-casts only — the producers filter on the self -//! guid; the channel pair is self-only *on the wire*), and the drain fires the reference -//! client's FrameScript events into the script VM — `SPELLCAST_START` and family, the exact -//! contract stock `Interface\FrameXML\CastingBarFrame.xml` registers for. +//! The spell's packet handlers and its local cancel (`crate::spell`) queue [`CastBarEdge`]s +//! (self-casts only — the producers filter on the self guid; the channel pair is self-only *on +//! the wire*), and the drain fires the reference client's FrameScript events into the script VM +//! — `SPELLCAST_START` and family, the exact contract stock +//! `Interface\FrameXML\CastingBarFrame.xml` registers for — and pushes the VM's casting flag. //! The spell name rides the event (resolved here from the `Spell.dbc` catalog — the script VM //! has no spell-catalog binding, deliberately: one lookup face, decision 0107). +//! +//! This is a feed and nothing else. The lifecycle it reads — the pending cast, the queued +//! strike, the running channel, the auto-repeat key — is the spell's (`crate::spell`, decision +//! 2328). -use std::time::{Duration, Instant}; +use std::time::Instant; use bevy::prelude::*; use benilla_ui::script::{ScriptValue, UiScript}; -use crate::creature_anim::{CastEvent, CastEventKind, Casting, PlaySeq}; -use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfGuid}; +use crate::creature_anim::Casting; +use crate::net::{GuidIndex, SelfGuid}; +use crate::spell::{inflight, PendingCast, QueuedMeleeSpell}; use crate::ui_action::Spells; use crate::ui_unit::UnitFeed; @@ -44,516 +50,6 @@ pub(crate) enum CastBarEdge { #[derive(Resource, Default)] pub(crate) struct CastBarFeed(pub(crate) Vec); -/// Provisional in-flight window armed at send, before `SMSG_SPELL_START` names the real cast time. -/// It only has to outlast the send→START round trip; an instant, whose `SMSG_SPELL_GO` clears the -/// guard first, never reaches it. Generous on purpose — the resolution packets do the real clearing. -const SEND_PROVISIONAL: Duration = Duration::from_secs(5); - -/// The same provisional for an **item** use's cast ([`PendingCast::arm_item`]) — deliberately much -/// tighter, because the two arms have different worst cases *on this wire*. Every -/// `CMSG_CAST_SPELL` vmangos accepts is answered (`Spell::SendCastResult` on any rejection, -/// `SMSG_SPELL_START`/`GO` otherwise), so a spell's guard is always cleared by a packet and -/// [`SEND_PROVISIONAL`] never actually governs. `CMSG_USE_ITEM` has legs that answer with -/// `SMSG_INVENTORY_CHANGE_FAILURE` and **no cast result at all** — `HandleUseItemOpcode`'s -/// equip-error gates (`CanUseItem`, the in-combat non-combat-spell refusal, item-not-found) — -/// and there the provisional IS the safety net, so it has to be short enough that a silently -/// refused use cannot lock casting out for long. It only ever covers the send→`SMSG_SPELL_START` -/// window anyway: [`PendingCast::refine`] stretches the guard to the server's real cast time the -/// moment START names it, so a use that *does* become a cast is guarded for its whole length. -const ITEM_SEND_PROVISIONAL: Duration = Duration::from_millis(1_500); - -/// Pushback slack added to the server's cast time at `SMSG_SPELL_START`, so a cast the server -/// delays (`SMSG_SPELL_DELAYED`) still guards past its stretched end. -const CAST_SLACK: Duration = Duration::from_secs(2); - -/// Our own outstanding cast — the client's optimistic in-flight guard, and the fix for the -/// spam-cancel bug. wow-re `wave-cast.md`: `TryCast 0x6e4b60` refuses to send a second -/// `CMSG_CAST_SPELL` while `IsCasting 0x6e3d30` (the inflight-spell-id `0xceca88`) is set — the -/// same-spell press bails **silently** at `6e4d43`; a different spell hits the -/// `[SpellRec+0x18] & 0x404` gate at `6e4d97`: when the *inflight* rec lacks those bits (an -/// ordinary cast — Fireball is `0x10000`), it errors 0x61 without sending; when the inflight IS -/// on-next-swing (a queued Heroic Strike), the new cast passes and nests around it (mask and -/// operand §5-confirmed byte-exact, wow-re `combat-feel-law.md` @ c445713b — exactly `0x404`, on -/// the inflight rec, no channel leg: Slam mid-cast blocks Battle Shout). In our model -/// the on-next-swing class never occupies this guard at all — it arms [`QueuedMeleeSpell`] -/// instead — so this guard only ever holds ordinary casts and the gate needs no attribute test. -/// Ours was server-driven: every mashed key fired a *duplicate* cast, the -/// server rejected the dupe with `SMSG_CAST_RESULT` failure, and that turned the running cast's bar -/// red while the original completed later. This marker drops the duplicate at the source, exactly -/// as the client does. -/// -/// It is **optimistic** — armed the instant we send, not when the server's `SMSG_SPELL_START` -/// echoes back — because the spam lands during that round trip. Cleared by the cast's resolution -/// (`SMSG_SPELL_GO` / a failing `SMSG_CAST_RESULT` / `SMSG_SPELL_FAILED_OTHER`), spell-id-keyed like -/// the `Casting` reap — and by the local self-cancel directly ([`local_self_cancel`], the -/// client's `AbortCast 0x6e4940` mirrored since 0444). `deadline` is the safety net for a -/// resolution that goes missing on the wire: armed generously and tightened to the real cast -/// time once `SMSG_SPELL_START` names it. -#[derive(Resource, Default)] -pub(crate) struct PendingCast(Option); - -struct PendingCastState { - spell_id: u32, - deadline: Instant, - /// Does this record **guard** — i.e. does it refuse the next press, light the in-flight ring, - /// and gate the by-name walk? True for the classes benilla lets occupy the guard: ordinary - /// casts and item uses. - /// - /// The reference has no such flag, because it has no such split: `[0xceca88]` is written for - /// **every** committed cast (`0x6e5026 call 0x6e4ad0` inside TryCast) and the *gate* does the - /// discriminating instead, on the inflight rec's `Attributes & 0x404` at `6e4d97`. Ours grew - /// up the other way round — the guard is armed only for the classes it refuses on, so a - /// ranged shot was never recorded as committed **at all**. - /// - /// That gap is what shipped B280 broken twice: the `modalNextSpell` chain - /// (`crate::net::apply::spells::cast_result`) has to answer the reference's `0x6e7408 cmp - /// ecx,[0xceca88]` — "is this reply for the cast I just sent?" — and every hunter shot is - /// `Attributes & 0x2` ranged, so the answer was always no and the chain never fired in play. - /// The flag closes it without touching the three consumers that were right: the record is now - /// armed for every commit ([`PendingCast::committed`] reads it), and `guards` keeps - /// [`PendingCast::in_flight`] / [`PendingCast::current`] answering exactly what they did. - guards: bool, -} - -impl PendingCast { - /// Whether a **guarding** cast we sent is still outstanding (unresolved and inside its safety - /// deadline) — the refusal's own question. A ranged shot is committed but does not guard. - pub(crate) fn in_flight(&self, now: Instant) -> bool { - self.0 - .as_ref() - .is_some_and(|p| p.guards && now < p.deadline) - } - - /// **The reference's `[0xceca88]` proper** — the id of the cast we last committed and have not - /// yet had a resolution for, whatever its class. This is what `HandleCastResult 0x6e7330` - /// tests at `0x6e7408` before it reads `modalNextSpell`, and the only read that must see a - /// ranged shot. Everything else about the record is unchanged; see [`PendingCastState::guards`] - /// for why the two questions are separate fields here and one global there. - pub(crate) fn committed(&self, now: Instant) -> Option { - self.0 - .as_ref() - .filter(|p| now < p.deadline) - .map(|p| p.spell_id) - } - - /// The outstanding cast's spell id, if one is in flight — the app-side mirror of the client's - /// current-cast read (`0xceca88`), which `IsCurrentAction`'s checked state keys on (decision - /// 0137 phase 4). - pub(crate) fn current(&self, now: Instant) -> Option { - self.0 - .as_ref() - .filter(|p| p.guards && now < p.deadline) - .map(|p| p.spell_id) - } - - /// Arm on a fresh send — the optimistic `0xceca88` write at the client's cast-send. `guards` - /// says whether this class also occupies the refusal ([`PendingCastState::guards`]): ordinary - /// casts do, a ranged shot is recorded and does not. - pub(crate) fn arm(&mut self, spell_id: u32, now: Instant, guards: bool) { - self.0 = Some(PendingCastState { - spell_id, - deadline: now + SEND_PROVISIONAL, - guards, - }); - } - - /// Arm the guard on an **item** use's send. The reference has no separate item-cast slot: the - /// item-use dispatcher's cast tail (`CGItem::Use 0x5d8d00` @ `0x5d9258`) calls `0x6e5a90`, - /// whose entire 54-byte body is `call 0x6e4b60` — `TryCast` itself (VERIFIED at the bytes, - /// wow-re `disasm-full.txt`; corroborated by its `action-button-state-api.md` §"dispatcher - /// `0x6e5a90` (→ `CastSpell 0x6e4b60`)" and `cursor-system.md` §536). So an item use writes - /// the SAME inflight id (`0xceca88`) and is refused by the same IsCasting gate — which is why - /// [`crate::ui_items::send_item_use`] runs both. Differs from [`PendingCast::arm`] only in the - /// deadline ([`ITEM_SEND_PROVISIONAL`], whose doc is the why). - pub(crate) fn arm_item(&mut self, spell_id: u32, now: Instant) { - self.0 = Some(PendingCastState { - spell_id, - deadline: now + ITEM_SEND_PROVISIONAL, - guards: true, - }); - } - - /// Tighten the deadline to the server's real cast time once `SMSG_SPELL_START` names it. - pub(crate) fn refine(&mut self, cast_time_ms: u32, now: Instant) { - if let Some(p) = &mut self.0 { - p.deadline = now + Duration::from_millis(u64::from(cast_time_ms)) + CAST_SLACK; - } - } - - /// Push the deadline out by a pushback (`SMSG_SPELL_DELAYED`), so the guard keeps holding past - /// the stretched cast end. Extends from whichever is later (the current deadline or now), so a - /// deadline already lapsed from an under-estimate still re-arms. - pub(crate) fn delay(&mut self, delay_ms: u32, now: Instant) { - if let Some(p) = &mut self.0 { - p.deadline = p.deadline.max(now) + Duration::from_millis(u64::from(delay_ms)); - } - } - - /// Clear the guard when the resolving spell is our outstanding one (spell-id-keyed, like the - /// `Casting` reap — a triggered proc's `SMSG_SPELL_GO` mid-cast must not open the gate early). - pub(crate) fn clear_if(&mut self, spell_id: u32) { - if self.0.as_ref().is_some_and(|p| p.spell_id == spell_id) { - self.0 = None; - } - } -} - -/// Our own queued **on-next-swing** spell (Heroic Strike, Cleave — `Attributes & 0x404`) — the -/// melee-slot half of the client's cast tracking. In the reference the queued spell *occupies* -/// the inflight id (`0xceca88`) until the swing fires it, and the already-casting refusal at -/// `6e4d97` exempts new casts because the inflight rec has the `0x404` bits — so a queued Heroic -/// Strike never blocks Rend, and the new cast nests around it (`PushPopNestedCast 0x6e4ad0` -/// restores the queued id when the nested cast resolves; wow-re `wave-cast.md`). We model the -/// same observable with a second slot instead of the push/pop pair: [`PendingCast`] keeps -/// ordinary casts, this keeps the melee queue, and the checked ring reads both (the ref's -/// `IsCurrentAction` C2 leg — spell == inflight — holds through the nesting either way, minus -/// the ref's one-RTT un-check blink while the nested cast displaces the slot). -/// -/// **Deadline-less, wire-cleared** — like the ref's inflight id, which no timer touches. The -/// clear set is deliberately wider than the ref's: the §5 (`combat-feel-law.md` @ c445713b) -/// pinned that the ref clears inflight/saved ONLY on a matching `SMSG_CAST_RESULT` (`0x6e7330`; -/// the GO handler never touches `0xceca88`) — but vmangos never sends an OK `CAST_RESULT` at -/// all, so on our wire the resolution is `SMSG_SPELL_GO` when the swing fires the strike, and a -/// failing `CAST_RESULT` + `SPELL_FAILED_OTHER` when it dies (vmangos `Spell::cancel` on the -/// PREPARING melee slot sends both — target death, manual cancel, replacement). We clear on any -/// of the three, id-keyed: identical observable where `0x130` does arrive, correct where it -/// never does. Re-arming replaces silently: the server holds a single `CURRENT_MELEE_SPELL` -/// slot. Re-pressing the queued spell itself is the ref's silent same-spell bail — §5-CONFIRMED -/// (`6e4d43`: debug-log, `xor al,al`, no CMSG, no error): 1.12 has no re-press-to-unqueue. The -/// real un-queue is the StopAttack chain (`0x5ecac0` → `CancelQueuedCast 0x6e6f30`, sending -/// `CMSG_ATTACKSTOP` + `CMSG_CANCEL_CAST`), reached by /stopattack, the Attack-button toggle, -/// **an auto-repeat press** (`0x6e5976`), target change/death, mount, interact — never movement. -/// That chain is [`crate::creature_anim::stop_attack_local`], and the auto-repeat arm inside -/// [`crate::ui_action::cast_send`]'s commit runs it whole: starting Auto Shot un-queues the -/// strike, which is what darkens its checked ring. Every attack edge reaches that seam since -/// 1044 — the Attack toggle, a target switch, a click-off, the ring's death teardown. -/// -/// **Escape is the other route, and it is not that chain at all** (1049). In the reference a -/// queued strike simply *is* the inflight spell, so `Script::SpellStopCasting 0x6e6e80`'s plain -/// `IsCasting 0x6e3d30` branch cancels it like any cast — no melee-specific code anywhere in it. -/// [`Inflight`] is where our two slots are re-joined for that reader. -#[derive(Resource, Default)] -pub(crate) struct QueuedMeleeSpell(Option); - -impl QueuedMeleeSpell { - /// The queued spell's id, if one is waiting on the next swing — what the checked ring and - /// the re-press bail read. - pub(crate) fn current(&self) -> Option { - self.0 - } - - /// Queue a fresh on-next-swing send (replacing any prior queue — the server's single slot). - pub(crate) fn arm(&mut self, spell_id: u32) { - self.0 = Some(spell_id); - } - - /// Clear on the wire resolution (`SMSG_SPELL_GO` / a failing `SMSG_CAST_RESULT` / - /// `SMSG_SPELL_FAILED_OTHER`), spell-id-keyed like every reap. - pub(crate) fn clear_if(&mut self, spell_id: u32) { - if self.0 == Some(spell_id) { - self.0 = None; - } - } -} - -/// What the reference's single inflight spell id (`0xceca88`) holds — as our **two** slots see it. -/// -/// The client has one slot, with one save beneath it for the nesting. An ordinary cast and a -/// queued on-next-swing strike both live in `0xceca88`, which is exactly why `IsCasting 0x6e3d30` -/// — a bare `ceca88 != 0` — answers true for either, and why `AbortCast 0x6e4940` cancels -/// whichever is there without ever asking which kind it was. [`PendingCast`] and -/// [`QueuedMeleeSpell`] split that one slot in two (its doc says why), which leaves every reader -/// of *"what is in flight"* to re-join them — and the one reader that forgot was the Esc ladder, -/// which is the bug decision 1049 fixes. So the join gets a name, once, here. -/// -/// **The order is the reference's nesting, not a preference.** An ordinary cast started over a -/// queued strike is allowed through `TryCast`'s already-casting refusal precisely because the -/// inflight rec has `Attributes & 0x404` (`0x6e4d97`/`0x6e4dbe`), and it *pushes* -/// (`PushPopNestedCast 0x6e4ad0` @ `0x6e5026`): the cast becomes `0xceca88`, the strike the save -/// in `0xcecaa8`. `AbortCast` cancels the cast and pops the strike back up — so the cast dies on -/// the first Esc and the strike on the second. Reading `PendingCast` first reproduces that -/// without our needing the push/pop pair. -/// -/// One knowing divergence there, pinned by wow-re's `spell/scratch/esc-queued-strike.md` §Q(c) -/// and weighed in decision 1058: the ref's pop *reads* `0xcecaa8` and never writes it, so a -/// second Esc re-asserts the same strike locally and only the server echo converges it. We have -/// no separate save slot to go stale, so ours clears. The two agree everywhere it shows: in the -/// un-nested case the ref's pop reads a zero save and clears too, and in the nested case vmangos -/// drops the melee slot on press **1** regardless (`HandleCancelCastOpcode` is spellId-blind -/// about `CURRENT_MELEE_SPELL`), so both clients converge off that echo. Reproducing the -/// re-assert would mean modelling `0xcecaa8` as a third slot to hold a value that is stale by -/// construction, for a window one localhost round trip wide. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Inflight { - /// An ordinary cast — ours to send-guard, to paint a bar for, and to reap. - Cast(u32), - /// A queued on-next-swing strike waiting for the swing. No bar ever opened for it and it - /// holds no [`Casting`], so cancelling it is the wire message and the slot, nothing else. - Strike(u32), -} - -impl Inflight { - /// The spell id in the slot — what a cancel names on the wire, either way. - pub(crate) fn spell_id(self) -> u32 { - match self { - Self::Cast(id) | Self::Strike(id) => id, - } - } -} - -/// Resolve the reference's inflight slot from our two — see [`Inflight`] for the order and the -/// why. `started` is the self [`Casting`] spell id, which covers a cast that never armed the send -/// guard (an item use) exactly as the ref's one id covers it. -pub(crate) fn inflight( - pending: &PendingCast, - queued_melee: &QueuedMeleeSpell, - started: Option, - now: Instant, -) -> Option { - pending - .current(now) - .or(started) - .map(Inflight::Cast) - .or_else(|| queued_melee.current().map(Inflight::Strike)) -} - -/// Slack past the server-named channel end, so a lost/late `MSG_CHANNEL_UPDATE(0)` can't pin the -/// checked ring on forever — the natural end clears through the deadline instead. -const CHANNEL_SLACK: Duration = Duration::from_secs(2); - -/// Our own running channel — the app-side mirror of the client's current-channel id (`0xceac58`, -/// read live-gated by the channeling word: the §5's `IsCurrentAction` channel leg, -/// `action-button-state-api.md` §3 — a channeled spell's button stays checked while its channel -/// runs). Set at `MSG_CHANNEL_START`, refreshed by nonzero `MSG_CHANNEL_UPDATE`s, cleared by the -/// `UPDATE(0)` that ends both the natural finish and the interrupt. -#[derive(Resource, Default)] -pub(crate) struct ActiveChannel(Option<(u32, Instant)>); - -impl ActiveChannel { - /// The running channel's spell id, if one is live. - pub(crate) fn current(&self, now: Instant) -> Option { - self.0 - .filter(|&(_, until)| now < until) - .map(|(spell, _)| spell) - } - - /// `MSG_CHANNEL_START`: open the window for the named duration (+ slack). - pub(crate) fn start(&mut self, spell_id: u32, duration_ms: u32, now: Instant) { - self.0 = Some(( - spell_id, - now + Duration::from_millis(u64::from(duration_ms)) + CHANNEL_SLACK, - )); - } - - /// `MSG_CHANNEL_UPDATE`: `0` ends the channel; anything else re-times the window (pushback - /// shortens a channel, so the deadline moves in as well as out). - pub(crate) fn update(&mut self, remaining_ms: u32, now: Instant) { - if remaining_ms == 0 { - self.0 = None; - } else if let Some((_, until)) = &mut self.0 { - *until = now + Duration::from_millis(u64::from(remaining_ms)) + CHANNEL_SLACK; - } - } -} - -/// ── The local self-cancel (decisions 0256 open item 2 / 0444 / 0445): move/jump/Esc mid-cast -/// ends the cast **locally**, the same client tick — the app-side mirror of `AbortCast -/// 0x6e4940`. The whole trigger chain is **VERIFIED** (wow-re `move-selfcancel.md`, the -/// 2026-07-17 §5): the `Script::Move*` keybind handlers funnel into the shared dispatcher -/// `0x515090`, whose interrupt mask `0x10f0` = {forward, backward, strafe L/R, autorun-toggle} -/// — turn (`0x100/0x200`) and pitch (`0x400/0x800`) are OUTSIDE the mask and never cancel — -/// and `Script::Jump 0x513bd0` inlines the same gate. Both call `AbortCast(cl=0, dl=1, -/// reason=0x1c)`: event **0x152 SPELLCAST_STOP** (the silent close — `cl=0` selects it; -/// `0x153/0x154` need `cl!=0`) + `CMSG_CANCEL_CAST`. The server stays the safety net: vmangos's -/// own movement interrupt (`Spell::update`'s 0.5-yd position delta) still kills anything we -/// miss. The per-spell gates, byte-verified at the call sites (`f6 46 54 01` @`0x51511e`, -/// `f6 42 5c 08` @`0x515175`; identities corroborated by vmangos `SpellDefines.h`): -/// (Also the entry test of the cast-INITIATION moving gate — `ui_action::state`'s -/// `cast_moving_refusal`, decision 0862 — which is a different mechanism from this self-cancel.) -pub(crate) const SPELL_INTERRUPT_MOVEMENT: u32 = 0x1; // SpellRec+0x54 InterruptFlags (SPELL_INTERRUPT_FLAG_MOVEMENT) -const CHANNEL_INTERRUPT_MOVING: u32 = 0x8; // SpellRec+0x5c ChannelInterruptFlags (AURA_INTERRUPT_MOVING_CANCELS) - -/// The controller's report that a cancel-worthy local move edge happened this frame — a -/// *directional* start (forward/back/strafe: our wire-axis mirror of the verified `0x10f0` -/// mask's flags) or a jump takeoff. The mask's fifth member, the autorun toggle, is dormant -/// until benilla grows an autorun (0445). Written by `player::control` at the same spot the -/// wire-axis transitions are computed, consumed (and cleared) by [`local_self_cancel`] next -/// frame — one engine frame (~16 ms) from key to bar-kill, vs the server round trip (~150 ms+: -/// the 0.5-yd delta has to accumulate before vmangos even notices). -#[derive(Resource, Default)] -pub(crate) struct LocalMoveStart(pub(crate) bool); - -/// Move/jump/Esc mid-cast: run the client's local cancel. One system for both triggers so the -/// resolution can't drift between them: -/// -/// - **the auto-repeat half (Esc only)** — the ref's branch order inside -/// `Script::SpellStopCasting 0x6e6e80` (§5-verified whole, wow-re `esc-stopcasting.md`): a -/// running auto-repeat dies FIRST — [`crate::creature_anim::cancel_auto_repeat_local`], the -/// byte-verified `0x6ea080` (key + idle gates + nocked ammo + `CMSG_CANCEL_AUTO_REPEAT_SPELL`) -/// — and SPENDS the press: one press stops one thing, the cast survives to the next. -/// Movement never touches auto-repeat. -/// - **the cast half** — clear [`PendingCast`], ship `CMSG_CANCEL_CAST`, reap the self -/// `Casting` state + fire the `Fail` cast event (the anim/visual teardown `AbortCast` does via -/// its stop-cast-anim tail), and push the **`Stop` + `Interrupted`** bar edges — the ref's -/// own two-step (its local STOP, then the echo's red repaint) at RTT→0: red "Interrupted", -/// held 1 s, the armed flash bursting white, then the fade (the transcribed `CastingBar.xml` -/// machinery runs the rest). Esc bypasses the movement flags gate (the ref's -/// `SpellStopCasting → AbortCast` has no `InterruptFlags` test — the gate belongs to the -/// movement path alone). -/// - **the channel half — MOVEMENT-ONLY** — ship `CMSG_CANCEL_CHANNELLING` and NOTHING else: -/// the verified asymmetry (0445). Esc never reaches a channel — `0x6e6e80`'s whole callee -/// closure never calls the channel canceler `0x6e9b70`, and its inflight gate (`0xceca88`) -/// is already 0 mid-channel (the launch `CAST_RESULT(OKAY)` clears it at `0x6e7408`): the -/// vanilla "/stopcasting can't stop a channel" quirk, kept faithfully (0454; wow-re -/// `esc-stopcasting.md`). The ref's `0x6e9b70` fires no FrameScript event and clears no -/// local state — the channel bar closes on the server's `SMSG_CHANNEL_UPDATE(0)` -/// (`0x6e75f0`), which also clears [`ActiveChannel`] through the normal wire path (its slack -/// deadline self-heals a lost packet). -/// -/// Why `Interrupted` when the client's own event is VERIFIED as the silent `0x152` STOP (every -/// self-cancel caller passes `cl=0`)? Because the REF'S NET OBSERVABLE is the red bar — the -/// director's falsifier, decision 0449, now pinned end to end (0454): vmangos `Spell::cancel` -/// answers the cancel with `SMSG_SPELL_FAILED_OTHER` and then a failing `SMSG_CAST_RESULT` -/// reason 0x23 (`SPELL_FAILED_INTERRUPTED`, counted against the 1.12.1 build guards), the -/// client routes a failing result through `HandleCastFailed 0x6e1a00`, and reasons -/// {0x23,0x24} select FrameScript **0x154 SPELLCAST_INTERRUPTED** — red, "Interrupted" -/// (GlobalStrings-exact), `holdTime = now + 1 s`. Our bar-edge producers are KEYED to the -/// self `Casting` (the 0137/0256 law that stops a proc's failure repainting the bar), and -/// this cancel's reap removes it — so the echo can't do the repainting here. The local -/// `Stop` + `Interrupted` pair replays the ref's exact sequence at RTT→0 through our keyed -/// architecture; the reap then keeps the echo idempotent (re-clears open guards, shows the -/// red error line, touches no bar). On-screen: the 1 s hold + ~25 bar ticks of flash+fade — -/// ~1.9 s in the reference bottle (whose `Config.wtf` caps `maxfps` at 30, and the ref steps -/// alpha PER TICK), the director's observed "2–3 s"; our transcription normalizes those steps -/// to the same 30 Hz reference tick so the tail matches at any render rate (`CastingBar.xml`). -fn local_self_cancel( - script: Option>, - mut moved: ResMut, - mut pending: ResMut, - mut queued_melee: ResMut, - channel: Res, - mut auto_repeat: ResMut, - mut feed: ResMut, - spells: Option>, - net: Res, - self_guid: Res, - index: Res, - casting: Query<&Casting>, - mut cast_events: MessageWriter, - mut play_seq: ResMut, - mut ecs: Commands, -) { - let esc = match script { - Some(mut s) => s.take_spell_stop(), - None => false, - }; - let moved = std::mem::take(&mut moved.0); - if !esc && !moved { - return; - } - let now = Instant::now(); - let self_e = self_guid.0.as_ref().and_then(|g| index.0.get(g)).copied(); - // The Esc press stops ONE thing, auto-repeat first — the ref's branch order (`0x6e6e80`). - let esc = if esc && auto_repeat.0.is_some() { - if *crate::net::CAST_TRACE { - info!("cast-trace: LOCAL auto-repeat self-cancel (esc), CMSG_CANCEL_AUTO_REPEAT_SPELL"); - } - crate::creature_anim::cancel_auto_repeat_local(self_e, &mut auto_repeat, &mut ecs, &net); - false // the press is spent - } else { - esc - }; - if !esc && !moved { - return; - } - let flags_open = |pick: fn(&benilla_formats::SpellDisplay) -> bool, spell_id: u32| { - // An uncataloged spell cancels: the gate exists to spare the rare cast-while-moving - // spell, and failing open matches the server's own verdict for everything ordinary. - spells - .as_ref() - .and_then(|s| s.catalog.get(spell_id)) - .is_none_or(pick) - }; - // The cast half — the ref's ONE inflight slot ([`Inflight`]), which is an ordinary cast or a - // queued on-next-swing strike. `AbortCast` doesn't distinguish, so neither does the send or - // the movement gate; only the local teardown differs, because only a cast ever had a bar. - let started = self_e.and_then(|e| casting.get(e).ok()).map(|c| c.spell_id); - if let Some(slot) = inflight(&pending, &queued_melee, started, now) { - let spell_id = slot.spell_id(); - // Esc bypasses the flags gate; movement consults the slot's own `InterruptFlags`, which - // is what spares a queued strike (Heroic Strike 78, Cleave 845, Raptor Strike 2973 all - // ship `InterruptFlags = 0x0` — verified against Spell.dbc, vs Fireball's `0xf`). So - // charging in with a strike queued keeps it queued, and only Esc takes it. - if esc - || flags_open( - |d| d.interrupt_flags & SPELL_INTERRUPT_MOVEMENT != 0, - spell_id, - ) - { - if *crate::net::CAST_TRACE { - info!( - "cast-trace: LOCAL self-cancel — {slot:?} ({}), CMSG_CANCEL_CAST", - if esc { "esc" } else { "moved" } - ); - } - let _ = net.0.send(ClientCommand::CancelCast { spell_id }); - match slot { - Inflight::Cast(_) => { - pending.clear_if(spell_id); - // The ref's two-step at RTT→0: STOP arms the bar's flash overlay, the echo's - // INTERRUPTED repaints it red and starts the hold — so after the hold the - // flash BURSTS white over the red bar, then the fade runs. One edge alone - // misses the burst (which the enemy-interrupt path — no prior STOP — - // correctly lacks). - feed.0.push(CastBarEdge::Stop); - feed.0.push(CastBarEdge::Interrupted); - // The reap — `spell_failed_other`'s self arm, run early so the echo finds it - // done. - if let Some(e) = self_e { - if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) { - ecs.entity(e).remove::(); - } - cast_events.write(CastEvent { - entity: e, - spell_id, - kind: CastEventKind::Fail, - seq: play_seq.next(), - }); - } - } - Inflight::Strike(_) => { - // Clearing the slot IS the un-toggle: the checked ring reads it - // (`ui_action::state`), so the button darkens this frame instead of waiting - // for vmangos's FAILED_OTHER + CAST_RESULT echo (which then finds the slot - // already open and is idempotent). `AbortCast` fires its `0x152` - // SPELLCAST_STOP here too — inert on a bar that never opened - // (`CastingBar.xml` guards every arm on `IsShown`), so it is fired for - // fidelity, not effect. No red INTERRUPTED: there is nothing to repaint. - queued_melee.clear_if(spell_id); - feed.0.push(CastBarEdge::Stop); - } - } - } - } - // The channel half — MOVEMENT-ONLY (Esc can't stop a channel: the doc above / 0454), and - // the send and nothing else (the verified asymmetry: the ref's `0x6e9b70` fires no event - // and clears no state; the bar and [`ActiveChannel`] both close on the server's - // `SMSG_CHANNEL_UPDATE(0)`, ~one localhost frame later). - if moved { - if let Some(spell_id) = channel.current(now) { - if flags_open( - |d| d.channel_interrupt_flags & CHANNEL_INTERRUPT_MOVING != 0, - spell_id, - ) { - if *crate::net::CAST_TRACE { - info!("cast-trace: LOCAL channel self-cancel — spell {spell_id}, CMSG_CANCEL_CHANNELLING"); - } - let _ = net.0.send(ClientCommand::CancelChannelling { spell_id }); - } - } - } -} - /// `SPELLCAST_START`'s **name argument** — what the reference `CastingBarFrame` puts straight into /// `CastingBarText:SetText(...)` for a *cast*. Byte-verified at `0x6e7a2d`–`0x6e7a47` (wow-re /// `spell/scratch/wave-cast.md`, "The casting-bar TEXT law", §5 cross-checked): the handler tests @@ -671,13 +167,13 @@ fn channel_start_args( /// queued strike makes `IsCasting` true in the reference, so `SpellStopCasting()` returns `1` and /// `ToggleGameMenu`'s ladder never reaches `ClearTarget()` (`UIParent.lua` l.1489 vs l.1492). /// Reading only the ordinary-cast half here is what dropped the target on the first Esc. -fn feed_cast_bar( +pub(crate) fn feed_cast_bar( script: Option>, mut feed: ResMut, spells: Option>, pending: Res, queued_melee: Res, - auto_repeat: Res, + auto_repeat: Res, self_guid: Res, index: Res, casting: Query<&Casting>, @@ -723,7 +219,7 @@ fn feed_cast_bar( } => { let args = channel_start_args(&script, spells.as_deref(), spell_id, duration_ms); // The channel half of the cast trace. The other five edges are traced at RECV - // (`net::apply::spells`); these two are traced HERE because the decision that + // (`spell::net`); these two are traced HERE because the decision that // wants watching — which of `0x6e7550`'s legs the spell took — is made here, // and the drain runs the same frame the packet lands. if *crate::net::CAST_TRACE { @@ -773,19 +269,19 @@ fn feed_cast_bar( /// OnUpdate land the same frame), and the local self-cancel resolved just ahead of the drain — /// a controller move edge or an ESC `SpellStopCasting()` from the previous frame kills the bar /// on this frame's VM tick (one engine frame from input to bar-death). +/// The feed only — the state it reads is `crate::spell`'s (decision 2328). pub(crate) struct UiCastPlugin; impl Plugin for UiCastPlugin { fn build(&self, app: &mut App) { - app.init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .add_systems( - Update, - (local_self_cancel, feed_cast_bar).chain().in_set(UnitFeed), - ); + app.init_resource::().add_systems( + Update, + // After the spell's local cancel, whose edges this drains the same frame — the + // `(local_self_cancel, feed_cast_bar).chain()` this plugin held until 2328. + feed_cast_bar + .in_set(UnitFeed) + .after(crate::spell::LocalCancel), + ); } } @@ -793,8 +289,6 @@ impl Plugin for UiCastPlugin { mod tests { use super::*; - const FIREBALL: u32 = 133; - /// **B247** (decision 1312): the bar's label honours `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`. /// Spell 22810's Spell.dbc name — "Opening - No Text" — is Blizzard's own annotation of the /// attribute, and it reached a player's screen because we printed the name regardless. @@ -933,668 +427,4 @@ mod tests { ScriptValue::Str("Channeling".into()), ); } - - /// The suppressed channel still arms [`ActiveChannel`], so its action button stays lit: the - /// reference's channel mirror is `SPELLCAST+0x10` (`0xceac58`), written at the cast SEND - /// (`0x6e4f88`) and never touched by `0x6e7550`. That is why all four of the handler's gates - /// live in the DRAIN and none of them in `net::apply::spells::channel_start`, which arms this. - #[test] - fn suppressing_the_channel_bar_does_not_clear_the_channel_mirror() { - let t0 = Instant::now(); - // What `MSG_CHANNEL_START` does for Blood Siphon 24322 — the suppressed row. - let mut channel = ActiveChannel::default(); - channel.start(24322, 8_000, t0); - assert_eq!( - channel.current(t0 + Duration::from_secs(1)), - Some(24322), - "the wire edge arms the mirror regardless of what the bar does with it" - ); - } - - #[test] - fn a_fresh_guard_is_open() { - let g = PendingCast::default(); - assert!( - !g.in_flight(Instant::now()), - "nothing sent yet — casting allowed" - ); - } - - #[test] - fn arming_closes_the_guard_and_a_matching_resolution_opens_it() { - let t0 = Instant::now(); - let mut g = PendingCast::default(); - g.arm(FIREBALL, t0, true); - assert!( - g.in_flight(t0), - "a cast is in flight — a second one is refused" - ); - g.clear_if(FIREBALL); // SMSG_SPELL_GO / a failing SMSG_CAST_RESULT for our cast - assert!( - !g.in_flight(t0), - "our cast resolved — casting allowed again" - ); - } - - #[test] - fn a_different_spells_resolution_leaves_the_guard_closed() { - let t0 = Instant::now(); - let mut g = PendingCast::default(); - g.arm(FIREBALL, t0, true); - g.clear_if(FIREBALL + 1); // a triggered proc's GO for some other spell mid-cast - assert!( - g.in_flight(t0), - "a different spell's resolution must not open the guard on our cast" - ); - } - - #[test] - fn the_guard_opens_when_its_safety_deadline_lapses() { - // The net that saves us if a cast resolves with no packet (a local move-cancel we don't - // model): the guard cannot wedge casting shut forever. - let t0 = Instant::now(); - let mut g = PendingCast::default(); - g.arm(FIREBALL, t0, true); - assert!(g.in_flight(t0 + SEND_PROVISIONAL - Duration::from_secs(1))); - assert!(!g.in_flight(t0 + SEND_PROVISIONAL + Duration::from_secs(1))); - } - - #[test] - fn a_pushback_extends_the_guard_so_it_holds_past_the_stretched_cast() { - let t0 = Instant::now(); - let mut g = PendingCast::default(); - g.arm(FIREBALL, t0, true); - g.refine(1_000, t0); // a 1 s cast: deadline t0 + 1s + 2s slack = t0 + 3s - g.delay(500, t0); // a hit at t0 pushes it +0.5s ⇒ t0 + 3.5s - assert!( - g.in_flight(t0 + Duration::from_millis(3_200)), - "the pushback kept the guard shut past the original refined deadline" - ); - } - - #[test] - fn spell_start_tightens_the_deadline_to_the_real_cast_time() { - let t0 = Instant::now(); - let mut g = PendingCast::default(); - g.arm(FIREBALL, t0, true); // provisional 5s window - g.refine(1_500, t0); // SMSG_SPELL_START: a 1.5s cast (+ 2s pushback slack ⇒ 3.5s) - let at_four = t0 + Duration::from_secs(4); - assert!( - !g.in_flight(at_four), - "refined to 1.5s + slack, the guard has opened by t+4s (it would still be shut at the \ - 5s provisional)" - ); - } - - /// The queued on-next-swing slot: deadline-less (no clock in its API at all), keyed clears - /// only, and a re-arm replaces — the server's single `CURRENT_MELEE_SPELL` slot. - #[test] - fn queued_melee_spell_is_wire_cleared_and_single_slot() { - const HEROIC_STRIKE: u32 = 78; - const CLEAVE: u32 = 845; - let mut q = QueuedMeleeSpell::default(); - assert_eq!(q.current(), None); - - q.arm(HEROIC_STRIKE); - assert_eq!(q.current(), Some(HEROIC_STRIKE), "queued and checked"); - - // A different spell's resolution (Rend's GO while HS waits) must not open the queue. - q.clear_if(772); - assert_eq!(q.current(), Some(HEROIC_STRIKE)); - - // A second on-next-swing cast replaces the queue (vmangos holds ONE melee slot). - q.arm(CLEAVE); - assert_eq!(q.current(), Some(CLEAVE)); - - // The swing fired it (SPELL_GO) — or the slot died (failing CAST_RESULT / - // SPELL_FAILED_OTHER): the matching id clears. - q.clear_if(CLEAVE); - assert_eq!(q.current(), None, "the wire resolution opened the queue"); - } - - /// The local self-cancel harness: a mini-App with the real [`local_self_cancel`] system, a - /// self player mid-cast/mid-channel, and the net channel's receive half to assert the wire. - /// The harness registers no `UiScript`, so the ESC trigger reads `false` by default; the - /// ESC-leg tests insert a real VM and drive the actual `SpellStopCasting()` binding (the - /// ladder's 1/nil semantics are pinned Lua-side in `ui_script::escape_tests`). - mod local_cancel { - use super::*; - use crate::creature_anim::PlaySeq; - use crate::net::{Guid, GuidIndex, NetCommands, SelfGuid, SelfPlayer}; - use benilla_formats::{SpellCatalog, SpellDisplay}; - use bevy::ecs::system::RunSystemOnce; - use std::collections::HashMap; - - const ARCANE_MISSILES: u32 = 5143; - const HEROIC_STRIKE: u32 = 78; - - fn harness( - displays: HashMap, - ) -> (App, crossbeam_channel::Receiver) { - let (tx, rx) = crossbeam_channel::unbounded(); - let mut app = App::new(); - app.add_message::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .insert_resource(NetCommands(tx)) - .insert_resource(crate::ui_action::Spells { - catalog: SpellCatalog::from_displays(displays), - ..crate::ui_action::Spells::empty_for_tests() - }); - let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id(); - app.world_mut() - .resource_mut::() - .0 - .insert(10, self_e); - app.world_mut().resource_mut::().0 = Some(10); - (app, rx) - } - - /// Mark the self player mid-cast on `spell_id` (the `SMSG_SPELL_START` echo's `Casting`). - fn mark_casting(app: &mut App, spell_id: u32) { - let self_e = app.world().resource::().0[&10]; - app.world_mut().entity_mut(self_e).insert(Casting { - spell_id, - until: None, - }); - } - - fn run(app: &mut App, moved: bool) { - app.world_mut().resource_mut::().0 = moved; - app.world_mut().run_system_once(local_self_cancel).unwrap(); - } - - /// The headline feel fix: a directional start / jump mid-cast kills the cast locally — - /// wire cancel out, guard open, the STOP + INTERRUPTED bar-edge pair (the ref's own - /// two-step: its silent 0x152 STOP arms the flash, the server echo's repaint holds the - /// red "Interrupted"; our keyed reap suppresses the real echo, so the local pair - /// replays the sequence at RTT→0 — decisions 0449/0454), `Casting` reaped — instead of - /// waiting ~150 ms+ for the server's 0.5-yd position-delta interrupt. - #[test] - fn a_move_edge_mid_cast_cancels_locally() { - let (mut app, rx) = harness(HashMap::from([( - FIREBALL, - SpellDisplay { - interrupt_flags: 0xf, // the ordinary timed-cast mask (movement bit set) - ..Default::default() - }, - )])); - app.world_mut() - .resource_mut::() - .arm(FIREBALL, Instant::now(), true); - mark_casting(&mut app, FIREBALL); - - run(&mut app, true); - - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) - ), - "the move edge ships CMSG_CANCEL_CAST for the in-flight cast" - ); - assert!( - !app.world() - .resource::() - .in_flight(Instant::now()), - "the in-flight guard opens with the local cancel" - ); - let feed = &app.world().resource::().0; - assert_eq!(feed.len(), 2); - assert!( - matches!( - (&feed[0], &feed[1]), - (CastBarEdge::Stop, CastBarEdge::Interrupted) - ), - "the bar edges are the ref's own two-step at RTT→0 — STOP (arms the flash \ - overlay) then the red INTERRUPTED (the echo's repaint: hold, burst, fade); \ - the keyed reap silences the real echo (0449/0454)" - ); - let self_e = app.world().resource::().0[&10]; - assert!( - app.world().entity(self_e).get::().is_none(), - "the self `Casting` state is reaped, so the server's echo (FAILED_OTHER + \ - CAST_RESULT) finds nothing to repaint" - ); - assert!( - !app.world().resource::().0, - "the edge is consumed" - ); - } - - /// The per-spell gate (VERIFIED — `test [SpellRec+0x54],1` at both call sites): a spell - /// whose `InterruptFlags` lacks the movement bit keeps casting while we move — no wire - /// cancel, no bar edge. - #[test] - fn the_interrupt_flags_gate_spares_a_movement_castable_spell() { - let (mut app, rx) = harness(HashMap::from([( - FIREBALL, - SpellDisplay { - interrupt_flags: 0xe, // movement bit CLEAR (pushback/stun/combat only) - ..Default::default() - }, - )])); - app.world_mut() - .resource_mut::() - .arm(FIREBALL, Instant::now(), true); - - run(&mut app, true); - - assert!(rx.try_recv().is_err(), "no wire cancel for a spared spell"); - assert!( - app.world() - .resource::() - .in_flight(Instant::now()), - "the guard keeps holding — the cast runs on" - ); - assert!(app.world().resource::().0.is_empty()); - } - - /// A spell the catalog doesn't know fails OPEN (cancels): the gate exists to spare the - /// rare cast-while-moving spell, and the server's own verdict for everything ordinary is - /// to interrupt. - #[test] - fn an_uncataloged_spell_fails_open() { - let (mut app, rx) = harness(HashMap::new()); - app.world_mut() - .resource_mut::() - .arm(FIREBALL, Instant::now(), true); - - run(&mut app, true); - - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) - ), - "an unknown spell's cast still cancels on movement" - ); - } - - /// The union's other half: an ITEM use's cast (a hearthstone) never arms the send - /// guard — the started self `Casting` (the `SMSG_SPELL_START` echo) alone makes it - /// move-cancelable, mirroring the ref's ONE inflight id (`0xceca88`), which item casts - /// set like any spell cast. - #[test] - fn a_started_item_cast_cancels_without_the_send_guard() { - let (mut app, rx) = harness(HashMap::from([( - FIREBALL, - SpellDisplay { - interrupt_flags: 0xf, - ..Default::default() - }, - )])); - mark_casting(&mut app, FIREBALL); // PendingCast stays empty — item casts never arm it - - run(&mut app, true); - - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) - ), - "the started cast cancels through the `Casting` half of the union" - ); - let self_e = app.world().resource::().0[&10]; - assert!(app.world().entity(self_e).get::().is_none()); - } - - /// No move edge, cast in flight: nothing happens — the system is edge-triggered, never - /// level-triggered (holding W through a whole cast is one cancel at the start, not one - /// per frame; and standing still cancels nothing). - #[test] - fn no_edge_means_no_cancel() { - let (mut app, rx) = harness(HashMap::from([( - FIREBALL, - SpellDisplay { - interrupt_flags: 0xf, - ..Default::default() - }, - )])); - app.world_mut() - .resource_mut::() - .arm(FIREBALL, Instant::now(), true); - - run(&mut app, false); - - assert!(rx.try_recv().is_err()); - assert!(app - .world() - .resource::() - .in_flight(Instant::now())); - assert!(app.world().resource::().0.is_empty()); - } - - /// Esc CANNOT stop a channel — the §5-verified `0x6e6e80` never reaches the channel - /// canceler `0x6e9b70`, and its inflight gate is already 0 mid-channel (wow-re - /// `esc-stopcasting.md`, 0454): the mirror answers `SpellStopCasting()` nil (the - /// ladder falls through to the next rung) and the drain ships nothing. - #[test] - fn esc_cannot_stop_a_channel() { - let (mut app, rx) = harness(HashMap::new()); - app.insert_non_send_resource(UiScript::new().unwrap()); - let now = Instant::now(); - app.world_mut() - .resource_mut::() - .start(ARCANE_MISSILES, 5_000, now); - - app.world_mut().run_system_once(feed_cast_bar).unwrap(); - assert_eq!( - app.world() - .non_send_resource::() - .eval::>("return SpellStopCasting()") - .unwrap(), - None, - "mid-channel the binding answers nil — the vanilla /stopcasting quirk" - ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); - assert!( - rx.try_recv().is_err(), - "no wire cancel — channels break only on the movement path" - ); - assert_eq!( - app.world().resource::().current(now), - Some(ARCANE_MISSILES) - ); - } - - /// The ref's branch order inside `SpellStopCasting` (`0x6e6e80`): a running auto-repeat - /// dies FIRST — `CMSG_CANCEL_AUTO_REPEAT_SPELL`, no bar edge — and SPENDS the press; - /// the in-flight cast survives to the next one. One press stops one thing. - #[test] - fn esc_cancels_the_auto_repeat_first_and_the_cast_on_the_next_press() { - const AUTO_SHOT: u32 = 75; - let (mut app, rx) = harness(HashMap::new()); - app.insert_non_send_resource(UiScript::new().unwrap()); - app.world_mut() - .resource_mut::() - .0 = Some(AUTO_SHOT); - app.world_mut() - .resource_mut::() - .arm(FIREBALL, Instant::now(), true); - mark_casting(&mut app, FIREBALL); - - // Press 1: truthy mirror (auto-repeat ∪ cast) → trigger queued → the drain stops - // the auto-repeat ONLY. - app.world_mut().run_system_once(feed_cast_bar).unwrap(); - assert_eq!( - app.world() - .non_send_resource::() - .eval::>("return SpellStopCasting()") - .unwrap(), - Some(1) - ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelAutoRepeat) - ), - "the auto-repeat cancel ships first — the ref's priority branch" - ); - assert!(rx.try_recv().is_err(), "the cast survives the first press"); - assert!(app - .world() - .resource::() - .0 - .is_none()); - assert!( - app.world() - .resource::() - .in_flight(Instant::now()), - "the send guard still holds — one press stopped one thing" - ); - assert!( - app.world().resource::().0.is_empty(), - "an auto-repeat cancel paints no bar edge" - ); - - // Press 2: the auto-repeat is gone — now the cast dies, the ordinary Esc cancel. - app.world_mut().run_system_once(feed_cast_bar).unwrap(); - assert_eq!( - app.world() - .non_send_resource::() - .eval::>("return SpellStopCasting()") - .unwrap(), - Some(1) - ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) - ), - "the second press reaches the cast half" - ); - } - - /// **The director's report, decision 1049: Esc with Heroic Strike queued un-toggles the - /// STRIKE and keeps the target.** In the reference a queued on-next-swing spell simply - /// *is* the inflight spell (`0xceca88`), so `SpellStopCasting 0x6e6e80` takes its plain - /// `IsCasting 0x6e3d30` branch — `AbortCast` → `CMSG_CANCEL_CAST` — and returns `1`, - /// which EATS the press so `ToggleGameMenu`'s ladder never reaches `ClearTarget()`. We - /// split that one slot in two and the Esc reader saw only the cast half, so the binding - /// answered nil: the strike stayed lit *and* the target dropped, both from one cause. - #[test] - fn esc_unqueues_a_strike_and_eats_the_press() { - let (mut app, rx) = harness(HashMap::from([( - HEROIC_STRIKE, - SpellDisplay { - attributes: 0x5_0014, // the shipped row: on-next-swing (`& 0x404`) - interrupt_flags: 0, // …and no movement bit, also shipped - ..Default::default() - }, - )])); - app.insert_non_send_resource(UiScript::new().unwrap()); - app.world_mut() - .resource_mut::() - .arm(HEROIC_STRIKE); - - app.world_mut().run_system_once(feed_cast_bar).unwrap(); - assert_eq!( - app.world() - .non_send_resource::() - .eval::>("return SpellStopCasting()") - .unwrap(), - Some(1), - "the queued strike makes IsCasting true, so the binding EATS the press — which \ - is what spares the target three rungs further down the ladder" - ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { - spell_id: HEROIC_STRIKE - }) - ), - "AbortCast names the queued strike on the wire, exactly as it would a cast" - ); - assert_eq!( - app.world().resource::().current(), - None, - "the slot opens locally — the checked ring darkens this frame, not one RTT later" - ); - assert!( - matches!( - app.world().resource::().0[..], - [CastBarEdge::Stop] - ), - "the ref's `0x152` STOP and nothing else: no red INTERRUPTED for a spell that \ - never opened a bar" - ); - } - - /// …but MOVEMENT does not take it. The movement path consults the inflight spell's own - /// `InterruptFlags`, and Heroic Strike 78 / Cleave 845 / Raptor Strike 2973 all ship - /// `InterruptFlags = 0x0` (read off the shipped `Spell.dbc`; Fireball is `0xf`). So - /// charging in with a strike queued keeps it queued — Esc is the only local taker. - #[test] - fn a_move_edge_leaves_a_queued_strike_queued() { - let (mut app, rx) = harness(HashMap::from([( - HEROIC_STRIKE, - SpellDisplay { - interrupt_flags: 0, - ..Default::default() - }, - )])); - app.world_mut() - .resource_mut::() - .arm(HEROIC_STRIKE); - - run(&mut app, true); - - assert!(rx.try_recv().is_err(), "running at the mob cancels nothing"); - assert_eq!( - app.world().resource::().current(), - Some(HEROIC_STRIKE) - ); - } - - /// The nesting order (`Inflight`'s doc): an ordinary cast started over a queued strike - /// pushes it down (`PushPopNestedCast 0x6e4ad0`), so `0xceca88` holds the CAST. One Esc - /// takes the cast; `AbortCast`'s pop restores the strike, and the next Esc takes that. - /// One press, one thing — the ladder's law all the way down. - /// - /// This is the **local** law, which is all a harness with no server can show, and it is - /// the ref's local law too (wow-re `esc-queued-strike.md` §Q(c)) — with the one knowing - /// difference `Inflight`'s doc names: the ref's press 2 re-asserts the strike instead of - /// clearing it. On a live wire neither client gets this far, because vmangos drops the - /// melee slot on press **1** regardless of the id in the packet, and both converge off - /// that echo. So what this pins is the ordering, not a two-press ritual a player sees. - #[test] - fn esc_takes_the_cast_first_and_the_strike_on_the_next_press() { - let (mut app, rx) = harness(HashMap::new()); - app.insert_non_send_resource(UiScript::new().unwrap()); - app.world_mut() - .resource_mut::() - .arm(HEROIC_STRIKE); - app.world_mut() - .resource_mut::() - .arm(FIREBALL, Instant::now(), true); - mark_casting(&mut app, FIREBALL); - - app.world_mut().run_system_once(feed_cast_bar).unwrap(); - assert_eq!( - app.world() - .non_send_resource::() - .eval::>("return SpellStopCasting()") - .unwrap(), - Some(1) - ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL }) - ), - "the nested cast is what sits in the inflight slot — it dies first" - ); - assert!(rx.try_recv().is_err()); - assert_eq!( - app.world().resource::().current(), - Some(HEROIC_STRIKE), - "the strike is the SAVE underneath, untouched by the first press" - ); - - // Press 2 — the pop put the strike back in the slot. - app.world_mut().run_system_once(feed_cast_bar).unwrap(); - assert_eq!( - app.world() - .non_send_resource::() - .eval::>("return SpellStopCasting()") - .unwrap(), - Some(1), - "still stoppable — so this press is eaten too, and the target still survives" - ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); - assert!(matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelCast { - spell_id: HEROIC_STRIKE - }) - )); - assert_eq!(app.world().resource::().current(), None); - } - - /// The channel half is the SEND and nothing else — the verified asymmetry (0445): the - /// ref's `0x6e9b70` ships CMSG_CANCEL_CHANNELLING but fires no FrameScript event and - /// clears no state; the channel bar and the mirror both close on the server's - /// `SMSG_CHANNEL_UPDATE(0)` through the normal wire path. - #[test] - fn a_move_edge_mid_channel_sends_the_cancel_and_touches_nothing_local() { - let (mut app, rx) = harness(HashMap::from([( - ARCANE_MISSILES, - SpellDisplay { - channel_interrupt_flags: 0x7c0c, // the real 5143 mask (moving bit 0x8 set) - ..Default::default() - }, - )])); - let now = Instant::now(); - app.world_mut() - .resource_mut::() - .start(ARCANE_MISSILES, 5_000, now); - - run(&mut app, true); - - assert!( - matches!( - rx.try_recv(), - Ok(crate::net::ClientCommand::CancelChannelling { - spell_id: ARCANE_MISSILES - }) - ), - "the move edge ships CMSG_CANCEL_CHANNELLING" - ); - assert_eq!( - app.world().resource::().current(now), - Some(ARCANE_MISSILES), - "the mirror stays live — the server's UPDATE(0) is what clears it" - ); - assert!( - app.world().resource::().0.is_empty(), - "no local bar edge — the bar closes on the wire's SPELLCAST_CHANNEL_STOP" - ); - } - } - - /// The channel mirror's lifecycle: START opens the window for the whole duration, a nonzero - /// UPDATE re-times it (pushback shortens channels), UPDATE(0) ends it — and the slack deadline - /// self-clears a channel whose closing packet never arrived. - #[test] - fn active_channel_tracks_the_wire_lifecycle() { - let t0 = Instant::now(); - let mut ch = ActiveChannel::default(); - assert_eq!(ch.current(t0), None); - - ch.start(10, 5_000, t0); // Arcane Missiles-shaped: 5 s channel - assert_eq!(ch.current(t0), Some(10)); - assert_eq!( - ch.current(t0 + Duration::from_secs(4)), - Some(10), - "still live near the end" - ); - assert_eq!( - ch.current(t0 + Duration::from_secs(8)), - None, - "the slack deadline self-clears a lost UPDATE(0)" - ); - - // Pushback: an update naming LESS time moves the deadline in. - ch.start(10, 5_000, t0); - ch.update(1_000, t0); - assert_eq!(ch.current(t0 + Duration::from_secs(4)), None); - - // The closing update ends it immediately. - ch.start(10, 5_000, t0); - ch.update(0, t0); - assert_eq!(ch.current(t0), None); - } } diff --git a/crates/benilla-app/src/ui_chat/ace_gate_tests.rs b/crates/benilla-app/src/ui_chat/ace_gate_tests.rs index ab843a42..687a5838 100644 --- a/crates/benilla-app/src/ui_chat/ace_gate_tests.rs +++ b/crates/benilla-app/src/ui_chat/ace_gate_tests.rs @@ -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 { - let mut out = Vec::new(); - if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") { - out.push(PathBuf::from(over)); - } - // …//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 { - 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 diff --git a/crates/benilla-app/src/ui_chat/combat/watch.rs b/crates/benilla-app/src/ui_chat/combat/watch.rs index 6ae5fa85..10a9d6d5 100644 --- a/crates/benilla-app/src/ui_chat/combat/watch.rs +++ b/crates/benilla-app/src/ui_chat/combat/watch.rs @@ -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 { diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index d0cd0d4a..8ee1d5af 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -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, diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index af366b4d..6543308f 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -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 diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 2348cddd..233efded 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -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= WOW_MOVE_TRACE_TAGS=addon` on a live run is the whole conversation in /// one file, in order. diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 9d949ae6..3839d671 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -903,13 +903,13 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1652,13 +1652,13 @@ mod bind_confirm_tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 31ba0cba..d95dd047 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -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, Changed)>, ), commands: Res, - cooldowns: Res, + cooldowns: Res, spells: Option>, // 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). diff --git a/crates/benilla-app/src/ui_loot/mod.rs b/crates/benilla-app/src/ui_loot/mod.rs index f6dcccc7..03a456c9 100644 --- a/crates/benilla-app/src/ui_loot/mod.rs +++ b/crates/benilla-app/src/ui_loot/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_merchant/mod.rs b/crates/benilla-app/src/ui_merchant/mod.rs index 9bb36ea5..d7663580 100644 --- a/crates/benilla-app/src/ui_merchant/mod.rs +++ b/crates/benilla-app/src/ui_merchant/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index c424ccd4..15da2830 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -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. diff --git a/crates/benilla-app/src/ui_party/net.rs b/crates/benilla-app/src/ui_party/net.rs index c80b1e24..eb157348 100644 --- a/crates/benilla-app/src/ui_party/net.rs +++ b/crates/benilla-app/src/ui_party/net.rs @@ -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 diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index 38dcea7c..fdf10334 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -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; diff --git a/crates/benilla-app/src/ui_pet/net.rs b/crates/benilla-app/src/ui_pet/net.rs index 3883a47f..d1e0b4b5 100644 --- a/crates/benilla-app/src/ui_pet/net.rs +++ b/crates/benilla-app/src/ui_pet/net.rs @@ -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); diff --git a/crates/benilla-app/src/ui_pet_stats.rs b/crates/benilla-app/src/ui_pet_stats.rs index 553a9b79..6d431808 100644 --- a/crates/benilla-app/src/ui_pet_stats.rs +++ b/crates/benilla-app/src/ui_pet_stats.rs @@ -732,8 +732,8 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() + .init_resource::() + .init_resource::() .add_message::() .add_message::() .insert_resource(NetCommands(tx)) diff --git a/crates/benilla-app/src/ui_reputation.rs b/crates/benilla-app/src/ui_reputation.rs index 18b8d436..8f275412 100644 --- a/crates/benilla-app/src/ui_reputation.rs +++ b/crates/benilla-app/src/ui_reputation.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index 161498cb..7d63c7e3 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -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 { - 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 { - 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::("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::("return Bagnon.size").expect("Bagnon.size"); + for i in 1..=size { let name = format!("BagnonItem{i}"); if s.eval::(&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` 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::(&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::("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::("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::("return MainMenuBarBackpackButton:GetChecked() and true or false") + !s.eval::("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)" ); } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index e746d231..abbea161 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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::(); - 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, diff --git a/crates/benilla-app/src/ui_script/quiver_tests.rs b/crates/benilla-app/src/ui_script/quiver_tests.rs index 12a6d5c3..3a2b9050 100644 --- a/crates/benilla-app/src/ui_script/quiver_tests.rs +++ b/crates/benilla-app/src/ui_script/quiver_tests.rs @@ -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 { - 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 { - 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; } diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index c783efe3..4193d39e 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -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. diff --git a/crates/benilla-app/src/ui_shapeshift.rs b/crates/benilla-app/src/ui_shapeshift.rs index f84f8049..7163d7a6 100644 --- a/crates/benilla-app/src/ui_shapeshift.rs +++ b/crates/benilla-app/src/ui_shapeshift.rs @@ -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, commands: Res, clock: Res, - spell_mods: Res, + spell_mods: Res, mut memory: Local>, ) { let Some(mut script) = script else { diff --git a/crates/benilla-app/src/ui_spellbook.rs b/crates/benilla-app/src/ui_spellbook.rs index 83b4031e..d80cf0e7 100644 --- a/crates/benilla-app/src/ui_spellbook.rs +++ b/crates/benilla-app/src/ui_spellbook.rs @@ -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, icons: Option>, commands: Res, - cooldowns: Res, + cooldowns: Res, clock: Res, mut memory: Local>, mut learned: ResMut, diff --git a/crates/benilla-app/src/ui_talent.rs b/crates/benilla-app/src/ui_talent.rs index 93a0b3af..337d9e24 100644 --- a/crates/benilla-app/src/ui_talent.rs +++ b/crates/benilla-app/src/ui_talent.rs @@ -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]); diff --git a/crates/benilla-app/src/ui_taxi/mod.rs b/crates/benilla-app/src/ui_taxi/mod.rs index e4a1e28c..ff9cf6f1 100644 --- a/crates/benilla-app/src/ui_taxi/mod.rs +++ b/crates/benilla-app/src/ui_taxi/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 797aab59..99ee5178 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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, + Res, ), commands: Res, mut memory: Local>, diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index d19a2e01..d7c6bce9 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -21,7 +21,7 @@ struct TestCtx { text: Box, /// 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 diff --git a/crates/benilla-app/src/ui_tradeskill.rs b/crates/benilla-app/src/ui_tradeskill.rs index da98230a..b5663dbe 100644 --- a/crates/benilla-app/src/ui_tradeskill.rs +++ b/crates/benilla-app/src/ui_tradeskill.rs @@ -251,7 +251,7 @@ fn resolve_recipe( store: &ObjectStore, items: &Items, commands: &NetCommands, - cooldowns: &crate::cooldowns::Cooldowns, + cooldowns: &crate::spell::Cooldowns, now: Instant, ) -> Option { let d = spells.catalog.get(spell_id)?; @@ -377,7 +377,7 @@ fn feed_trade_skill( self_store: Query<&ObjectStore, With>, items: Res, commands: Res, - cooldowns: Res, + cooldowns: Res, mut last: Local>>, ) { let Some(mut script) = script else { diff --git a/crates/benilla-app/src/ui_trainer/tests.rs b/crates/benilla-app/src/ui_trainer/tests.rs index 481b5e3a..bba7307c 100644 --- a/crates/benilla-app/src/ui_trainer/tests.rs +++ b/crates/benilla-app/src/ui_trainer/tests.rs @@ -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!( diff --git a/crates/benilla-app/tests/message_keys.rs b/crates/benilla-app/tests/message_keys.rs index 25a673b3..06d070b5 100644 --- a/crates/benilla-app/tests/message_keys.rs +++ b/crates/benilla-app/tests/message_keys.rs @@ -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* diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 907e4b95..9c051e0f 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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 diff --git a/crates/benilla-dbc/src/lib.rs b/crates/benilla-dbc/src/lib.rs index 37617f42..4fb76d42 100644 --- a/crates/benilla-dbc/src/lib.rs +++ b/crates/benilla-dbc/src/lib.rs @@ -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()); + } } diff --git a/crates/benilla-formats/src/install.rs b/crates/benilla-formats/src/install.rs index cc92210a..4eff968d 100644 --- a/crates/benilla-formats/src/install.rs +++ b/crates/benilla-formats/src/install.rs @@ -127,6 +127,9 @@ fn candidates_from(override_dir: Option, exe_dir: Option) -> 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 +/// **`/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 { + 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 { + 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) -> Vec { + 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::::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")]); + } } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 875cce89..918d2cb6 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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, diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index dd42f83b..c0a27198 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -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 { position: [f32; 3], times: Vec, } - let mut model_events: Vec = 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 = + 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 { 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( 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 { 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 diff --git a/crates/benilla-formats/src/ribbons.rs b/crates/benilla-formats/src/ribbons.rs index 71d8bbb4..d86d6498 100644 --- a/crates/benilla-formats/src/ribbons.rs +++ b/crates/benilla-formats/src/ribbons.rs @@ -264,6 +264,14 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result> { }; 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> { 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 diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 4e534b93..41005181 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -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 { diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index a3318312..4ff5d4fa 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -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>"); + } } diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs index ecab2523..0c0acc6a 100644 --- a/crates/benilla-m2/src/lib.rs +++ b/crates/benilla-m2/src/lib.rs @@ -288,11 +288,18 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { // 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 { // 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 { // 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), diff --git a/crates/benilla-m2/src/tests.rs b/crates/benilla-m2/src/tests.rs index b0c62ce4..c4f13bd6 100644 --- a/crates/benilla-m2/src/tests.rs +++ b/crates/benilla-m2/src/tests.rs @@ -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(); diff --git a/crates/benilla-mpq/src/lib.rs b/crates/benilla-mpq/src/lib.rs index 8b40451b..eebc1270 100644 --- a/crates/benilla-mpq/src/lib.rs +++ b/crates/benilla-mpq/src/lib.rs @@ -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 { 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 { + 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 { 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); + } } diff --git a/crates/benilla-protocol/src/messages/update_object/movement.rs b/crates/benilla-protocol/src/messages/update_object/movement.rs index 6e770110..12cc4282 100644 --- a/crates/benilla-protocol/src/messages/update_object/movement.rs +++ b/crates/benilla-protocol/src/messages/update_object/movement.rs @@ -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 { diff --git a/crates/benilla-wdt/src/lib.rs b/crates/benilla-wdt/src/lib.rs index 4af13b4f..0028a968 100644 --- a/crates/benilla-wdt/src/lib.rs +++ b/crates/benilla-wdt/src/lib.rs @@ -151,9 +151,31 @@ impl WdtReader { } } + /// 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> { + 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 { + // 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> = None; let mut wmo_only = false; let mut wmo_path: Option = None; @@ -169,8 +191,7 @@ impl WdtReader { // 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 WdtReader { // 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 WdtReader { } // 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"); + } + } } diff --git a/crates/benilla-world/Cargo.toml b/crates/benilla-world/Cargo.toml index eadf82f7..22634e54 100644 --- a/crates/benilla-world/Cargo.toml +++ b/crates/benilla-world/Cargo.toml @@ -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. diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index ff7fc4ce..baeb596f 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -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 = 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=` 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> = 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()), } } } diff --git a/crates/benilla-world/src/wmo_portal/probe.rs b/crates/benilla-world/src/wmo_portal/probe.rs index 7643001d..d7c0d329 100644 --- a/crates/benilla-world/src/wmo_portal/probe.rs +++ b/crates/benilla-world/src/wmo_portal/probe.rs @@ -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=` is the headless spelling. + pub dump_to: Option, /// **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 {