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:
Generated
-2
@@ -513,7 +513,6 @@ dependencies = [
|
||||
"benilla-formats",
|
||||
"benilla-protocol",
|
||||
"benilla-ui",
|
||||
"benilla-wmo",
|
||||
"benilla-world",
|
||||
"bevy",
|
||||
"bevy_egui",
|
||||
@@ -674,7 +673,6 @@ dependencies = [
|
||||
"bevy",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
+3
-3
@@ -57,7 +57,7 @@ sha1 = "0.10.6"
|
||||
#
|
||||
# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's
|
||||
# addon VM runs on, and benilla's Lua has to accept EXACTLY the grammar the 1.12.1 client's Lua
|
||||
# accepts — no more and no less. Six hunks, all byte-read out of the client's own parser and VM:
|
||||
# accepts — no more and no less. The hunks — `third_party/lua-src/BENILLA.md` lists them — all byte-read out of the client's own parser and VM:
|
||||
# three restore what 5.1 removed (the iterator-less generic-for `for k, v in someTable do`, which
|
||||
# 183 of 218 corpus addons are reached by; nesting long strings; the constructor's compat
|
||||
# semicolon), three delete what 5.1 added (`...` as an expression, `#`, `%`). Both directions are
|
||||
@@ -105,11 +105,11 @@ lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of
|
||||
# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes
|
||||
# hundreds of thousands of lines (243k when this was measured), and release is non-incremental by cargo's default, so a one-line edit re-optimizes
|
||||
# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild
|
||||
# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it
|
||||
# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental`
|
||||
# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and
|
||||
# holds the workspace members and zero registry deps), so the ~760 dependencies stay non-incremental and
|
||||
# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched.
|
||||
#
|
||||
# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count
|
||||
|
||||
@@ -35,11 +35,11 @@ and no bundled game assets.
|
||||
- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live
|
||||
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank,
|
||||
loot, the auction house and PvP honor.
|
||||
- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and
|
||||
character screens through the full HUD, the classic windows (guild, macros and key bindings
|
||||
included), chat, nameplates, floating combat text and tooltips; third-party addons load from
|
||||
a `benilla-config/AddOns/` folder beside the executable (partial: AtlasLoot and Bagnon run,
|
||||
see below).
|
||||
- **UI:** a from-scratch FrameXML + Lua engine that runs the client's own stock interface off
|
||||
your install's patch chain, from the login and character screens through the full HUD, the
|
||||
classic windows (guild, macros and key bindings included), chat, nameplates, floating combat
|
||||
text and tooltips; third-party addons load from a `benilla-config/AddOns/` folder beside the
|
||||
executable (partial: AtlasLoot and Bagnon run).
|
||||
- **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and
|
||||
cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline.
|
||||
- **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with
|
||||
@@ -82,9 +82,11 @@ Early inspiration and file format guidance came from the
|
||||
|
||||
benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment.
|
||||
It ships **no Blizzard content** — no art, models, sounds, maps, MPQ contents or FrameXML; you
|
||||
provide your own legally obtained 1.12.1 client. The interface code under
|
||||
`crates/benilla-app/assets/ui/` is ours, written to the client's own layout and API names so that
|
||||
the windows look right and 1.12.1 addons find the names they expect.
|
||||
provide your own legally obtained 1.12.1 client. The stock interface runs off your own install's
|
||||
FrameXML at runtime; the handful of files under `crates/benilla-app/assets/ui/` are our own
|
||||
adapters and developer frames, not copies of it.
|
||||
|
||||
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
|
||||
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option.
|
||||
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option. The two vendored components
|
||||
under `third_party/` — the kira audio engine, and a Lua 5.1 patched to the 1.12 client's dialect —
|
||||
keep their own upstream licenses, alongside each.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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>();
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>,
|
||||
) {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+26
-1196
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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user