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

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

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