diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 5f027c32..664d6c1a 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -92,6 +92,7 @@ mod phase_probe; mod pick_probe; mod probe_auction; mod probe_bank; +mod probe_bg; mod probe_bg_queue; mod probe_binder; mod probe_book; @@ -124,6 +125,7 @@ pub(crate) use phase_probe::PhaseProbePlugin; pub(crate) use pick_probe::PickProbePlugin; pub(crate) use probe_auction::ProbeAuctionPlugin; pub(crate) use probe_bank::ProbeBankPlugin; +pub(crate) use probe_bg::ProbeBgPlugin; pub(crate) use probe_bg_queue::ProbeBgQueuePlugin; pub(crate) use probe_binder::ProbeBinderPlugin; pub(crate) use probe_book::ProbeBookPlugin; diff --git a/crates/benilla-app/src/capture/probe_bg.rs b/crates/benilla-app/src/capture/probe_bg.rs new file mode 100644 index 00000000..a1f05310 --- /dev/null +++ b/crates/benilla-app/src/capture/probe_bg.rs @@ -0,0 +1,1098 @@ +//! The **inside-a-battleground** live probe (`WOW_PROBE_BG=wsg|ab|av`) — the instrument that turns +//! "does a battleground work?" from a question nobody can answer into a census anyone can read. +//! Inert without the env. +//! +//! ## Why this exists +//! +//! Decisions 1963 / 1972 / 1974 / 1980 built the whole battleground **interface** — the queue, the +//! list window, the scoreboard, the always-up readout, the map blips, forty-odd Lua verbs — and +//! every one of them off the reference's own FrameXML. What none of them could build is the thing +//! on the far side of the port button: **nothing in this tree has ever entered map 489, 529 or +//! 30.** `ProbeBgQueuePlugin` stops at the queue by design (it is 2232's login-burst fixture); +//! the terrain, the world states, the objects, the spirit guides and the match's own end have +//! never been observed at all. So the gaps are not *known* to be gaps — they are unmeasured, and +//! an unmeasured subsystem is where a "should work by construction" quietly isn't. +//! +//! This probe is the measurement. It walks the real player road — level, greet a battlemaster on +//! the wire, queue, take the port through the **stock Lua verb** the reference's own dialog calls +//! — and then, from inside, prints a structured census every [`CENSUS_EVERY`] until +//! [`census_samples`] are in. Every later battleground round is judged against these lines. +//! +//! ## The one server-side lever, and why it is needed +//! +//! A battleground does not start until `min_players_per_team` bodies are queued on each side — +//! four for Warsong Gulch on this server's patch, twenty for Alterac Valley +//! (`mangos.battleground_template`). One probe can never reach that. vmangos has the lever built +//! in: **`.debug bg`** toggles `BattleGroundMgr::m_testing`, and `BattleGroundQueue::CheckNormalMatch` +//! then starts a battleground as soon as *either* side has one player +//! (`BattleGroundMgr.cpp:557/623/756`). It is `SEC_ADMINISTRATOR` (6), exactly what a probe account +//! holds, and it is **in-memory only** — `m_testing` is initialised `false` in the manager's +//! constructor, so a server restart clears it whatever happens here. The probe turns it on at +//! Setup and off again at Report; a crashed run leaves it on until the next restart, which is +//! surprising but harmless (a solo queue would pop). +//! +//! Note that `.debug bg` is a **toggle, not a set**, and it announces itself to the whole realm +//! (`LANG_DEBUG_BG_ON`/`_OFF`). Both facts are reported on the probe's own lines so a run that +//! started from the wrong state reads as such rather than as a broken queue. +//! +//! ## What the census reports, and why each column is there +//! +//! | column | the question it answers | +//! |---|---| +//! | `map` / `area` | did the worldport land, and does the terrain know where we are? | +//! | `pos` | is the body at the battleground's own start location, or at the origin? | +//! | `terrain` | [`WorldLoadProgress`] — did map 489's ADTs stream, or is the ground missing? | +//! | `ents` | units / players / gameobjects / dynamic / corpses actually mirrored into the ECS | +//! | `gos` | the gameobject **entries** in range — the flags, the doors, the banners | +//! | `states` | the raw world-state table and its scope: the score, the flag carriers, the timer | +//! | `alwaysup` | `GetNumWorldStateUI()` — what the reference's readout would actually draw | +//! | `status` | `GetBattlefieldStatus(1..3)`, straight out of the VM | +//! | `score` | `GetNumBattlefieldScores()` / `GetBattlefieldWinner()` | +//! | `guide` | the nearest `SPIRITGUIDE`-flagged unit — the graveyard's resurrect wave | +//! | `lua` | script errors collected since the last sample; a non-zero count is the finding | +//! +//! ## The run recipe +//! +//! ```sh +//! cd && WOW_USER=probe7 WOW_PASS=pprobe7 WOW_CHAR=Probeseven \ +//! WOW_UNATTENDED=1 WOW_NOSOUND=1 WOW_GM=off WOW_PROBE_BG=wsg \ +//! timeout 420 cargo run -p benilla 2>&1 | grep -E 'PROBE bg:' +//! ``` +//! +//! `WOW_GM=off` is not optional here: GM mode re-templates the body's faction to 35, and a +//! battleground is the one place where every reaction, every objective and the server's own team +//! assignment reads off it (0649, 0679). +//! +//! Non-combat — the probe never attacks and never stands anywhere contested; with one player in +//! the instance there is nobody to fight. Pair with the SLOT-KEYED probe identity +//! (`method.md`, "The local vmangos server"). + +use bevy::ecs::system::NonSendMut; +use bevy::prelude::*; + +use benilla_ui::script::UiScript; +use benilla_world::terrain_stream::{CurrentArea, WorldLoadProgress}; +use benilla_world::world_map::CurrentMap; + +use super::probes::ProbeClock; +use crate::net::{ + ChatKind, ClientCommand, DroppedOpcodes, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer, +}; +use crate::player::Player; +use crate::target::cursor_mode::npc_flags; +use crate::ui_battlefield::Battlefield; +use crate::ui_dialog_verbs::BattlefieldQueue; +use crate::world_state::WorldStates; + +/// One battleground's fixtures: what to queue for, and which battlemaster to greet. +/// +/// All three battlemasters stand within ~35 yd of each other in Stormwind's PvP alcove, so one +/// `.go` reaches any of them — which is why the probe finds its NPC by **creature entry** off the +/// guid rather than by "the nearest battlemaster", a predicate that would pick whichever of the +/// three streamed in first. +struct Arena { + /// `WOW_PROBE_BG`'s value. + key: &'static str, + /// The Map.dbc row — what `CMSG_BATTLEMASTER_JOIN` carries and what the worldport must land on. + map: u32, + /// The battlemaster's `creature_template.entry` (vmangos `battlemaster_entry`). + npc_entry: u32, + /// That NPC's display name, for the probe's own lines. + npc_name: &'static str, + /// Its spawn — the `.go xyz` target (vmangos `creature`, map 0, Stormwind). + at: [f32; 3], + /// The objective this battleground's **flag leg** goes for: the gameobject entry to click and + /// where it stands on the battleground map (vmangos `gameobject`). `None` for a battleground + /// whose objective is not a single clickable flag. + flag: Option<(u32, [f32; 3])>, +} + +/// The three 1.12 battlegrounds. Levels are not per-arena: [`QUEUE_LEVEL`] clears every bracket +/// floor on every content patch, so the probe never has to know which patch the server runs. +const ARENAS: [Arena; 3] = [ + Arena { + key: "wsg", + map: 489, + npc_entry: 14981, + npc_name: "Elfarran", + at: [-8454.62, 318.85, 120.97], + // The **Warsong Flag** at the Horde base — the one an Alliance body carries. Our own + // Silverwing Flag (179830) is the capture point, not the pickup. + flag: Some((179831, [916.02, 1434.40, 345.41])), + }, + Arena { + key: "ab", + map: 529, + npc_entry: 15008, + npc_name: "Lady Hoteshem", + at: [-8420.48, 328.71, 120.89], + // Arathi Basin's objective is five capturable banners, not a carried flag. + flag: None, + }, + Arena { + key: "av", + map: 30, + npc_entry: 7410, + npc_name: "Thelman Slatefist", + at: [-8424.55, 342.81, 120.89], + // Alterac Valley's are towers, graveyards and captains. + flag: None, + }, +]; + +/// The level the probe body is raised to. +/// +/// 60 rather than the 25 `probe_bg_queue` uses, because Alterac Valley's floor is **51** on every +/// patch that ships it and the bracket is checked at the HELLO — a body under it is refused there, +/// silently, so the list never arrives and the failure reads like a broken client. +const QUEUE_LEVEL: u32 = 60; + +/// How many times to drop and re-take the queue before giving up (see the `Joined` arm). +const MAX_REJOINS: u32 = 3; + +/// The `.go`'s settle radius before the battlemaster scan. Not a gate — the server applies its own +/// interaction check; this only keeps the probe from greeting a battlemaster it has not reached. +const NEAR_YD: f32 = 20.0; + +/// How often a census line is printed once inside. +const CENSUS_EVERY: f64 = 12.0; + +/// How many census samples to take before reporting and leaving. +/// +/// **Sized by the preparation phase, not by taste.** A battleground does not begin when you land +/// in it: vmangos holds every arrival behind closed doors for `BG_START_DELAY_2M` = **120 s** +/// (`BattleGround.cpp:248`, the four `BG_STARTING_EVENT_*` steps at 2 min / 1 min / 30 s / go), +/// and `.debug bg` does not shorten it. A window that ended before then would census nothing but +/// the pen and report it as the whole battleground. Sixteen × 12 s = **192 s** covers the full +/// prep, the doors dropping, and ~70 s of live match — while staying inside vmangos's +/// `BattleGround.PrematureFinishTimer` (5 min), the clock that ends an under-populated one. +/// Twelve × 12 s = **144 s**: the full prep, the doors dropping at ~116 s, and one sample past it, +/// which leaves room for the graveyard leg inside the same run. +const CENSUS_SAMPLES_DEFAULT: u32 = 12; + +/// The census sample count, overridable with `WOW_PROBE_BG_SAMPLES`. +/// +/// The default covers the preparation phase and the doors. **The reason it is a dial** is the end +/// of a match: with one body inside, vmangos starts its `BattleGround.PrematureFinishTimer` (5 min, +/// because `CreateNewBattleGround` reads `min_players_per_team` from the template and not from the +/// testing override, so a solo match is permanently under-populated) and then ends the battleground +/// with no winner. `WOW_PROBE_BG_SAMPLES=30` is a ~6-minute window, which reaches it — the +/// `MSG_PVP_LOG_DATA` "ended" byte, `GetBattlefieldWinner()` and the automatic scoreboard, all of +/// which are built and none of which anything here has watched. +fn census_samples() -> u32 { + std::env::var("WOW_PROBE_BG_SAMPLES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(CENSUS_SAMPLES_DEFAULT) +} + +/// How often the ghost leg samples, and for how long. The battleground resurrect wave is a 30 s +/// server cycle (`BattleGround::Update`'s `m_lastResurrectTime`), so eleven 4 s samples cover a +/// full wave and a little of the next — enough to see the clock arm, tick down, and fire. +const GHOST_SAMPLE_EVERY: f64 = 4.0; +/// See [`GHOST_SAMPLE_EVERY`]. +const GHOST_SAMPLES: u32 = 11; + +/// How many 3 s samples the flag leg takes after the use. +const FLAG_SAMPLES: u32 = 5; + +/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the reference's area-spirit-healer acquire +/// scan keys on (wow-re `interact-dead-fork-and-npc-service-ladder.md` §C row 6). +const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6; + +/// The **event tap** — a Lua frame the probe installs on entry that records every battleground +/// event the reference's own interface listens for, with its `arg1`. +/// +/// This exists because the first run's finding could not be read off the log at all. vmangos sends +/// three `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines during the countdown (`BattleGround.cpp:383/392/400`, +/// the one-minute / half-minute / has-begun `m_startMessageIds`) and a `SMSG_PLAY_SOUND` +/// (`PlaySoundToAll(SOUND_BG_START)`), and **nothing appeared** — but an absent log line is not an +/// absent event, because a chat kind that reaches the VM logs nothing on the way. Silence in a log +/// is not evidence; a tap on the event itself is. +/// +/// It is written in the reference's own Lua dialect (`this`/`event`/`arg1` globals in `OnEvent`, +/// no `ipairs` over a literal) so it exercises the same road a 1.12 addon would. +const EVENT_TAP: &str = r#" +BenillaBgLog = {}; +BenillaBgTap = CreateFrame("Frame"); +BenillaBgTap:SetScript("OnEvent", function() + table.insert(BenillaBgLog, event .. "(" .. tostring(arg1) .. ")"); +end); +BenillaBgTap:RegisterEvent("CHAT_MSG_BG_SYSTEM_NEUTRAL"); +BenillaBgTap:RegisterEvent("CHAT_MSG_BG_SYSTEM_ALLIANCE"); +BenillaBgTap:RegisterEvent("CHAT_MSG_BG_SYSTEM_HORDE"); +BenillaBgTap:RegisterEvent("CHAT_MSG_SYSTEM"); +BenillaBgTap:RegisterEvent("CHAT_MSG_MONSTER_YELL"); +BenillaBgTap:RegisterEvent("UPDATE_WORLD_STATES"); +BenillaBgTap:RegisterEvent("UPDATE_BATTLEFIELD_STATUS"); +BenillaBgTap:RegisterEvent("UPDATE_BATTLEFIELD_SCORE"); +BenillaBgTap:RegisterEvent("AREA_SPIRIT_HEALER_IN_RANGE"); +BenillaBgTap:RegisterEvent("AREA_SPIRIT_HEALER_OUT_OF_RANGE"); +BenillaBgTap:RegisterEvent("PLAYER_DEAD"); +BenillaBgTap:RegisterEvent("PLAYER_UNGHOST"); +BenillaBgTap:RegisterEvent("PLAYER_ALIVE"); +BenillaBgTap:RegisterEvent("ZONE_CHANGED_NEW_AREA"); +"#; + +/// Drain the tap: everything it recorded since the last census, then empty it. +const EVENT_DRAIN: &str = r#" +local out = ""; +if BenillaBgLog then + for i = 1, table.getn(BenillaBgLog) do out = out .. " " .. BenillaBgLog[i]; end + BenillaBgLog = {}; +end +return out; +"#; + +pub(crate) struct ProbeBgPlugin; + +impl Plugin for ProbeBgPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems(Update, bg_probe); + } +} + +#[derive(Resource, Default)] +struct BgProbe { + phase: Phase, + /// The Lua event tap is in (or failed once and said so). + tap_installed: bool, + /// How many times the queue has been dropped and re-taken (see the `Joined` arm). + rejoins: u32, +} + +/// `Wait` → (levelled, hop sent) `Hopped` → (`.debug bg` sent) `Toggled` → (reply read, testing +/// confirmed on, battlemaster greeted) `Greeted` → +/// (join sent) `Joined` → (the port taken) `Ported` → (map 489 reached) `Inside` → +/// (`.die` sent) `Dying` → (released, ghost at the graveyard) `Ghost` → `Done`. +#[derive(Default, PartialEq)] +enum Phase { + #[default] + Wait, + Hopped { + sent_at: f64, + }, + Toggled { + sent_at: f64, + corrected: bool, + }, + Greeted { + master: u64, + sent_at: f64, + }, + Joined { + sent_at: f64, + }, + Rejoining { + at: f64, + }, + Ported { + sent_at: f64, + }, + Inside { + entered_at: f64, + next_census: f64, + samples: u32, + }, + FlagHop { + at: f64, + }, + FlagUsed { + at: f64, + samples: u32, + }, + Dying { + sent_at: f64, + }, + Ghost { + released_at: f64, + next_sample: f64, + samples: u32, + }, + Done, +} + +/// Which battleground this run is for — `WOW_PROBE_BG`'s value, defaulting to Warsong Gulch (the +/// smallest, fastest and the only one whose objectives fit in one census window). +fn arena() -> &'static Arena { + let want = std::env::var("WOW_PROBE_BG").unwrap_or_default(); + ARENAS + .iter() + .find(|a| a.key.eq_ignore_ascii_case(&want)) + .unwrap_or(&ARENAS[0]) +} + +/// A GM dot-command on the real chat wire; every reply is echoed by `net` as +/// `server says — …`, so a command that did nothing is visible as such. +fn gm(net: &NetCommands, text: impl Into) { + let _ = net.0.send(ClientCommand::Chat { + kind: ChatKind::Say, + target: None, + text: text.into(), + }); +} + +// One Bevy system's full input set — the guard-poi probe's shape, plus the census reads. +#[allow(clippy::too_many_arguments)] +fn bg_probe( + time: ProbeClock, + mut probe: ResMut, + me: Query<&ObjectStore, With>, + player: Res, + battlefield: Res, + queue: Res, + units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>, + net: Res, + map: Option>, + area: Option>, + load: Option>, + states: Res, + dropped: Res, + go_templates: Res, + mut script: Option>, +) { + let Ok(store) = me.single() else { + return; + }; + let arena = arena(); + let now = time.elapsed_secs_f64(); + + // **The tap goes in as soon as there is a VM to put it in, and not before.** The probe's own + // first frame is a world-entry frame: the in-game interface has not materialized yet, so an + // install at Setup gets `no VM` and every later `EVENTS` line reads `(none)` — which looks + // exactly like "the battleground fired no events" and is the most misleading answer this + // instrument could give. Retried every frame until it lands; one bool test once it has. + if !probe.tap_installed { + if let Some(script) = script.as_deref_mut() { + match script.eval::<()>(EVENT_TAP) { + Ok(()) => { + probe.tap_installed = true; + info!("PROBE bg: TAP installed"); + } + Err(e) => { + probe.tap_installed = true; // a raise will not fix itself; say so once + error!("PROBE bg: TAP FAILURE — {e}"); + } + } + } + } + + match probe.phase { + Phase::Wait => { + // Revive first, unconditionally: `CanInteractWithNPC` refuses a dead player outright, + // and the refusal surfaces as an anticheat line about an "invalid creature" — which + // reads like a wrong guid, not like a corpse (`probe_bg_queue`'s note). + gm(&net, ".revive"); + let level = store.0.unit_level().unwrap_or(0); + if level < QUEUE_LEVEL { + gm(&net, format!(".levelup {}", QUEUE_LEVEL - level)); + } + // **Deserter first.** Leaving a battleground any way but "the match ended" earns + // spell **26013** (vmangos `Player.cpp:2178/18816`, `Battleground.CastDeserter` + // defaults true), and `HandleBattlemasterJoinOpcode` then refuses the join outright + // (`BG_GROUPJOIN_DESERTERS`) — so the *second* run of this probe never queues at all, + // and reads as "the queue is broken" rather than "the last run left a debuff". Found + // the hard way; cleared unconditionally, because a body without it does not care. + gm(&net, ".unaura 26013"); + let [x, y, z] = arena.at; + info!( + "PROBE bg: SETUP arena={} map={} level>={QUEUE_LEVEL} master={}({})", + arena.key, arena.map, arena.npc_name, arena.npc_entry, + ); + gm(&net, format!(".go xyz {x} {y} {z} 0")); + probe.phase = Phase::Hopped { sent_at: now }; + } + Phase::Hopped { sent_at } => { + if now - sent_at < 3.0 { + return; // post-teleport settle: let the battlemasters stream in + } + // **The lever goes here, not in Setup, and its reply is read before anything queues.** + // `.debug bg` is a TOGGLE (`BattleGroundMgr::ToggleTesting`), so a blind send is not + // idempotent and a run that died before its own undo flips the next one the wrong way. + // Correcting it *after* joining does not work either, and that is the part worth + // writing down: vmangos evaluates a queue only when something SCHEDULES an update + // (`BattleGroundMgr::Update` drains `m_queueUpdateScheduler`, which a join or a leave + // fills — `BattleGroundMgr.cpp:1005-1028`). Flipping the flag under a queue entry that + // is already sitting there schedules nothing, so the entry waits forever. Hence: get + // the flag right first, then join once. + gm(&net, ".debug bg"); + probe.phase = Phase::Toggled { + sent_at: now, + corrected: false, + }; + } + Phase::Toggled { sent_at, corrected } => { + if now - sent_at < 1.5 { + return; // let the world text come back and reach the tap + } + let seen = script + .as_deref_mut() + .and_then(|s| s.eval::(EVENT_DRAIN).ok()) + .unwrap_or_default(); + if seen.contains("normal playercount") { + if corrected { + error!( + "PROBE bg: FAILURE — `.debug bg` reads OFF after two sends; the account \ + may be below SEC_ADMINISTRATOR (6) for it" + ); + probe.phase = Phase::Done; + return; + } + info!("PROBE bg: TESTING was on; that send turned it OFF — sending once more"); + gm(&net, ".debug bg"); + probe.phase = Phase::Toggled { + sent_at: now, + corrected: true, + }; + return; + } + if seen.contains("debugging") { + info!("PROBE bg: TESTING on (1v0)"); + } else { + warn!( + "PROBE bg: TESTING unread — no `.debug bg` reply in the tap ({seen:?}); \ + continuing, but a queue that never pops is why" + ); + } + let here = player.pos; + // By ENTRY, not by "the nearest battlemaster": all three stand in one alcove. + let master = units.iter().find(|(guid, kind, store, tf)| { + kind.kind == benilla_protocol::EntityKind::Unit + && store.0.unit_npc_flags() & npc_flags::BATTLEMASTER != 0 + && benilla_protocol::guid::entry(guid.0) == Some(arena.npc_entry) + && tf.translation.distance(here) < NEAR_YD + }); + if let Some((guid, ..)) = master { + info!( + "PROBE bg: GREETING {} ({:#x}) on the wire", + arena.npc_name, guid.0 + ); + let _ = net.0.send(ClientCommand::BattlemasterHello { npc: guid.0 }); + probe.phase = Phase::Greeted { + master: guid.0, + sent_at: now, + }; + } else if now - sent_at > 15.0 { + error!( + "PROBE bg: FAILURE — {} (entry {}) never streamed within 15 yd in 15 s", + arena.npc_name, arena.npc_entry + ); + probe.phase = Phase::Done; + } + } + Phase::Greeted { master, sent_at } => { + // Waiting for the LIST, not for a clock: its guid is the one the join must quote, and + // its arrival is the proof the level gate was cleared. + if battlefield.battlemaster() == Some(master) { + info!("PROBE bg: LISTED — queueing for map {}", arena.map); + let _ = net.0.send(ClientCommand::BattlemasterJoin { + battlemaster: master, + map_id: arena.map, + instance_id: 0, + as_group: false, + }); + probe.phase = Phase::Joined { sent_at: now }; + } else if now - sent_at > 8.0 { + error!( + "PROBE bg: FAILURE — no SMSG_BATTLEFIELD_LIST 8 s after the hello \ + (a level under the bracket floor is refused here, silently)" + ); + probe.phase = Phase::Done; + } + } + Phase::Joined { sent_at } => { + // Status 2 is "your battleground is ready, confirm within the deadline". The probe + // takes it through the STOCK VERB, not through a hand-built packet: the whole point + // is that the reference's own dialog road works. + let ready = queue + .slots() + .iter() + .position(|s| s.as_ref().is_some_and(|(st, _)| st.status == 2)); + if let Some(index) = ready { + let slot = index + 1; // the verbs are 1-based + info!("PROBE bg: CONFIRM — slot {slot} is ready; AcceptBattlefieldPort({slot}, 1)"); + if let Some(script) = script { + if let Err(e) = script.eval::<()>(&format!("AcceptBattlefieldPort({slot}, 1)")) + { + error!("PROBE bg: FAILURE — AcceptBattlefieldPort raised: {e}"); + probe.phase = Phase::Done; + return; + } + } else { + error!("PROBE bg: FAILURE — no VM to take the port through"); + probe.phase = Phase::Done; + return; + } + probe.phase = Phase::Ported { sent_at: now }; + } else if now - sent_at > 12.0 && probe.rejoins < MAX_REJOINS { + // **Leave the queue and join again.** vmangos evaluates a queue only when + // something SCHEDULES an update, and a queue entry that was scheduled once and + // did not match just sits there — no timer re-examines it + // (`BattleGroundMgr::Update` drains `m_queueUpdateScheduler` and nothing refills + // it on its own, `BattleGroundMgr.cpp:1005`). Whatever left the server's + // bookkeeping unable to match this entry, a fresh one gets a fresh evaluation. + // Both halves are real client verbs: `AcceptBattlefieldPort(slot, 0)` is the + // queue's own decline, which no other probe or test here has ever walked. + probe.rejoins += 1; + let slot = queue + .slots() + .iter() + .position(|s| s.is_some()) + .map_or(1, |i| i + 1); + info!( + "PROBE bg: REJOIN {} — nothing popped in 12 s; \ + AcceptBattlefieldPort({slot}, 0) then queueing again", + probe.rejoins + ); + if let Some(script) = script.as_deref_mut() { + let _ = script.eval::<()>(&format!("AcceptBattlefieldPort({slot}, 0)")); + } + probe.phase = Phase::Rejoining { at: now }; + } else if now - sent_at > 30.0 { + let slots: Vec = queue + .slots() + .iter() + .map(|s| { + s.as_ref().map_or_else( + || "-".into(), + |(st, _)| format!("{}:{}", st.map_id, st.status), + ) + }) + .collect(); + error!( + "PROBE bg: FAILURE — no slot reached status 2 in 30 s (slots: {}). \ + The testing flag was confirmed ON before the join, so the cause is not that: \ + look for Deserter (26013) on the body, a bracket the server refused, or a \ + battleground this body is still registered in from a killed run.", + slots.join(" ") + ); + probe.phase = Phase::Done; + } + } + Phase::Ported { sent_at } => { + let here = map.as_ref().map_or(0, |m| m.0); + if here == arena.map { + info!( + "PROBE bg: ENTERED map {} after {:.1}s — census every {CENSUS_EVERY:.0}s × {}", + arena.map, + now - sent_at, + census_samples(), + ); + probe.phase = Phase::Inside { + entered_at: now, + next_census: now + CENSUS_EVERY, + samples: 0, + }; + } else if now - sent_at > 30.0 { + error!( + "PROBE bg: FAILURE — 30 s after the port the map is still {here}, not {}", + arena.map + ); + probe.phase = Phase::Done; + } + } + Phase::Inside { + entered_at, + next_census, + samples, + } => { + if now < next_census { + return; + } + let Some(mut script) = script else { + error!("PROBE bg: FAILURE — the VM went away inside the battleground"); + probe.phase = Phase::Done; + return; + }; + census( + now - entered_at, + arena, + &player, + &units, + map.as_deref(), + area.as_deref(), + load.as_deref(), + &states, + &dropped, + &go_templates, + &queue, + &mut script, + ); + let samples = samples + 1; + if samples >= census_samples() { + // **The graveyard leg.** `.die` is the one thing that clears the probe's god + // shield by design (0677), so a probe CAN die on purpose; a battleground death is + // also the only place the area-spirit-healer arc (2291) is reachable at all, and + // a mechanism that has never been watched fire is not a mechanism anyone should + // claim. Non-combat: nothing kills us, we ask to be dead. + match arena.flag { + Some((entry, [x, y, z])) => { + info!("PROBE bg: FLAG — hopping to {entry} at ({x}, {y}, {z}) to take it"); + gm(&net, format!(".go xyz {x} {y} {z} {}", arena.map)); + probe.phase = Phase::FlagHop { at: now }; + } + None => { + info!("PROBE bg: FLAG — none for this battleground; on to the graveyard"); + gm(&net, ".die"); + probe.phase = Phase::Dying { sent_at: now }; + } + } + } else { + probe.phase = Phase::Inside { + entered_at, + next_census: now + CENSUS_EVERY, + samples, + }; + } + } + Phase::Rejoining { at } => { + if now - at < 2.0 { + return; // let the decline land and the slot clear + } + let Some(master) = battlefield.battlemaster() else { + error!("PROBE bg: FAILURE — the battlemaster list went away before the rejoin"); + probe.phase = Phase::Done; + return; + }; + let _ = net.0.send(ClientCommand::BattlemasterJoin { + battlemaster: master, + map_id: arena.map, + instance_id: 0, + as_group: false, + }); + probe.phase = Phase::Joined { sent_at: now }; + } + Phase::FlagHop { at } => { + if now - at < 4.0 { + return; // the hop crosses the map; let the far base stream in + } + let Some((entry, _)) = arena.flag else { + probe.phase = Phase::Done; + return; + }; + let found = units.iter().find(|(guid, kind, _, tf)| { + kind.kind == benilla_protocol::EntityKind::GameObject + && benilla_protocol::guid::entry(guid.0) == Some(entry) + && tf.translation.distance(player.pos) < 15.0 + }); + match found { + Some((guid, _, _, tf)) => { + // What the click's own GameObject ladder would decide, reported rather than + // assumed: a FLAGSTAND carries no lock, so `resolve_go_action` takes its + // `Use` arm and the packet is `CMSG_GAMEOBJ_USE` — the one sent here. + let lock = go_templates.get(guid.0).map_or(u32::MAX, |t| t.lock_id); + info!( + "PROBE bg: FLAG {entry} streamed at {:.1} yd, lock_id={lock} — CMSG_GAMEOBJ_USE", + tf.translation.distance(player.pos) + ); + let _ = net.0.send(ClientCommand::GameObjUse { guid: guid.0 }); + probe.phase = Phase::FlagUsed { + at: now, + samples: 0, + }; + } + None if now - at > 20.0 => { + error!("PROBE bg: FLAG FAILURE — {entry} never streamed within 15 yd in 20 s"); + gm(&net, ".die"); + probe.phase = Phase::Dying { sent_at: now }; + } + None => {} + } + } + Phase::FlagUsed { at, samples } => { + if now - at < f64::from(samples + 1) * 3.0 { + return; + } + let Some(script) = script else { + probe.phase = Phase::Done; + return; + }; + // **The two readings that say the flag is on the body.** World state 2339 is the + // ALLIANCE readout row's icon selector, and it is only ever 1 or 2 — 2 meaning the + // flag that row is about is being carried (vmangos `BattleGroundWS.cpp:512` and + // `FillInitialWorldStates` 713-722). It is **not** the four-value `m_flagState` enum, + // which never reaches the client at all; that distinction is why a census reading + // `2338=1 2339=1` for a whole match is correct rather than a stuck value. The aura is + // the other half: what the reference actually draws on the body. + let auras = script + .eval::( + r#" + local out = ""; + for i = 1, 16 do + local t = UnitBuff("player", i); + if t then out = out .. " " .. t; end + end + return out; + "#, + ) + .unwrap_or_else(|e| format!("")); + let events = script.eval::(EVENT_DRAIN).unwrap_or_default(); + info!( + "PROBE bg: FLAG t={:.0}s ws2338={} ws2339={} captures={}/{} buffs=[{}]{}", + now - at, + states.get(2338), + states.get(2339), + states.get(1581), + states.get(1582), + auras.trim(), + if events.trim().is_empty() { + String::new() + } else { + format!(" events={events}") + }, + ); + let samples = samples + 1; + if samples >= FLAG_SAMPLES { + info!("PROBE bg: FLAG done — on to the graveyard"); + gm(&net, ".die"); + probe.phase = Phase::Dying { sent_at: now }; + } else { + probe.phase = Phase::FlagUsed { at, samples }; + } + } + Phase::Dying { sent_at } => { + if store.0.unit_is_dead() { + info!("PROBE bg: DEAD — releasing (CMSG_REPOP_REQUEST)"); + let _ = net.0.send(ClientCommand::RepopRequest); + probe.phase = Phase::Ghost { + released_at: now, + next_sample: now + GHOST_SAMPLE_EVERY, + samples: 0, + }; + } else if now - sent_at > 15.0 { + error!( + "PROBE bg: FAILURE — still alive 15 s after `.die` (is the shield re-armed?)" + ); + probe.phase = Phase::Done; + } + } + Phase::Ghost { + released_at, + next_sample, + samples, + } => { + if now < next_sample { + return; + } + let Some(mut script) = script else { + probe.phase = Phase::Done; + return; + }; + // The whole point of this leg, in four readings: are we a ghost, is a spirit guide in + // range, did the engine adopt it (`GetAreaSpiritHealerTime` is non-zero only once + // `SMSG_AREA_SPIRIT_HEALER_TIME` answered the query the adopt sent), and did the + // stock dialog's event fire. + let ghost = store.0.player_is_ghost(); + let nearest = units + .iter() + .filter(|(_, k, st, _)| { + k.kind == benilla_protocol::EntityKind::Unit + && st.0.unit_npc_flags() & NPC_FLAG_SPIRITGUIDE != 0 + }) + .map(|(g, _, _, tf)| (g.0, tf.translation.distance(player.pos))) + .min_by(|a, b| a.1.total_cmp(&b.1)); + let wave = script + .eval::( + "return tostring(GetAreaSpiritHealerTime()) .. \" popup=\" .. \ + tostring(StaticPopup_Visible and StaticPopup_Visible(\"AREA_SPIRIT_HEAL\"))", + ) + .unwrap_or_else(|e| format!("")); + let events = script.eval::(EVENT_DRAIN).unwrap_or_default(); + info!( + "PROBE bg: GHOST t={:.0}s ghost={ghost} pos=({:.0},{:.0},{:.0}) area={:?} guide={} wave={wave}{}", + now - released_at, + player.pos.x, + player.pos.y, + player.pos.z, + area.as_deref().and_then(|a| a.0), + nearest.map_or_else( + || "none".to_string(), + |(g, d)| format!("{g:#x}@{d:.1}yd") + ), + if events.trim().is_empty() { + String::new() + } else { + format!(" events={events}") + }, + ); + let samples = samples + 1; + if samples >= GHOST_SAMPLES { + // Take the wave if one is offered — the verb that was permanently silent before + // 2291, and the only way back to the world from a battleground graveyard. + match script.eval::<()>("AcceptAreaSpiritHeal()") { + Ok(()) => info!("PROBE bg: GHOST AcceptAreaSpiritHeal() sent"), + Err(e) => error!("PROBE bg: GHOST AcceptAreaSpiritHeal() raised: {e}"), + } + report(arena, &net, &queue, &mut script); + probe.phase = Phase::Done; + } else { + probe.phase = Phase::Ghost { + released_at, + next_sample: now + GHOST_SAMPLE_EVERY, + samples, + }; + } + } + Phase::Done => {} + } +} + +/// One census sample — eleven greppable `PROBE bg:` lines describing everything the client can see +/// from inside the battleground. Deliberately verbose: this is the first look anyone has had, and +/// a column nobody needed is cheaper than a round trip for one that was left out. +#[allow(clippy::too_many_arguments)] +fn census( + t: f64, + arena: &Arena, + player: &Player, + units: &Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>, + map: Option<&CurrentMap>, + area: Option<&CurrentArea>, + load: Option<&WorldLoadProgress>, + states: &WorldStates, + dropped: &DroppedOpcodes, + go_templates: &crate::go_templates::GameObjectTemplates, + queue: &BattlefieldQueue, + script: &mut UiScript, +) { + use benilla_protocol::EntityKind as K; + + let p = player.pos; + let terrain = load.map_or_else( + || "no-progress".to_string(), + |l| { + format!( + "{}/{} focus={} scene={} colliders={}", + l.ready, l.total, l.focus_resident, l.scene_ready, l.colliders_pending + ) + }, + ); + info!( + "PROBE bg: CENSUS t={t:.0}s map={} area={:?} pos=({:.0},{:.0},{:.0}) terrain={terrain}", + map.map_or(0, |m| m.0), + area.and_then(|a| a.0), + p.x, + p.y, + p.z, + ); + + // Entities, by kind — "is there a world here at all?" + let (mut u, mut pl, mut go, mut dy, mut co) = (0, 0, 0, 0, 0); + let mut go_entries: Vec<(u32, Option)> = Vec::new(); + let mut nearest_guide: Option<(u64, f32)> = None; + for (guid, kind, store, tf) in units.iter() { + match kind.kind { + K::Unit => { + u += 1; + if store.0.unit_npc_flags() & NPC_FLAG_SPIRITGUIDE != 0 { + let d = tf.translation.distance(p); + if nearest_guide.is_none_or(|(_, best)| d < best) { + nearest_guide = Some((guid.0, d)); + } + } + } + K::Player => pl += 1, + K::GameObject => { + go += 1; + if let Some(e) = benilla_protocol::guid::entry(guid.0) { + // The NAME, not just the entry: "179918" is a number to look up in a database + // and `Doodad_PortcullisActive01` is the battleground's gate. The cache is the + // ask-once `GAMEOBJECT_QUERY` store (0239) the client already fills for every + // GO that streams in, so this costs nothing and answers for free. + go_entries.push((e, go_templates.get(guid.0).map(|t| t.name.clone()))); + } + } + K::DynamicObject => dy += 1, + K::Corpse => co += 1, + K::Other => {} + } + } + go_entries.sort_unstable(); + let mut counted: Vec = Vec::new(); + let mut i = 0; + while i < go_entries.len() { + let (e, ref name) = go_entries[i]; + let n = go_entries[i..].iter().take_while(|(x, _)| *x == e).count(); + let label = name + .as_deref() + .map_or_else(|| e.to_string(), |n| format!("{e}:{n}")); + counted.push(if n > 1 { + format!("{label}×{n}") + } else { + label + }); + i += n; + } + info!("PROBE bg: ENTS units={u} players={pl} gos={go} dyn={dy} corpses={co}"); + info!( + "PROBE bg: GOS [{}]", + if counted.is_empty() { + "none".to_string() + } else { + counted.join(" ") + } + ); + info!( + "PROBE bg: GUIDE {}", + nearest_guide.map_or_else( + || "none in range".to_string(), + |(g, d)| format!("{g:#x} at {d:.1} yd") + ) + ); + + // The world-state table — the battleground's whole score, as raw dwords. + let mut pairs: Vec<(u32, i32)> = states.pairs().collect(); + pairs.sort_unstable_by_key(|(k, _)| *k); + info!( + "PROBE bg: STATES scope={:?} n={} [{}]", + states.scope(), + pairs.len(), + pairs + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") + ); + + // What the reference's own readout would draw, straight out of the VM. + let alwaysup = script + .eval::( + r#" + local n = GetNumWorldStateUI() + local out = tostring(n) + for i = 1, n do + local ui, state, hidden, text, icon = GetWorldStateUIInfo(i) + out = out .. " | " .. tostring(state) .. " '" .. tostring(text) .. "' icon=" .. tostring(icon) + end + return out + "#, + ) + .unwrap_or_else(|e| format!("")); + info!("PROBE bg: ALWAYSUP {alwaysup}"); + + let status = script + .eval::( + r#" + local out = "" + for i = 1, 3 do + local s, name, id, lo, hi = GetBattlefieldStatus(i) + out = out .. i .. "=" .. tostring(s) .. "/" .. tostring(name) .. "/" .. tostring(id) .. " " + end + return out .. "runtime=" .. tostring(GetBattlefieldInstanceRunTime()) + "#, + ) + .unwrap_or_else(|e| format!("")); + info!( + "PROBE bg: STATUS {status} active_map={:?}", + queue.active_map() + ); + + let score = script + .eval::( + r#" + RequestBattlefieldScoreData() + return tostring(GetNumBattlefieldScores()) .. " winner=" .. tostring(GetBattlefieldWinner()) + "#, + ) + .unwrap_or_else(|e| format!("")); + info!("PROBE bg: SCORE n={score}"); + + // **The wire-coverage tally** — every opcode the codec threw on the floor, by name. This is + // the column the first run did not have and most needed: a battleground that sends something + // benilla has no arm for is invisible in every other reading here, and reads as "nothing + // happened" rather than as a gap. `DroppedOpcodes` is never cleared, so the numbers are + // cumulative over the whole session — a row that grows between samples is the finding. + let mut drops: Vec<(u16, u64, u64)> = dropped + .0 + .iter() + .map(|(&op, t)| (op, t.unknown, t.unparseable)) + .collect(); + drops.sort_unstable_by_key(|(op, _, _)| *op); + info!( + "PROBE bg: DROPPED [{}]", + if drops.is_empty() { + "none".to_string() + } else { + drops + .iter() + .map(|(op, unk, bad)| { + let name = benilla_protocol::messages::opcode_name(*op) + .map_or_else(|| format!("{op:#06x}"), str::to_string); + format!("{name}:unknown={unk},unparseable={bad}") + }) + .collect::>() + .join(" ") + } + ); + + // The event tap's window (see `EVENT_TAP`): what the VM was actually told since the last + // sample. An empty window while the server is running the start countdown is a finding. + let events = script + .eval::(EVENT_DRAIN) + .unwrap_or_else(|e| format!("")); + info!( + "PROBE bg: EVENTS{}", + if events.trim().is_empty() { + " (none)".to_string() + } else { + events + } + ); + + let errors = script.take_errors(); + if errors.is_empty() { + info!("PROBE bg: LUA clean"); + } else { + for e in &errors { + error!("PROBE bg: LUA ERROR {e}"); + } + } + let _ = arena; +} + +/// The run's last act: drive the two battleground windows the census cannot reach by watching +/// (they are opened by a keybind, never by an event), then put the server's testing toggle back +/// and leave the battleground the way a player does. +fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &mut UiScript) { + // The scoreboard and the battlefield minimap: both are LoadOnDemand-shaped roads nothing in + // this tree has ever walked. A raise here is the finding. + for (what, chunk) in [ + ("SCOREFRAME", "ToggleWorldStateScoreFrame()"), + ("BFMINIMAP", "ToggleBattlefieldMinimap()"), + ] { + match script.eval::<()>(chunk) { + Ok(()) => { + let shown = script + .eval::(&format!( + "return tostring({}:IsShown())", + if what == "SCOREFRAME" { + "WorldStateScoreFrame" + } else { + "BattlefieldMinimap" + } + )) + .unwrap_or_else(|e| format!("")); + info!("PROBE bg: {what} {chunk} ok, IsShown={shown}"); + } + Err(e) => error!("PROBE bg: {what} FAILURE — {chunk} raised: {e}"), + } + } + for e in script.take_errors() { + error!("PROBE bg: LUA ERROR {e}"); + } + + // `LeaveBattlefield` is gated client-side on the scoreboard's "ended" byte (1972), so while a + // match is live the verb is correctly silent — which means the honest way out is the same one + // a player uses when they give up on a battleground: the GM teleport home. The queue slot the + // server still holds is cleared by the leave it sends on the map change. + info!( + "PROBE bg: LEAVING map {} (active_map={:?})", + arena.map, + queue.active_map() + ); + let _ = script.eval::<()>("LeaveBattlefield()"); + gm(net, ".recall"); + // Put the lever back. A toggle, so this run's second `.debug bg` undoes this run's first. + gm(net, ".debug bg"); + info!("PROBE bg: DONE — `.debug bg` toggled back off"); +} diff --git a/crates/benilla-app/src/capture/probe_env.rs b/crates/benilla-app/src/capture/probe_env.rs index 581bb198..cf37ede9 100644 --- a/crates/benilla-app/src/capture/probe_env.rs +++ b/crates/benilla-app/src/capture/probe_env.rs @@ -213,6 +213,16 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[ // ── The `=1` live probes: each parks the body somewhere real and steps a phase machine ─── // Every one below reads `time.elapsed_secs_f64()` into a `since`/`now` phase machine — // waits, settles and timeouts in wall seconds — so each arms the occlusion defence. + ProbeVar { + name: "WOW_PROBE_BG_SAMPLES", + purpose: "n — how many 12 s census samples WOW_PROBE_BG takes inside the battleground (default 12; ~30 reaches vmangos's 5-minute premature finish, i.e. the end of a match)", + wall_clock: false, + }, + ProbeVar { + name: "WOW_PROBE_BG", + purpose: "wsg|ab|av — queue for that battleground, take the port through the stock verb and census the instance from inside", + wall_clock: true, + }, ProbeVar { name: "WOW_PROBE_BGQUEUE", purpose: "1 — level past the bracket floor, greet Stormwind's Warsong Gulch battlemaster and queue through his list", diff --git a/crates/benilla-app/src/capture/probe_service.rs b/crates/benilla-app/src/capture/probe_service.rs index a31e13ef..eb32d358 100644 --- a/crates/benilla-app/src/capture/probe_service.rs +++ b/crates/benilla-app/src/capture/probe_service.rs @@ -518,6 +518,9 @@ fn service_probe( let why: &str = match other { ServiceAction::AskBinder => "CONFIRM_BINDER, no packet", ServiceAction::AskSpiritHealer => "CONFIRM_XP_LOSS, no packet", + ServiceAction::AcquireSpiritGuide => { + "adopts the area spirit healer; the packet leaves from the cache" + } ServiceAction::Silent(w) => w, ServiceAction::Send(_) | ServiceAction::SellFromCursor(_) => { unreachable!() diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index d373396d..b1532881 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -319,6 +319,14 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE_BGQUEUE").is_ok() { app.add_plugins(crate::capture::ProbeBgQueuePlugin); } + // The inside-a-battleground live probe: `WOW_PROBE_BG=wsg|ab|av` walks the whole + // player road — level, greet, queue, take the port through the stock + // `AcceptBattlefieldPort` — and then censuses the battleground from inside. The + // interface arc (1963/1972/1974/1980) built everything up to the port button and + // nothing past it; this is the first instrument that looks (see `capture::ProbeBgPlugin`). + if std::env::var("WOW_PROBE_BG").is_ok() { + app.add_plugins(crate::capture::ProbeBgPlugin); + } // The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the // Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through // the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`). diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index eb7252fa..a972cdfb 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -823,7 +823,7 @@ pub(crate) mod schedule_tests { /// **Measured 2026-09-17 (decision 2287), 662 systems:** 16,548 pairs in all, 12,849 of /// them explained — 10,703 on the VM alone, 431 on the other non-`Send` owners, 556 on /// the pure caches, 87 on the sinks, 41 on the stream, 1,031 on a mix of those — and - /// 3,699 actionable. The largest part of those is `Transform` on disjoint lanes (716 pairs + /// 3,705 actionable. The largest part of those is `Transform` on disjoint lanes (716 pairs /// alone): populations that never intersect, which the filter algebra cannot see. A lane /// is not a class — the checker cannot tell a disjoint lane from two writers of the same /// entity — so they stay here, and each new one is a claim made at the registration with @@ -851,10 +851,22 @@ pub(crate) mod schedule_tests { /// the pairs over the pure caches moved into the VM-only class, where the same systems still /// meet over the VM alone. /// + /// **3,290 (decision 2291)** — the area-spirit-healer poll's `&Transform` read against the frame's + /// movers (motion, transports, the portrait booths, the quest markers' bake): **+6**, measured + /// on the rebased tree rather than assumed additive, the same discipline 2295 used one + /// paragraph up. Its one write-write pair — against the click, which also holds + /// `AreaSpiritHealer` — is *declared*, and declared where the reference puts it: after the + /// frame's pick, because `0x4923b0` runs from the same `CGWorldFrame` OnUpdate that ran the + /// pick eighty bytes earlier at `0x48184a`. What is left is reading a unit's position one + /// mover early or late, which moves a 20 yd acquire decision by at most one frame of walking + /// — and the poll is **level**-triggered, re-deriving the whole cache every frame, so a + /// boundary case decided early is decided again next frame. The target scanner carries the + /// same class for the same reason. + /// /// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with /// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is /// about a resource that commutes by construction, the claim belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 3_284; + const UPDATE_ACTIONABLE_CEILING: usize = 3_290; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 093ba035..5f701bde 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -1323,6 +1323,13 @@ pub(crate) enum ClientCommand { AreaSpiritHealerQueue { healer: u64, }, + /// The client ADOPTED a new area spirit healer — `CMSG_AREA_SPIRIT_HEALER_QUERY`, asking for + /// its wave clock. No Lua verb sends this: its only writer is the reference's own + /// "set current area spirit healer" routine `0x4921c0`, reached from the per-frame proximity + /// poll and from the spirit-guide click arm ([`crate::ui_dialog_verbs::AreaSpiritHealer`]). + AreaSpiritHealerQuery { + healer: u64, + }, /// `AcceptBattlefieldPort(index, accept)` — `CMSG_BATTLEFIELD_PORT`: the slot's map id and /// the answer as one byte. BattlefieldPort { diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 4036d3c4..41baf964 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -1386,6 +1386,9 @@ fn writer_loop( ClientCommand::AreaSpiritHealerQueue { healer } => { w.area_spirit_healer_queue(healer) } + ClientCommand::AreaSpiritHealerQuery { healer } => { + w.area_spirit_healer_query(healer) + } ClientCommand::BattlefieldPort { map_id, accept } => { w.battlefield_port(map_id, accept) } diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 344b22cd..89fce85e 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -236,6 +236,11 @@ pub(crate) struct ServiceArms<'w> { /// The spirit healer's XP-loss question (bit 5): `0x5df730` does the same, firing /// `CONFIRM_XP_LOSS`. pub(crate) death: ResMut<'w, crate::death::DeathNet>, + /// The spirit GUIDE's arm (bit 6) — the battleground graveyard's resurrect wave. Unlike its + /// two no-packet neighbours this one does send, and it sends through the cache the per-frame + /// proximity poll also writes, so the click and the poll cannot disagree about which healer + /// is current. + pub(crate) spirit: ResMut<'w, crate::ui_dialog_verbs::AreaSpiritHealer>, /// `[0xb4e2d0]/[0xb4e2d4]`'s mirror — the NPC whose window is open, which the dispatcher /// compares the clicked guid against *before* the ladder ([`interaction_already_open_on`]). pub(crate) interact: Res<'w, crate::ui_session::InteractNpc>, @@ -944,6 +949,24 @@ pub(super) fn act_on_right_click( debug!("right-click interact: {guid:#x} (spirit healer — CONFIRM_XP_LOSS, no packet)"); service.death.ask_spirit_healer(guid); } + ServiceAction::AcquireSpiritGuide => { + debug!("right-click interact: {guid:#x} (spirit guide — adopting, 0x2E2)"); + let outcome = service.spirit.click_guide(guid); + if outcome.cancel_aura { + if let Some(script) = service.script.as_deref_mut() { + script.fire_event("AREA_SPIRIT_HEALER_OUT_OF_RANGE", vec![]); + } + let _ = seam.net.0.send(ClientCommand::CancelAura { + spell_id: crate::ui_dialog_verbs::AREA_SPIRIT_HEALER_AURA, + }); + } + if let Some(healer) = outcome.query { + let _ = seam + .net + .0 + .send(ClientCommand::AreaSpiritHealerQuery { healer }); + } + } ServiceAction::Silent(why) => { debug!("right-click interact: {guid:#x} ({arm:?}) — silent: {why}"); } @@ -1380,6 +1403,9 @@ pub(crate) enum ServiceAction { AskBinder, /// Raise `CONFIRM_XP_LOSS` locally and send nothing (`0x5df730`). AskSpiritHealer, + /// Adopt this spirit guide as the current-area spirit healer (`0x5df950` → two calls into + /// `0x4921c0`, the second of which always transmits) — `CMSG_AREA_SPIRIT_HEALER_QUERY`. + AcquireSpiritGuide, /// Nothing goes out. The payload is why, for the debug line. Silent(&'static str), } @@ -1430,13 +1456,13 @@ pub(crate) fn service_action( ServiceArm::Trainer => ServiceAction::Send(ClientCommand::TrainerList { trainer: guid }), ServiceArm::SpiritHealer if ghost => ServiceAction::AskSpiritHealer, ServiceArm::SpiritHealer => ServiceAction::Silent("spirit healer, and we are alive"), - // Ghost-gated like its neighbour, and then a stated gap: the ghost's arm sends opcode - // `0x2E2` (the area spirit-healer time query) from one call deeper, and benilla has no - // battleground resurrect timer for the reply to fill. For a living player this IS the - // reference's answer; for a ghost it is the gap. - ServiceArm::SpiritGuide if ghost => { - ServiceAction::Silent("spirit guide — the 0x2E2 timer query is unbuilt") - } + // Ghost-gated like its neighbour. The arm sends `CMSG_AREA_SPIRIT_HEALER_QUERY 0x2E2` + // from one call deeper (`0x5df950` → `0x4921c0`), and it does it through the *same* + // "set current area spirit healer" routine the per-frame proximity poll uses — with a + // deliberate `(0,0)` cache-bust first, so a click on the guide you are already standing + // next to re-asks for the wave clock instead of being swallowed by the routine's + // unchanged-guid early return. + ServiceArm::SpiritGuide if ghost => ServiceAction::AcquireSpiritGuide, ServiceArm::SpiritGuide => ServiceAction::Silent("spirit guide, and we are alive"), ServiceArm::Innkeeper => ServiceAction::AskBinder, ServiceArm::Banker => ServiceAction::Send(ClientCommand::BankerActivate { guid }), @@ -1922,6 +1948,7 @@ mod tests { ServiceAction::SellFromCursor(cmd) => format!("sell {cmd:?}"), ServiceAction::AskBinder => "ask-binder".to_string(), ServiceAction::AskSpiritHealer => "ask-spirit-healer".to_string(), + ServiceAction::AcquireSpiritGuide => "acquire-spirit-guide".to_string(), ServiceAction::Silent(_) => "silent".to_string(), }; assert!(matches!( @@ -2026,6 +2053,7 @@ mod tests { ServiceAction::SellFromCursor(cmd) => format!("SELL {cmd:?}"), ServiceAction::AskBinder => "ask-binder".into(), ServiceAction::AskSpiritHealer => "ask-xp-loss".into(), + ServiceAction::AcquireSpiritGuide => "acquire-spirit-guide".into(), ServiceAction::Silent(w) => format!("silent {w}"), }; let empty = describe(service_action(arm, VENDOR, ghost, None)); @@ -2336,6 +2364,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); diff --git a/crates/benilla-app/src/ui_dialog_verbs.rs b/crates/benilla-app/src/ui_dialog_verbs.rs index e2e9501c..73e33b9f 100644 --- a/crates/benilla-app/src/ui_dialog_verbs.rs +++ b/crates/benilla-app/src/ui_dialog_verbs.rs @@ -41,6 +41,7 @@ use std::time::Instant; use benilla_protocol::messages::{BattlefieldStatus, MeetingStoneNotice}; use benilla_ui::script::{ScriptValue, UiScript}; +use bevy::ecs::system::NonSendMut; use bevy::prelude::*; use crate::area::AreaTableRes; @@ -120,10 +121,49 @@ impl InstanceBoot { } } +/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the acquire callback `0x4924c0` tests at +/// `0x4924fc shr eax,0x6; test al,1` (wow-re `interact-dead-fork-and-npc-service-ladder.md` §C). +const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6; + +/// The area spirit healer's aura, `0xA18` = 2584 — the one spell `0x4921c0`'s cancel leg and +/// `CancelAreaSpiritHeal` both name, and the only spell id `0x6e7040` fires +/// `AREA_SPIRIT_HEALER_OUT_OF_RANGE` for (`0x6e70b6 cmp esi,0xa18`). +pub(crate) const AREA_SPIRIT_HEALER_AURA: u32 = 2584; + +/// The area spirit healer's **acquire** radius — the `.rdata` f32 `[0x8044d0] = 20.0`, compared +/// squared in the enumerate callback `0x4924c0`. +const SPIRIT_GUIDE_ACQUIRE_YD: f32 = 20.0; +/// The **retain** radius: the same f32 times `[0x804580] = 1.1`, i.e. 22.0 (`0x492406`). A healer +/// is adopted inside 20 yd and kept until 22 — the hysteresis is the reference's, and without it +/// a body standing on the boundary would send a query every frame it jittered across. +const SPIRIT_GUIDE_RETAIN_YD: f32 = SPIRIT_GUIDE_ACQUIRE_YD * 1.1; + +/// What [`AreaSpiritHealer::set_healer`] owes the rest of the frame. +/// +/// The reference does both of these inside `0x4921c0` itself; here the resource is a plain data +/// type with no access to the VM or the socket, so it *reports* them and the systems pay them. +#[derive(Default, PartialEq, Eq, Debug)] +pub(crate) struct SetHealerOutcome { + /// A wave deadline was pending and the healer changed, so `0x4921fc mov ecx,0xa18; + /// call 0x6e7040` ran: `AREA_SPIRIT_HEALER_OUT_OF_RANGE` fires and `CMSG_CANCEL_AURA(2584)` + /// goes out. **This is how walking away from a graveyard closes the wave dialog** — no Lua + /// call is involved, which is why the event has no argument and no caller. + pub(crate) cancel_aura: bool, + /// The newly adopted healer, if the change landed on a non-zero guid: `CMSG 0x2E2` with it. + pub(crate) query: Option, +} + /// The current-area spirit healer (`[0xb4e330/334]`) and its wave clock (`[0xb4e338]`). +/// +/// **The writer is [`Self::set_healer`], and it is the reference's `0x4921c0` to the branch.** +/// 1963 shipped this resource with the note "no writer yet" because the acquire side was thought +/// to be uncarved; it is not — wow-re recorded the whole trio, in two different nodes, before that +/// record landed (`ui/scratch/staticpopup-dialog-bindings.md` §6 for the setter and the poll's +/// radii, `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C row 6 for the +/// click arm). Both roads in are built here. #[derive(Resource, Default)] pub(crate) struct AreaSpiritHealer { - /// The cached healer. No writer yet — see the module doc. + /// The cached healer (`[0xb4e330/334]`), written only by [`Self::set_healer`]. healer: Option, deadline: Option, in_range: bool, @@ -139,6 +179,60 @@ impl AreaSpiritHealer { } } + /// **`0x4921c0` — "set current area spirit healer"**, decoded at the branch: + /// + /// ```text + /// if (cached == new) return; // 0x4921cf/0x4921dd — guards EVERYTHING below + /// cached = new; // 0x4921e5 / 0x4921f5 + /// if (deadline != 0) CancelAura(0xA18); // 0x4921fc — fires _OUT_OF_RANGE, sends 0x136 + /// deadline = 0; // 0x492211 + /// if (cached != 0) send CMSG 0x2E2; // 0x492217 / 0x492219 + /// ``` + /// + /// The early return at the top is the load-bearing part and the one a paraphrase loses: with + /// the guid unchanged this routine does **nothing at all** — no cancel, no deadline clear, no + /// packet. That is what lets the per-frame poll call it unconditionally (it clears by calling + /// `set_healer(None)` every frame it has no ghost) at zero cost. + pub(crate) fn set_healer(&mut self, new: Option) -> SetHealerOutcome { + // The reference keeps a 0:0 guid where we keep `None`; normalise so a zero guid arriving + // from the wire cannot masquerade as a real healer. + let new = new.filter(|&g| g != 0); + if self.healer == new { + return SetHealerOutcome::default(); + } + self.healer = new; + let cancel_aura = self.deadline.take().is_some(); + if cancel_aura { + // The cancel closes the dialog, so the `_IN_RANGE` this frame would have owed is + // stale — the reference cannot have one pending here either (the event is fired from + // the handler, which runs the poll first). + self.in_range = false; + } + SetHealerOutcome { + cancel_aura, + query: new, + } + } + + /// The **spirit-guide click arm**'s two calls (`0x5df950`: `0x4921c0(0,0)` then + /// `0x4921c0(guid)`). The first is a deliberate cache-bust — its own early return suppresses + /// the send for a zero guid — so the second **always** transmits, even for the healer already + /// cached. Clicking the guide you are standing next to therefore re-asks for the clock, which + /// is exactly what a player does when the dialog has been dismissed. + pub(crate) fn click_guide(&mut self, guid: u64) -> SetHealerOutcome { + let bust = self.set_healer(None); + let set = self.set_healer(Some(guid)); + SetHealerOutcome { + cancel_aura: bust.cancel_aura || set.cancel_aura, + query: set.query, + } + } + + /// The cached healer — `AcceptAreaSpiritHeal`'s guid and the poll's retain subject. + pub(crate) fn healer(&self) -> Option { + self.healer + } + fn secs(&self, now: Instant) -> u32 { self.deadline .map(|d| d.saturating_duration_since(now).as_secs()) @@ -734,7 +828,7 @@ pub(crate) fn feed_dialog_verbs( } let secs = spirit.secs(now); - script.set_area_spirit_healer(spirit.healer.is_some(), secs); + script.set_area_spirit_healer(spirit.healer().is_some(), secs); if std::mem::take(&mut spirit.in_range) { script.fire_event("AREA_SPIRIT_HEALER_IN_RANGE", vec![]); } @@ -791,7 +885,7 @@ fn drain_latch_verbs( // AcceptAreaSpiritHeal: the cached healer's guid (the binding was silent without one). let accepts = script.take_area_spirit_accepts(); - if let Some(healer) = spirit.healer { + if let Some(healer) = spirit.healer() { for _ in 0..accepts { let _ = commands .0 @@ -843,6 +937,127 @@ fn drain_queue_verbs( } } +/// **The per-frame area-spirit-healer poll — `0x4923b0`**, which the reference runs from +/// `CGWorldFrame`'s own `OnUpdate` (`0x4818ca`) on **every frame**, mouse focus or not. +/// +/// ```text +/// player = the active player object; none -> set_healer(None); return +/// not a GHOST ([[player+0xe68]+8] bit 4) -> set_healer(None); return +/// if cached: +/// the cached guid no longer resolves to a unit -> set_healer(None) +/// else d²(player, healer) > 22² -> set_healer(None) +/// if still cached: return // 0x492487 +/// enumerate every object: the first (last, really — // 0x492495 +/// the callback never stops the walk) unit that is +/// SPIRITGUIDE, CanAssist, and within 20 yd -> set_healer(it) +/// ``` +/// +/// **Ghost-gated at the top**, so a living player never holds a healer and never sends a query — +/// the graveyard's wave clock only exists for the dead. That gate is why this costs nothing in +/// ordinary play: one flag read per frame and out. +/// +/// The callback `0x4924c0` returns 1 unconditionally, so the enumeration is **not** stopped by a +/// match — the last qualifying unit in enumeration order is the one that sticks. `set_healer`'s +/// own early return makes that harmless for a stable set (only a genuine change sends), and +/// reproducing "last wins" rather than "nearest wins" matters at a graveyard with two guides in +/// range: the reference does not pick the closer one, and neither does this. +fn poll_area_spirit_healer( + mut spirit: ResMut, + me: Query<(&ObjectStore, &Transform), With>, + units: Query< + ( + &crate::net::Guid, + &crate::net::NetEntity, + &ObjectStore, + &Transform, + ), + Without, + >, + factions: Option>, + reputations: Res, + index: Option>, + stores: Query<&ObjectStore>, + commands: Res, + mut script: Option>, +) { + let mut apply = |outcome: SetHealerOutcome| { + if outcome.cancel_aura { + // `0x6e7040(0xA18)` does both halves, and both are the reference's: the event with no + // argument, and the packet with no guid. + if let Some(script) = script.as_deref_mut() { + script.fire_event("AREA_SPIRIT_HEALER_OUT_OF_RANGE", vec![]); + } + let _ = commands.0.send(ClientCommand::CancelAura { + spell_id: AREA_SPIRIT_HEALER_AURA, + }); + } + if let Some(healer) = outcome.query { + let _ = commands + .0 + .send(ClientCommand::AreaSpiritHealerQuery { healer }); + } + }; + + let Ok((self_store, self_tf)) = me.single() else { + apply(spirit.set_healer(None)); + return; + }; + if !self_store.0.player_is_ghost() { + apply(spirit.set_healer(None)); + return; + } + let here = self_tf.translation; + + // The retain leg. A cached guid that no longer streams to us is dropped exactly as one that + // walked out of range is — the reference's `ObjectPtr` miss falls into the same clear. + if let Some(cached) = spirit.healer() { + let still = units.iter().find(|(guid, ..)| guid.0 == cached); + let keep = still.is_some_and(|(_, _, _, tf)| { + tf.translation.distance_squared(here) <= SPIRIT_GUIDE_RETAIN_YD * SPIRIT_GUIDE_RETAIN_YD + }); + if !keep { + apply(spirit.set_healer(None)); + } + } + if spirit.healer().is_some() { + return; + } + + // The acquire walk. `can_assist` is `0x6066f0`, the same predicate the target scanner and the + // buff gate already run — its owner chase wants a store by guid, which is what the index is + // for. + let store_of = |guid: u64| -> Option { + let entity = *index.as_ref()?.0.get(&guid)?; + stores.get(entity).ok().cloned() + }; + let mut adopted = None; + for (guid, kind, store, tf) in units.iter() { + if kind.kind != benilla_protocol::EntityKind::Unit { + continue; + } + if store.0.unit_npc_flags() & NPC_FLAG_SPIRITGUIDE == 0 { + continue; + } + if tf.translation.distance_squared(here) > SPIRIT_GUIDE_ACQUIRE_YD * SPIRIT_GUIDE_ACQUIRE_YD + { + continue; + } + if !crate::target::can_assist( + Some(store), + factions.as_deref(), + &reputations, + Some(self_store), + store_of, + ) { + continue; + } + adopted = Some(guid.0); // last wins — the callback never stops the walk + } + if let Some(guid) = adopted { + apply(spirit.set_healer(Some(guid))); + } +} + pub(crate) struct UiDialogVerbsPlugin; impl Plugin for UiDialogVerbsPlugin { @@ -867,6 +1082,34 @@ impl Plugin for UiDialogVerbsPlugin { feed_dialog_verbs .before(UiInput) .run_if(crate::ui_script::ingame_ui_up), + // **After the frame's pick, which is the reference's own order.** The poll + // is `0x4923b0`, called from `CGWorldFrame`'s OnUpdate at `0x4818ca` — and + // that same function ran the mouse pick eighty bytes earlier, at `0x48184a`. + // So a right-click that adopts a spirit guide is decided BEFORE the poll gets + // to look, not after; ordering it `.after(TargetUpdate)` (the set + // `act_on_right_click` chains inside) reproduces that, and settles the one + // real write-write pair this system has — both it and the click's + // `ServiceArms` hold `AreaSpiritHealer`. + // + // The cost is that an adopt reaches `feed_dialog_verbs` on the NEXT frame + // rather than this one, because `UiInput` sits before `WorldStage::Input` and + // `TargetUpdate` after it — the two orders cannot both hold. That is the + // right way round to lose: the event this system fires itself + // (`_OUT_OF_RANGE`) is immediate, and what lags is one frame of a clock the + // server ticks in seconds. + // + // **Gated on the interface being up** like its neighbour, and safely so: this + // poll is LEVEL-triggered, not edge-triggered. It re-derives the whole cache + // from the ghost flag, the streamed guides and the distances every frame, so a + // frame it does not run is a frame it simply has not got to yet — the very + // next one adopts and queries. That is what makes it exempt in substance from + // the one-shot loss 2214's window causes (2220/2232): there is no edge here to + // lose. The `_OUT_OF_RANGE` it can fire is the one thing that needs the VM, + // and it can only follow an adopt this same system made. + poll_area_spirit_healer + .after(crate::target::TargetUpdate) + .run_if(crate::ui_script::ingame_ui_up) + .in_set(crate::char_select::InWorldGated), // In-world only: the claim is about the VM, but the query is a world // packet, and the boot VM exists at the glue screen too. meeting_stone_enter_world @@ -894,10 +1137,11 @@ impl Plugin for UiDialogVerbsPlugin { } } -/// The area spirit healer's aura, `0xA18` — `CancelAreaSpiritHeal`'s one spell (the engine's -/// `dialog_verbs::AREA_SPIRIT_HEALER_SPELL`), read here only to check its flags against the data. +/// The area spirit healer's aura as the test below names it — the same 2584 as +/// [`AREA_SPIRIT_HEALER_AURA`], kept under its own name because the test is about +/// `CancelAreaSpiritHeal`'s spell and the constant above is about `0x4921c0`'s. #[cfg(test)] -const AREA_SPIRIT_HEALER_SPELL: u32 = 2584; +const AREA_SPIRIT_HEALER_SPELL: u32 = AREA_SPIRIT_HEALER_AURA; /// The generic cancel-aura routine's refusal (`0x6e7040`, wow-re `staticpopup-dialog-bindings.md` /// §6): it returns without sending when the spell's `AttributesEx` has bit 13 set and bit 2 @@ -915,6 +1159,122 @@ mod tests { /// `UNIT_FIELD_LEVEL`, absolute field 34. const LEVEL_FIELD: u16 = 34; + /// **`0x4921c0`'s early return guards everything.** The first two compares + /// (`0x4921cf cmp ecx,eax` / `0x4921dd cmp edx,ecx`) jump straight to the epilogue at + /// `0x492282` when the guid is unchanged — so a repeat call sends no packet, cancels no aura + /// and does **not** clear the pending deadline. That last one is the part a paraphrase loses, + /// and it is what lets the per-frame poll call this routine unconditionally. + #[test] + fn setting_the_same_healer_is_a_complete_no_op() { + let mut spirit = AreaSpiritHealer::default(); + assert_eq!( + spirit.set_healer(Some(7)), + SetHealerOutcome { + cancel_aura: false, + query: Some(7) + }, + "a fresh adopt asks for the clock" + ); + let now = Instant::now(); + spirit.on_time(7, 30_000, now); + assert_eq!(spirit.secs(now), 30, "the wave clock is armed"); + + assert_eq!( + spirit.set_healer(Some(7)), + SetHealerOutcome::default(), + "the same guid again: no query, no cancel" + ); + assert_eq!( + spirit.secs(now), + 30, + "and the deadline SURVIVES — 0x492211's clear sits past the early return" + ); + } + + /// The change legs, in the order the routine runs them: a pending deadline is cancelled + /// (`0x4921fc`, which is how walking out of range fires `_OUT_OF_RANGE` with no Lua call), + /// the deadline is zeroed unconditionally (`0x492211`), and only a **non-zero** new guid + /// sends (`0x492217`'s `je`). + #[test] + fn dropping_a_healer_cancels_the_wave_and_sends_nothing() { + let mut spirit = AreaSpiritHealer::default(); + spirit.set_healer(Some(7)); + let now = Instant::now(); + spirit.on_time(7, 30_000, now); + + assert_eq!( + spirit.set_healer(None), + SetHealerOutcome { + cancel_aura: true, + query: None + }, + "walking away: the aura cancel, and no query for a zero guid" + ); + assert_eq!(spirit.secs(now), 0, "the clock is zeroed"); + assert_eq!(spirit.healer(), None); + + // And a drop with no clock armed cancels nothing. + spirit.set_healer(Some(9)); + assert_eq!( + spirit.set_healer(None), + SetHealerOutcome { + cancel_aura: false, + query: None + }, + "no deadline pending — 0x4921fa's `je` skips the cancel" + ); + } + + /// `SMSG_AREA_SPIRIT_HEALER_TIME` is addressed: a clock for a healer that is **not** the + /// cached one is dropped (`0x4922a0 cmp eax,[ebp+8]`), which is what keeps a stale reply from + /// a graveyard you already left out of the dialog. + #[test] + fn a_clock_for_another_healer_is_ignored() { + let mut spirit = AreaSpiritHealer::default(); + spirit.set_healer(Some(7)); + let now = Instant::now(); + spirit.on_time(8, 30_000, now); + assert_eq!(spirit.secs(now), 0, "not our healer"); + spirit.on_time(7, 0, now); + assert_eq!(spirit.secs(now), 0, "a zero time arms nothing"); + spirit.on_time(7, 25_000, now); + assert_eq!(spirit.secs(now), 25); + } + + /// **The click's cache-bust.** `0x5df950` calls the setter twice — `(0,0)` then the guid — + /// precisely so the second call is never swallowed by the unchanged-guid early return. So + /// clicking the guide you are already standing next to DOES re-ask for the clock, which is + /// the behaviour a player relies on after dismissing the dialog. + #[test] + fn clicking_the_cached_guide_still_asks_again() { + let mut spirit = AreaSpiritHealer::default(); + spirit.set_healer(Some(7)); + let now = Instant::now(); + spirit.on_time(7, 30_000, now); + + let outcome = spirit.click_guide(7); + assert_eq!( + outcome.query, + Some(7), + "the bust makes the second call a real change" + ); + assert!( + outcome.cancel_aura, + "and the bust's own leg cancelled the pending wave" + ); + assert_eq!(spirit.healer(), Some(7)); + } + + /// The two radii are the reference's `.rdata` f32s, not round numbers someone picked: + /// `[0x8044d0] = 20.0` to acquire and `× [0x804580] = 1.1` to retain. The hysteresis is the + /// point — equal radii would re-send a query every frame a body jittered across the boundary. + #[test] + fn the_acquire_and_retain_radii_are_the_reference_pair() { + assert!((SPIRIT_GUIDE_ACQUIRE_YD - 20.0).abs() < f32::EPSILON); + assert!((SPIRIT_GUIDE_RETAIN_YD - 22.0).abs() < 1e-5); + const { assert!(SPIRIT_GUIDE_RETAIN_YD > SPIRIT_GUIDE_ACQUIRE_YD) }; + } + /// The four client-side refusals of MEETINGSTONE(23)'s use slot (`0x5f69d0`, decision 2283), /// in the reference's own order — and the pass that reaches `CMSG 0x292`. #[test] diff --git a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs new file mode 100644 index 00000000..5ef0c935 --- /dev/null +++ b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs @@ -0,0 +1,1109 @@ +//! The **battle map** — `Blizzard_BattlefieldMinimap`, the reference's own LoadOnDemand addon, +//! driven through the road stock `UIParent.lua` drives it down: `ToggleBattlefieldMinimap()` +//! (SHIFT-M) → `BattlefieldMinimap_LoadUI()` → `UIParentLoadAddOn`. +//! +//! benilla has registered the addon as a chain row since 1957 and a live run confirmed it loads — +//! and that was the whole of the evidence. Nothing had ever checked that its twelve overlay +//! textures get cut from `GetMapOverlayInfo`, that its POI pool grows, that the battleground blips +//! and the flag carrier reach it, that its arrow is the *mini* singleton rather than the world +//! map's, or that its tab, dropdown and opacity slider work at all. This file is that check: the +//! whole addon, top to bottom, against the engine verbs it actually calls. +//! +//! **The harness is the whole manifest, not a cut dependency prefix** — the deliberate difference +//! from `tradeskill_frame.rs` / `talent_frame.rs` / `auction_frame.rs`, whose windows each sit on +//! a handful of FrameXML files. This one reads `MiniMapBattlefieldFrame.status` (Minimap.xml), +//! `GetNumWorldStateUI` and `SHOW_BATTLEFIELD_MINIMAP` (WorldStateFrame.lua), `UIOptionsFrame` +//! (UIOptionsFrame.xml), `OpacityFrameSlider` (ColorPickerFrame.xml), the `WorldMapUnitTemplate` +//! family (WorldMapFrameTemplates.xml) and `MAX_PARTY_MEMBERS`/`MAX_RAID_MEMBERS` — five corners +//! of the manifest, which is exactly the state a player is in when they press SHIFT-M. It also +//! lives *in* the crate rather than under `tests/`, because the LoadOnDemand road needs +//! [`super::test_ui::seat_chain_addon`] and an integration test cannot reach it (see +//! `tests/common/mod.rs`'s own note on the `#[cfg(test)]` boundary). + +use benilla_ui::script::{ + BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, UnitState, + WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView, + WorldStateUiView, ARROW_MODEL, +}; + +/// **The manifest's own warning floor** — one row, and it is a decided permanent gap rather than +/// anything this addon does: stock `OptionsFrame.lua:300` reads `GetCVar("gxRefresh")` and 2177 +/// keeps that CVar unregistered on purpose, because nothing here can set a refresh rate. +/// `world_entry_tests`' own `KNOWN` list carries the same row for the same reason. Pinning the +/// *exact* set rather than allowing any warning is what makes [`quiet`] a real assertion: a +/// warning the addon causes shows up as a second row. +const MANIFEST_WARNINGS: [&str; 1] = ["unknown CVar 'gxRefresh' (not host-registered) — ignored"]; + +/// Zero Lua errors, and no host warning the manifest did not already carry. +fn quiet(s: &UiScript) { + assert!(s.errors().is_empty(), "script errors: {:#?}", s.errors()); + assert_eq!( + s.warnings(), + MANIFEST_WARNINGS, + "host warnings beyond the manifest's own" + ); +} + +/// The full interface with the addon **registered but not loaded** — the state a character is in +/// the moment before SHIFT-M. +/// +/// The player exists because the in-game UI materializes on world entry (1051) and several stock +/// OnLoads format `UnitName("player")` into their labels; a manifest load with no player is a +/// state the client never reaches (1848). +fn session() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.set_unit( + "player", + Some(UnitState { + exists: true, + name: Some("Probefour".into()), + level: 60, + ..Default::default() + }), + ); + let failures = super::load_default_ui(&s); + assert!(failures.is_empty(), "manifest load errors: {failures:#?}"); + super::test_ui::seat_chain_addon(&mut s, "Blizzard_BattlefieldMinimap"); + s.resolve(); + quiet(&s); + s +} + +/// The arrow file's facts, as the host hands them over (2007/2015) — `MinimapArrow.m2`'s one +/// looping 3.333 s Stand and its header box, which the implicit rect and the re-centring read. +/// Seated *after* the manifest on purpose: the world map's own arrow is created during that load, +/// so this is the mini arrow's facts and only the mini arrow's. +fn arrow_facts(s: &mut UiScript) { + use benilla_ui::widget::{ModelFileFacts, SequenceFacts}; + s.set_model_facts( + ARROW_MODEL, + ModelFileFacts { + sequences: vec![SequenceFacts { + anim_id: 0, + duration_ms: 3333, + looping: true, + }], + bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]), + cameras: 0, + }, + ); +} + +/// One `WorldMapOverlay` row, revealed by a single explore bit. +fn overlay(texture: &str, w: u32, h: u32, ox: u32, oy: u32, bit: u32) -> WorldMapOverlayView { + WorldMapOverlayView { + texture: format!("Interface\\WorldMap\\WarsongGulch\\{texture}"), + width: w, + height: h, + offset_x: ox, + offset_y: oy, + explore_bits: vec![bit], + hit_rect: (0, 0, 0, 0), + area_name: None, + } +} + +/// A one-zone catalog whose zone **has overlays that make the slicing loop do real work**. +/// +/// The sizes are chosen so both arms of the reference's tile walk run. `SilverwingHold` is +/// 300×200: `ceil(300/256) = 2` wide, `ceil(200/256) = 1` tall, so its second column takes the +/// remainder arm (`mod(300,256) = 44` px in a 64-px power-of-two file) while the first takes the +/// full-tile arm, and its single row takes the remainder arm vertically (`mod(200,256) = 200` in a +/// 256-px file). `WarsongLumberMill` is exactly 256×256 — the one case where *both* `mod`s come +/// out zero and the reference's `if ( texturePixelWidth == 0 ) then texturePixelWidth = 256` guard +/// is what keeps the tile from collapsing. Between them they need three textures, which is also +/// what makes the second half of [`the_overlay_textures_are_cut_from_the_revealed_overlays`] — the +/// tail that parks — observable. +fn catalog() -> Vec { + vec![WorldMapContinentView { + name: "Kalimdor".into(), + map_file: "Kalimdor".into(), + world_rect: (0.0, 0.0, 1.0, 1.0), + loc_rect: (10000.0, -10000.0, 10000.0, -10000.0), + zone_grid: Vec::new(), + zones: vec![WorldMapZoneView { + name: "Warsong Gulch".into(), + area_id: 3277, + map_file: "WarsongGulch".into(), + loc_rect: (1000.0, 0.0, 1000.0, 0.0), + overlays: vec![ + overlay("SilverwingHold", 300, 200, 100, 50, 1), + overlay("WarsongLumberMill", 256, 256, 400, 300, 2), + ], + }], + }] +} + +/// Put the session in a battleground and press SHIFT-M. +/// +/// The world-state push is the gate, not scenery: `BattlefieldMinimap_Toggle` shows nothing unless +/// `MiniMapBattlefieldFrame.status == "active"` **or** `GetNumWorldStateUI() > 0`, which is the +/// reference refusing to open a battle map outside a battle. The catalog + explored bitset put the +/// displayed map on a zone that has revealed overlays, and `player_zone` is what the window's own +/// `` `SetMapToCurrentZone()` lands on. +fn open(s: &mut UiScript) { + s.set_world_map_catalog(catalog()); + s.set_world_map_explored(vec![0b110; 64]); + s.set_world_map_feed( + Some((1, 1)), + Some((0.5, 0.5)), + 0.0, + None, + Vec::new(), + Vec::new(), + ); + s.set_world_state_ui(vec![WorldStateUiView { + ui_state: 1, + text: "Flags captured: 2".into(), + ..Default::default() + }]); + s.tick(0.0); + s.fire_event("UPDATE_WORLD_STATES", vec![]); + s.run("ToggleBattlefieldMinimap()").expect("SHIFT-M"); + assert!( + s.eval::("return BattlefieldMinimap:IsShown()") + .unwrap(), + "the battle map is up" + ); +} + +/// The window's own per-frame update, driven the way its `` script runs it: `this` is +/// the frame and the elapsed time its one argument. +fn update(s: &mut UiScript, elapsed: f64) { + s.run(&format!( + "this = BattlefieldMinimap BattlefieldMinimap_OnUpdate({elapsed}) this = nil" + )) + .unwrap(); +} + +/// A frame's centre in the screen units the pointer speaks (its own layout centre × its effective +/// scale) — what a hit test and a `mouse_move` want. +fn screen_centre(s: &mut UiScript, frame: &str) -> (f32, f32) { + s.resolve(); + let (x, y, eff) = s + .eval::<(f64, f64, f64)>(&format!( + "local a, b = {frame}:GetCenter() return a, b, {frame}:GetEffectiveScale()" + )) + .unwrap_or_else(|e| panic!("{frame}:GetCenter() — {e}")); + ((x * eff) as f32, (y * eff) as f32) +} + +/// The mini arrow's screen centre. It is anonymous and it is the window's **last** child (its +/// `Create…` runs in the ``, after the XML's own forty-eight), so it is found by kind +/// rather than by name or position. +fn arrow_centre(s: &mut UiScript) -> (f32, f32) { + s.resolve(); + let (x, y, eff) = s + .eval::<(f64, f64, f64)>( + r#"for _, c in ipairs({ BattlefieldMinimap:GetChildren() }) do + if c:GetObjectType() == "Model" then + local a, b = c:GetCenter() + return a, b, c:GetEffectiveScale() + end + end"#, + ) + .expect("the battle map has a Model child"); + ((x * eff) as f32, (y * eff) as f32) +} + +fn shown(s: &UiScript, frame: &str) -> bool { + s.eval::(&format!("return {frame}:IsShown()")) + .unwrap_or_else(|e| panic!("{frame}:IsShown() — {e}")) +} + +fn num(s: &UiScript, expr: &str) -> f64 { + s.eval::(&format!("return {expr}")) + .unwrap_or_else(|e| panic!("{expr} — {e}")) +} + +/// **The addon loads on demand and every frame its XML declares materializes.** +/// +/// Driven down the reference's own road — `ToggleBattlefieldMinimap()` is what SHIFT-M is bound +/// to, and its first line is `BattlefieldMinimap_LoadUI()` → `UIParentLoadAddOn(...)` → the +/// registry's `LoadAddOn`. Loading it any other way (running the `.xml` as a chain file, the way +/// the auction and tradeskill suites load theirs) would skip the registry, `ADDON_LOADED` and its +/// `arg1` filter — and the `ADDON_LOADED` arm is where the tab gets its seat, the dropdown gets +/// initialized and the opacity gets applied, so it would skip most of the window's initial state +/// too. +/// +/// The gate is asserted in both directions first: the load happens either way, but a battle map +/// opens only inside a battle. +#[test] +fn the_addon_loads_on_demand_through_uiparents_own_road() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + assert_eq!( + s.eval::>("return IsAddOnLoaded(\"Blizzard_BattlefieldMinimap\")") + .unwrap(), + None, + "nothing is loaded before the first toggle" + ); + + // Out of a battleground: the addon still loads — `BattlefieldMinimap_LoadUI()` runs + // unconditionally — and then `BattlefieldMinimap_Toggle`'s own gate declines to show it. + s.run("ToggleBattlefieldMinimap()").unwrap(); + assert_eq!( + s.eval::("return IsAddOnLoaded(\"Blizzard_BattlefieldMinimap\")") + .unwrap(), + 1, + "the demand load ran" + ); + assert!( + !shown(&s, "BattlefieldMinimap"), + "…and a battle map does not open outside a battle: no active queue, no world-state rows" + ); + assert_eq!( + s.eval::("return SHOW_BATTLEFIELD_MINIMAP").unwrap(), + "0", + "the RegisterForSave global stays at WorldStateFrame.lua's own boot value" + ); + + // Every frame, region and font string the `.xml` declares, by name. The loop answers the FIRST + // missing one rather than a bare false, so a regression names itself. + let missing: Option = s + .eval( + r#" + local names = { + "BattlefieldMinimap", "BattlefieldMinimapBackground", "BattlefieldMinimapCorner", + "BattlefieldMinimapCloseButton", "BattlefieldMinimapCorpse", + "BattlefieldMinimapTab", "BattlefieldMinimapTabLeft", "BattlefieldMinimapTabMiddle", + "BattlefieldMinimapTabRight", "BattlefieldMinimapTabFlash", + "BattlefieldMinimapTabText", "BattlefieldMinimapTabDropDown", + } + for i = 1, 12 do table.insert(names, "BattlefieldMinimap" .. i) end + for i = 1, 4 do table.insert(names, "BattlefieldMinimapParty" .. i) end + for i = 1, 40 do table.insert(names, "BattlefieldMinimapRaid" .. i) end + for i = 1, 2 do table.insert(names, "BattlefieldMinimapFlag" .. i) end + for _, n in ipairs(names) do + if not getglobal(n) then return n end + end + return nil + "#, + ) + .unwrap(); + assert_eq!(missing, None, "a declared frame never materialized"); + + // `BattlefieldMinimapRaidUnitTemplate`'s own OnLoad ran on each of the forty: the unit token + // from the frame's `id=`, and the party blip art on its `$parentIcon`. + assert_eq!( + s.eval::("return BattlefieldMinimapRaid17.unit") + .unwrap(), + "raid17" + ); + assert_eq!( + s.eval::("return BattlefieldMinimapRaid17Icon:GetTexture()") + .unwrap(), + "Interface\\WorldMap\\WorldMapPartyIcon" + ); + + // The `ADDON_LOADED` arm: the saved-variable table came up on `BattlefieldMinimapDefaults`… + assert_eq!( + s.eval::<(f64, bool, bool)>( + "return BattlefieldMinimapOptions.opacity, BattlefieldMinimapOptions.locked, \ + BattlefieldMinimapOptions.showPlayers" + ) + .unwrap(), + (0.7, true, true) + ); + // …the tab took its default seat (no saved position → the `-225-CONTAINER_OFFSET_X` corner, + // whose two constants are ContainerFrame.lua's and UIParent.lua's)… + let (point, relative, rel_point, x, y) = s + .eval::<(String, String, String, f64, f64)>( + "local p, r, rp, ox, oy = BattlefieldMinimapTab:GetPoint(1) \ + return p, r:GetName(), rp, ox, oy", + ) + .unwrap(); + assert_eq!( + (point.as_str(), relative.as_str(), rel_point.as_str()), + ("BOTTOMLEFT", "UIParent", "BOTTOMRIGHT") + ); + assert_eq!( + (x, y), + ( + -225.0 - num(&s, "CONTAINER_OFFSET_X"), + num(&s, "BATTLEFIELD_TAB_OFFSET_Y") + ) + ); + // …and `BattlefieldMinimap_SetOpacity()` ran off the seeded slider value, so the window is + // already wearing the default 0.7 opacity (its alpha is `1 - value`) before it is ever shown. + assert!( + (num(&s, "BattlefieldMinimapBackground:GetAlpha()") - 0.3).abs() < 1e-6, + "the load-time opacity pass reached the border texture" + ); + + // Now inside a battleground, the same toggle opens it. + open(&mut s); + assert_eq!( + s.eval::("return SHOW_BATTLEFIELD_MINIMAP").unwrap(), + "1" + ); + assert!( + shown(&s, "BattlefieldMinimapTab"), + "the tab comes up with it" + ); + quiet(&s); +} + +/// **The twelve map tiles and the overlay textures are cut from the map the engine is showing.** +/// +/// Two loops, both reading host verbs the addon shares with the world map. The tiles are +/// `GetMapInfo()`'s art folder repeated `NUM_WORLDMAP_DETAIL_TILES` times; the overlays are +/// `GetNumMapOverlays()` / `GetMapOverlayInfo(i)` sliced into 256-px tiles, each scaled by the +/// window's own `BattlefieldMinimap1:GetWidth()/256` — a **56/256 = 0.21875** shrink, which is the +/// whole reason this window's overlay geometry is not the world map's and has to be checked +/// separately. +/// +/// Every number below is that arithmetic done by hand from [`catalog`]'s two overlays, so the test +/// fails on a wrong scale, a wrong remainder, a dropped power-of-two round-up or a sign flip in +/// the y offset — not merely on "something was drawn". +#[test] +fn the_overlay_textures_are_cut_from_the_revealed_overlays() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + open(&mut s); + + // The window's `` already ran `SetMapToCurrentZone()` and `BattlefieldMinimap_Update()`, + // so the displayed map is the player's zone and the tiles are laid. + assert_eq!( + s.eval::("return GetMapInfo()").unwrap(), + "WarsongGulch" + ); + for i in [1, 7, 12] { + assert_eq!( + s.eval::(&format!("return BattlefieldMinimap{i}:GetTexture()")) + .unwrap(), + format!("Interface\\WorldMap\\WarsongGulch\\WarsongGulch{i}"), + "detail tile {i}" + ); + } + + assert_eq!(s.eval::("return GetNumMapOverlays()").unwrap(), 2); + assert_eq!( + s.eval::("return NUM_BATTLEFIELDMAP_OVERLAYS").unwrap(), + 3, + "2 + 1 tiles: the 300×200 overlay needs two, the 256×256 one needs one" + ); + + // The reference reads back `(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy)` since 1840; the old + // `(left, right, top, bottom)` rect is positions 1, 5, 2, 4. + let tex_rect = |s: &UiScript, n: u32| -> (f64, f64, f64, f64) { + let (l, t, _, b, r, ..): (f64, f64, f64, f64, f64, f64, f64, f64) = s + .eval(&format!( + "return BattlefieldMinimapOverlay{n}:GetTexCoord()" + )) + .unwrap(); + (l, r, t, b) + }; + let seat = |s: &UiScript, n: u32| -> (String, String, f64, f64) { + s.eval(&format!( + "local p, r, rp, x, y = BattlefieldMinimapOverlay{n}:GetPoint(1) return p, rp, x, y" + )) + .unwrap() + }; + const SCALE: f64 = 56.0 / 256.0; + + // Tile 1 of SilverwingHold: a full 256-px column, 200 px tall in a 256-px file, at the + // overlay's own (100, 50) offset — x right, y DOWN, both through the shrink. + assert_eq!( + s.eval::("return BattlefieldMinimapOverlay1:GetTexture()") + .unwrap(), + "Interface\\WorldMap\\WarsongGulch\\SilverwingHold1" + ); + assert_eq!( + ( + num(&s, "BattlefieldMinimapOverlay1:GetWidth()"), + num(&s, "BattlefieldMinimapOverlay1:GetHeight()") + ), + (256.0 * SCALE, 200.0 * SCALE) + ); + assert_eq!(tex_rect(&s, 1), (0.0, 1.0, 0.0, 200.0 / 256.0)); + assert_eq!( + seat(&s, 1), + ( + "TOPLEFT".into(), + "TOPLEFT".into(), + 100.0 * SCALE, + -(50.0 * SCALE) + ) + ); + + // Tile 2: the remainder column — 44 px of art in the 64-px power-of-two file the client rounds + // up to, seated exactly one full tile (256 × scale) to the right of tile 1. + assert_eq!( + s.eval::("return BattlefieldMinimapOverlay2:GetTexture()") + .unwrap(), + "Interface\\WorldMap\\WarsongGulch\\SilverwingHold2" + ); + assert_eq!( + num(&s, "BattlefieldMinimapOverlay2:GetWidth()"), + 44.0 * SCALE + ); + assert_eq!(tex_rect(&s, 2), (0.0, 44.0 / 64.0, 0.0, 200.0 / 256.0)); + assert_eq!( + seat(&s, 2), + ( + "TOPLEFT".into(), + "TOPLEFT".into(), + (100.0 + 256.0) * SCALE, + -(50.0 * SCALE) + ) + ); + + // Tile 3 is the second overlay's only tile: exactly 256×256, the case where both `mod`s are + // zero and the reference's `== 0 then 256` guards are load-bearing — a missing guard would + // give this a zero-width, zero-height, `0..0` cropped tile. + assert_eq!( + s.eval::("return BattlefieldMinimapOverlay3:GetTexture()") + .unwrap(), + "Interface\\WorldMap\\WarsongGulch\\WarsongLumberMill1" + ); + assert_eq!( + ( + num(&s, "BattlefieldMinimapOverlay3:GetWidth()"), + num(&s, "BattlefieldMinimapOverlay3:GetHeight()") + ), + (56.0, 56.0) + ); + assert_eq!(tex_rect(&s, 3), (0.0, 1.0, 0.0, 1.0)); + for n in 1..=3 { + assert!( + shown(&s, &format!("BattlefieldMinimapOverlay{n}")), + "overlay tile {n} is lit" + ); + } + + // Un-explore the lumber mill: the host stops returning that overlay, the repaint lights two + // tiles and PARKS the third — the pool never shrinks, exactly as the world map's does not. + s.set_world_map_explored(vec![0b010; 64]); + s.tick(0.0); // the push queues WORLD_MAP_UPDATE, the addon's own repaint event + assert_eq!(s.eval::("return GetNumMapOverlays()").unwrap(), 1); + assert!(shown(&s, "BattlefieldMinimapOverlay1") && shown(&s, "BattlefieldMinimapOverlay2")); + assert!( + !shown(&s, "BattlefieldMinimapOverlay3"), + "the tail parks hidden rather than being destroyed" + ); + assert_eq!( + s.eval::("return NUM_BATTLEFIELDMAP_OVERLAYS").unwrap(), + 3, + "…and the pool keeps its high-water mark" + ); + quiet(&s); +} + +/// **The POI layer** — `GetNumMapLandmarks` / `GetMapLandmarkInfo` → `BattlefieldMinimap_CreatePOI` +/// → `WorldMap_GetPOITextureCoords`, the same three verbs the world map's own pool walks, but +/// seated against the 225×150 battle map and sized by `GetBattlefieldMapIconScale()` rather than +/// left at the flat 12 px the world map uses. +/// +/// That scale is the point of the second half: the reference multiplies `DEFAULT_POI_ICON_SIZE` by +/// it on every repaint, so a battleground whose `MinimapIconScale` is not 1 draws bigger icons on +/// the battle map and identical ones on the world map. +#[test] +fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + open(&mut s); + assert_eq!(s.eval::("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 0); + + let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkView { + name: name.into(), + description: String::new(), + texture_index: icon, + uv, + }; + // Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell — two different cells of + // the 8×8 `POIIcons` atlas, so a hard-coded crop cannot pass both. + s.set_world_map_landmarks(vec![ + landmark("Silverwing Flag", 6, (0.25, 0.5)), + landmark("Warsong Flag", 9, (0.75, 0.25)), + ]); + s.tick(0.0); + + assert_eq!(s.eval::("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 2); + assert!(shown(&s, "BattlefieldMinimapPOI1") && shown(&s, "BattlefieldMinimapPOI2")); + // Cell 6 = column 6, row 0; cell 9 = column 1, row 1. `coordIncrement` is 16/128 = 0.125. + let cell = |s: &UiScript, n: u32| -> (f64, f64) { + let (l, t, ..): (f64, f64, f64, f64, f64, f64, f64, f64) = s + .eval(&format!( + "return BattlefieldMinimapPOI{n}Texture:GetTexCoord()" + )) + .unwrap(); + (l, t) + }; + assert_eq!(cell(&s, 1), (0.75, 0.0)); + assert_eq!(cell(&s, 2), (0.125, 0.125)); + // Seated CENTER against the window's TOPLEFT, x × its width and y × MINUS its height — v runs + // down the sheet where frame y runs up, and the sign is what mirrors every icon if it is wrong. + assert_eq!( + s.eval::<(String, String, String, f64, f64)>( + "local p, r, rp, x, y = BattlefieldMinimapPOI1:GetPoint(1) return p, r:GetName(), rp, x, y" + ) + .unwrap(), + ( + "CENTER".into(), + "BattlefieldMinimap".into(), + "TOPLEFT".into(), + 0.25 * 225.0, + -0.5 * 150.0 + ) + ); + // The default scale leaves the icon at `DEFAULT_POI_ICON_SIZE`. + assert_eq!(num(&s, "BattlefieldMinimapPOI1:GetWidth()"), 12.0); + + // A battleground whose map row carries a bigger `MinimapIconScale` grows every icon by it. + s.set_battlefield_positions(Vec::new(), None, 1.5); + s.run("BattlefieldMinimap_Update()").unwrap(); + assert_eq!( + ( + num(&s, "BattlefieldMinimapPOI1:GetWidth()"), + num(&s, "BattlefieldMinimapPOI1:GetHeight()") + ), + (18.0, 18.0), + "DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()" + ); + + // One landmark left: the pool keeps both frames, re-seats the first and parks the second. + s.set_world_map_landmarks(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]); + s.tick(0.0); + assert_eq!( + s.eval::("return NUM_BATTLEFIELDMAP_POIS").unwrap(), + 2, + "the pool never shrinks" + ); + assert!(shown(&s, "BattlefieldMinimapPOI1")); + assert_eq!( + cell(&s, 1), + (0.125, 0.125), + "slot 1 was re-seated on the surviving landmark, not left on the stale one" + ); + assert!(!shown(&s, "BattlefieldMinimapPOI2"), "slot 2 parked hidden"); + quiet(&s); +} + +/// **The battleground blips** — the four position verbs the window polls every frame, plus the +/// party slots it shares with the world map, driven through the real ``. +/// +/// The team-member loop is the subtle one: with no raid up, `playerCount` stays 0, so +/// `BattlefieldMinimapRaid` takes `GetBattlefieldPosition(i)` — the raid frames double as the +/// battleground roster. The `(0, 0)` pair is the hide sentinel on every one of these verbs, and +/// the name each row carries is what the blip's tooltip shows for a teammate who is in neither +/// your party nor your raid. +#[test] +fn the_battleground_blips_and_the_flag_follow_the_position_family() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + open(&mut s); + + s.set_battlefield_positions( + vec![ + BattlefieldPositionView { + uv: (0.25, 0.75), + name: Some("Alliedguy".into()), + }, + BattlefieldPositionView { + uv: (0.0, 0.0), + name: None, + }, + ], + Some(BattlefieldFlagView { + uv: (0.4, 0.6), + token: Some("HordeFlag".into()), + }), + 1.0, + ); + s.set_world_map_feed( + Some((1, 1)), + Some((0.5, 0.25)), + 0.0, + None, + vec![Some((0.1, 0.2)), None], + Vec::new(), + ); + update(&mut s, 0.1); + + assert!( + shown(&s, "BattlefieldMinimapRaid1"), + "the placed teammate shows" + ); + assert_eq!( + s.eval::("return BattlefieldMinimapRaid1.name") + .unwrap(), + "Alliedguy", + "…carrying the name the tooltip reads for a non-group teammate" + ); + assert_eq!( + s.eval::<(String, String, f64, f64)>( + "local p, r, rp, x, y = BattlefieldMinimapRaid1:GetPoint(1) return p, rp, x, y" + ) + .unwrap(), + ( + "CENTER".into(), + "TOPLEFT".into(), + 0.25 * 225.0, + -0.75 * 150.0 + ) + ); + assert!( + !shown(&s, "BattlefieldMinimapRaid2"), + "the (0,0) teammate hides" + ); + assert!( + !shown(&s, "BattlefieldMinimapRaid40"), + "…and so does every slot past the roster" + ); + + // The party arm reads `GetPlayerMapPosition("party"..i)`, the world map's own feed. + assert!(shown(&s, "BattlefieldMinimapParty1")); + assert_eq!( + s.eval::<(f64, f64)>( + "local _, _, _, x, y = BattlefieldMinimapParty1:GetPoint(1) return x, y" + ) + .unwrap(), + (0.1 * 225.0, -0.2 * 150.0) + ); + assert!( + !shown(&s, "BattlefieldMinimapParty2"), + "a slot with no position hides" + ); + + // The carrier takes flag frame 1, wearing the token's own art; the second frame stays parked. + assert!(shown(&s, "BattlefieldMinimapFlag1")); + assert_eq!( + s.eval::("return BattlefieldMinimapFlag1Texture:GetTexture()") + .unwrap(), + "Interface\\WorldStateFrame\\HordeFlag" + ); + assert!(!shown(&s, "BattlefieldMinimapFlag2")); + + // Hovering a teammate's blip names them — `BattlefieldMinimapUnit_OnEnter` prefers the row's + // own `name` over `UnitName(unit)`, which is the only way a non-group teammate gets a label. + let (bx, by) = screen_centre(&mut s, "BattlefieldMinimapRaid1"); + assert_eq!( + s.hit_test_name(bx, by).as_deref(), + Some("BattlefieldMinimapRaid1"), + "the blip is what the cursor is over" + ); + s.mouse_move(bx, by); + assert!(s.eval::("return GameTooltip:IsShown()").unwrap()); + assert_eq!( + s.eval::("return GameTooltipTextLeft1:GetText()") + .unwrap(), + "Alliedguy" + ); + s.mouse_move(5.0, 5.0); + + // The dropdown's "Show Teammates" off: the party and raid blips go in one pass, before any of + // the position verbs are consulted — and **the flag carrier stays**. That asymmetry is the + // reference's own: `Blizzard_BattlefieldMinimap.lua:245-252`'s `not showPlayers` arm hides + // `BattlefieldMinimapParty1..4` and `BattlefieldMinimapRaid1..40` and nothing else, so the two + // flag frames simply keep whatever the last showPlayers-on pass left on them. Pinned in both + // directions on purpose: "hide everything" is the obvious tidy-up and it is not what 1.12 does. + s.run("BattlefieldMinimapOptions.showPlayers = false") + .unwrap(); + update(&mut s, 0.1); + for f in ["BattlefieldMinimapRaid1", "BattlefieldMinimapParty1"] { + assert!(!shown(&s, f), "{f} hides while teammates are switched off"); + } + assert!( + shown(&s, "BattlefieldMinimapFlag1"), + "…but the carrier's flag is not in that arm's two loops, so it stays up" + ); + + // Back on, and then leaving the battleground — the empty push — hides them for real. + s.run("BattlefieldMinimapOptions.showPlayers = true") + .unwrap(); + update(&mut s, 0.1); + assert!(shown(&s, "BattlefieldMinimapRaid1")); + s.set_battlefield_positions(Vec::new(), None, 1.0); + update(&mut s, 0.1); + assert!( + !shown(&s, "BattlefieldMinimapRaid1") && !shown(&s, "BattlefieldMinimapFlag1"), + "leaving the battleground takes the blips with it" + ); + quiet(&s); +} + +/// **The player arrow is the MINI singleton, not the world map's.** +/// +/// The two are separate slots in the engine (`Arrow::World` / `Arrow::Mini`), each created once per +/// session by its own `Create…` verb and never freed — so the failure this guards against is the +/// addon's `CreateMiniWorldMapArrowFrame(BattlefieldMinimap)` being answered with the world map's +/// existing arrow, which would leave the battle map with no arrow at all and yank the world map's +/// across the screen on every `PositionMini…`. +/// +/// Told apart by the one property that distinguishes them at the renderer: the mini's model scale +/// is `G48 · 10/9` where the world map's is `G48 · 5/3` (`G48 = 1/√(aspect²+1)`, so at 4:3 that is +/// 0.6667 against 1.0). The seat is cross-checked in Lua against the window's own TOPLEFT. +#[test] +fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + arrow_facts(&mut s); + open(&mut s); + + // `BattlefieldMinimap_OnLoad` made it: an anonymous `Model` child of the window. + assert_eq!( + s.eval::( + r#"local n = 0 + for _, c in ipairs({ BattlefieldMinimap:GetChildren() }) do + if c:GetObjectType() == "Model" then n = n + 1 end + end + return n"# + ) + .unwrap(), + 1, + "exactly one arrow pane, parented to the battle map" + ); + + s.set_world_map_feed( + Some((1, 1)), + Some((0.25, 0.75)), + 1.25, + None, + Vec::new(), + Vec::new(), + ); + update(&mut s, 0.1); + + // The seat, read back in Lua — the arrow is anonymous, so it is found among the children by + // kind. Same CENTER-on-TOPLEFT law as every blip. + let seat = s + .eval::<(bool, String, String, f64, f64)>( + r#"for _, c in ipairs({ BattlefieldMinimap:GetChildren() }) do + if c:GetObjectType() == "Model" then + local p, r, rp, x, y = c:GetPoint(1) + return c:IsShown(), p, rp, x, y + end + end"#, + ) + .unwrap(); + assert_eq!( + seat, + ( + true, + "CENTER".into(), + "TOPLEFT".into(), + 0.25 * 225.0, + -0.75 * 150.0 + ) + ); + + // The renderer's side: the arrow model, turned to the player's facing by + // `UpdateWorldMapArrowFrames`, at the MINI's own model scale, on a rect centred where Lua says. + let (cx, cy) = arrow_centre(&mut s); + let pane = s + .extract() + .into_iter() + .find(|q| { + matches!(&q.content, QuadContent::ModelPane { model: Some(m), .. } if m == ARROW_MODEL) + && q.rect.is_some_and(|r| { + ((r.left + r.right) / 2.0 - cx).abs() < 0.5 + && ((r.top + r.bottom) / 2.0 - cy).abs() < 0.5 + }) + }) + .expect("the battle map's arrow pane is in the render list"); + let QuadContent::ModelPane { + facing, + model_scale, + .. + } = pane.content + else { + unreachable!() + }; + assert!( + (facing - 1.25).abs() < 1e-6, + "turned to the player's facing, got {facing}" + ); + assert!( + (model_scale - 10.0 / 9.0 * 0.6).abs() < 1e-4, + "the MINI arrow's own `G48 · 10/9` scale — the world map's would be 1.0 here, got \ + {model_scale}" + ); + + // Off the displayed map: the `(0, 0)` sentinel routes through `ShowMiniWorldMapArrowFrame(nil)` + // and the pane leaves the render list entirely. + s.set_world_map_feed(Some((1, 1)), None, 0.0, None, Vec::new(), Vec::new()); + update(&mut s, 0.1); + s.resolve(); + assert!( + !s.extract().into_iter().any(|q| matches!( + &q.content, + QuadContent::ModelPane { model: Some(m), .. } if m == ARROW_MODEL + ) && q.rect.is_some()), + "off-map hides the arrow" + ); + quiet(&s); +} + +/// **The tab** — the strip of chat-frame art the window hangs from: it comes and goes with the +/// window, fades in under the cursor on the reference's own three-frame ramp, and refuses to be +/// dragged while the options say the map is locked. +/// +/// The hover ramp is worth spelling out because it looks like a bug and is not. Frame 1 takes the +/// `else` arm and only *starts* hovering (it stores the cursor into the globals `CURSOR_OLD_X/Y` +/// and never into `BattlefieldMinimap.oldX/oldy` — the reference's own slip). Frame 2 therefore +/// compares a nil `oldX` against the cursor, misses, and resets `hoverTime` to 0 while finally +/// recording the position. Only frame 3 can accumulate, so the `BATTLEFIELD_TAB_SHOW_DELAY` clock +/// does not start until the third update after the cursor arrives. A "fix" that fades on frame 2 +/// goes red here, which is the point. +#[test] +fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + open(&mut s); + + assert_eq!( + s.eval::("return BattlefieldMinimapTabText:GetText()") + .unwrap(), + "Battle Map", + "the tab wears BATTLEFIELD_MINIMAP, resized by PanelTemplates_TabResize in the OnShow" + ); + assert_eq!( + num(&s, "BattlefieldMinimapTab:GetAlpha()"), + 0.0, + "…and starts invisible: the OnLoad's own SetAlpha(0)" + ); + + // The hover ramp. + let (mx, my) = screen_centre(&mut s, "BattlefieldMinimap"); + s.mouse_move(mx, my); + update(&mut s, 0.3); + assert_eq!( + s.eval::<(Option, f64, Option)>( + "return BattlefieldMinimap.hover, BattlefieldMinimap.hoverTime, BattlefieldMinimap.oldX" + ) + .unwrap(), + (Some(1), 0.0, None), + "frame 1 only starts hovering — and leaves oldX nil, the reference's own slip" + ); + update(&mut s, 0.3); + assert_eq!( + num(&s, "BattlefieldMinimap.hoverTime"), + 0.0, + "frame 2 misses the nil oldX and RESTARTS the clock" + ); + update(&mut s, 0.3); + assert_eq!( + s.eval::<(f64, Option)>( + "return BattlefieldMinimap.hoverTime, BattlefieldMinimap.hasBeenFaded" + ) + .unwrap(), + (0.3, Some(1)), + "frame 3 finally crosses BATTLEFIELD_TAB_SHOW_DELAY and starts the fade" + ); + // `UIFrameFadeIn` runs on the fade manager's own update, from 0 to DEFAULT_BATTLEFIELD_TAB_ALPHA + // over BATTLEFIELD_TAB_FADE_TIME. + s.tick(0.05); + let part_way = num(&s, "BattlefieldMinimapTab:GetAlpha()"); + assert!( + part_way > 0.0 && part_way < 0.75, + "part way through the 0.15 s fade, got {part_way}" + ); + s.tick(0.2); + assert_eq!(num(&s, "BattlefieldMinimapTab:GetAlpha()"), 0.75); + + // The cursor leaves: the else arm fades it back to the alpha it was remembered at. + s.mouse_move(5.0, 5.0); + update(&mut s, 0.1); + assert_eq!( + s.eval::>("return BattlefieldMinimap.hover") + .unwrap(), + None, + "the hover state is cleared on the way out" + ); + s.tick(0.2); + assert_eq!(num(&s, "BattlefieldMinimapTab:GetAlpha()"), 0.0); + + // The lock. A left click on a locked tab returns before `StartMoving`, and `StartMoving` is + // what stamps the userPlaced bit (2193), so the bit is the observable in both directions. + assert!(!s + .eval::("return BattlefieldMinimapTab:IsUserPlaced()") + .unwrap()); + s.run("BattlefieldMinimapTab:Click(\"LeftButton\")") + .unwrap(); + assert!( + !s.eval::("return BattlefieldMinimapTab:IsUserPlaced()") + .unwrap(), + "a locked tab does not start a drag" + ); + s.run("BattlefieldMinimapOptions.locked = false BattlefieldMinimapTab:Click(\"LeftButton\")") + .unwrap(); + assert!( + s.eval::("return BattlefieldMinimapTab:IsUserPlaced()") + .unwrap(), + "unlocked, the same click starts one" + ); + s.run("BattlefieldMinimapTab:StopMovingOrSizing()").unwrap(); + + // `PLAYER_LOGOUT` writes a user-placed tab's centre into the saved table and hands the seat + // back to the file's own anchor; a tab nobody moved clears the row instead. + s.resolve(); + let centre = s + .eval::<(f64, f64)>("return BattlefieldMinimapTab:GetCenter()") + .unwrap(); + s.fire_event("PLAYER_LOGOUT", vec![]); + assert_eq!( + s.eval::<(f64, f64)>( + "return BattlefieldMinimapOptions.position.x, BattlefieldMinimapOptions.position.y" + ) + .unwrap(), + centre + ); + assert!( + !s.eval::("return BattlefieldMinimapTab:IsUserPlaced()") + .unwrap(), + "…and the bit is handed back, so the row is what restores the seat next login" + ); + s.fire_event("PLAYER_LOGOUT", vec![]); + assert_eq!( + s.eval::>("return BattlefieldMinimapOptions.position ~= nil and 1 or nil") + .unwrap(), + None, + "a tab nobody placed clears the saved row" + ); + + // The second toggle closes it, and the tab goes with it through the window's own OnHide. + s.run("ToggleBattlefieldMinimap()").unwrap(); + assert!(!shown(&s, "BattlefieldMinimap") && !shown(&s, "BattlefieldMinimapTab")); + assert_eq!( + s.eval::("return SHOW_BATTLEFIELD_MINIMAP").unwrap(), + "0" + ); + + // And leaving the battleground closes it on the client's own event, not on a toggle: the + // `PLAYER_ENTERING_WORLD` arm hides a battle map with no battle behind it. + s.run("ToggleBattlefieldMinimap()").unwrap(); + assert!(shown(&s, "BattlefieldMinimap")); + s.set_world_state_ui(Vec::new()); + s.tick(0.0); + s.fire_event("PLAYER_ENTERING_WORLD", vec![]); + assert!( + !shown(&s, "BattlefieldMinimap"), + "no world-state rows and no active queue: the window hides itself" + ); + quiet(&s); +} + +/// **The tab's right-click menu and the opacity slider it opens** — the window's whole options +/// surface, and the only place `BattlefieldMinimapOptions` is written by a player. +/// +/// Worth stating what this path does **not** touch, because it is easy to assume otherwise: +/// `BattlefieldMinimap_SetOpacity` reads no CVar. Its input is `OpacityFrameSlider:GetValue()` — +/// ColorPickerFrame.xml's shared vertical slider, which the addon borrows by pointing +/// `OpacityFrame.opacityFunc` at itself — and what persists is the addon's own +/// `SavedVariablesPerCharacter` table plus WorldStateFrame.lua's `RegisterForSave` global +/// `SHOW_BATTLEFIELD_MINIMAP`. There is no battle-map opacity CVar in 1.12 to register or to give +/// a reference default to, so 1804's rule does not reach this window. +/// +/// The alpha formula has two tiers and both are pinned: the border and the twelve detail tiles get +/// `1 - value`, while the overlays, the close button and the corner get a further 0.15 off it +/// (and, below 0.15, nothing off at all) — so a regression that applies one tier everywhere shows +/// up as the map art and its explored overlays being the same brightness. +#[test] +fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = session(); + open(&mut s); + + // A real right-click on the tab — `RegisterForClicks` admits `RightButtonUp`, and the handler's + // first arm opens the menu on the tab's own anchor. + s.run("BattlefieldMinimapTab:Click(\"RightButton\")") + .unwrap(); + assert!(shown(&s, "DropDownList1"), "the menu opened"); + let row = |s: &UiScript, n: u32| -> (String, bool) { + ( + s.eval(&format!("return DropDownList1Button{n}:GetText()")) + .unwrap(), + s.eval(&format!("return DropDownList1Button{n}Check:IsShown()")) + .unwrap(), + ) + }; + // Both toggles come up checked off `BattlefieldMinimapDefaults`; the opacity row is an action, + // so it carries no check. + assert_eq!(row(&s, 1), ("Show Teammates".into(), true)); + assert_eq!(row(&s, 2), ("Lock Battle Map".into(), true)); + assert_eq!(row(&s, 3), ("Change Opacity".into(), false)); + + s.run("DropDownList1Button1:Click()").unwrap(); + assert!( + !s.eval::("return BattlefieldMinimapOptions.showPlayers") + .unwrap(), + "row 1 flips Show Teammates" + ); + s.run("BattlefieldMinimapTab:Click(\"RightButton\")") + .unwrap(); + assert_eq!( + row(&s, 1), + ("Show Teammates".into(), false), + "…and the menu re-initializes off the new value" + ); + s.run("DropDownList1Button2:Click()").unwrap(); + assert!( + !s.eval::("return BattlefieldMinimapOptions.locked") + .unwrap(), + "row 2 flips the lock" + ); + + // Row 3 borrows the shared opacity frame: re-anchored onto the battle map's left edge, with + // both of its hooks pointed at this window. + s.run("BattlefieldMinimapTab:Click(\"RightButton\")") + .unwrap(); + s.run("DropDownList1Button3:Click()").unwrap(); + assert!(shown(&s, "OpacityFrame")); + assert_eq!( + s.eval::<(String, String, String, f64, f64)>( + "local p, r, rp, x, y = OpacityFrame:GetPoint(1) return p, r:GetName(), rp, x, y" + ) + .unwrap(), + ( + "TOPRIGHT".into(), + "BattlefieldMinimap".into(), + "TOPLEFT".into(), + 0.0, + 7.0 + ) + ); + + // Dragging the slider repaints through `OpacityFrame.opacityFunc` — the slider's own + // `` is the only caller, so this is the live path, not a direct call. + s.run("OpacityFrameSlider:SetValue(0.25)").unwrap(); + let alpha = |s: &UiScript, f: &str| num(s, &format!("{f}:GetAlpha()")); + for f in [ + "BattlefieldMinimapBackground", + "BattlefieldMinimap1", + "BattlefieldMinimap12", + ] { + assert!( + (alpha(&s, f) - 0.75).abs() < 1e-6, + "{f} takes the plain 1 - value" + ); + } + for f in [ + "BattlefieldMinimapOverlay1", + "BattlefieldMinimapCloseButton", + "BattlefieldMinimapCorner", + ] { + assert!( + (alpha(&s, f) - 0.6).abs() < 1e-6, + "{f} takes a further 0.15 off, got {}", + alpha(&s, f) + ); + } + // At the bottom of the second tier the subtraction stops rather than going negative. + s.run("OpacityFrameSlider:SetValue(0.9)").unwrap(); + assert!((alpha(&s, "BattlefieldMinimapBackground") - 0.1).abs() < 1e-6); + assert!( + (alpha(&s, "BattlefieldMinimapOverlay1") - 0.1).abs() < 1e-6, + "under 0.15 the overlays keep the plain alpha" + ); + + // Dismissing the frame runs `saveOpacityFunc`, which is what puts the value in the saved table. + s.run("OpacityFrameCloseButton:Click()").unwrap(); + assert!(!shown(&s, "OpacityFrame")); + assert!( + (num(&s, "BattlefieldMinimapOptions.opacity") - 0.9).abs() < 1e-6, + "the close saved the slider's value for next login" + ); + quiet(&s); +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 837f1b35..9e8c044f 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -1174,6 +1174,11 @@ mod quest_timer_tests; #[cfg(test)] mod battlefield_tests; +/// The battle map — the reference's `Blizzard_BattlefieldMinimap` addon, demand-loaded the way +/// SHIFT-M loads it, over the overlay/POI/position/arrow verbs it shares with the world map. +#[cfg(test)] +mod battlefield_minimap_tests; + #[cfg(test)] mod tutorial_tests; diff --git a/crates/benilla-app/src/world_state.rs b/crates/benilla-app/src/world_state.rs index 31c7c5c2..d75c7f5d 100644 --- a/crates/benilla-app/src/world_state.rs +++ b/crates/benilla-app/src/world_state.rs @@ -62,6 +62,15 @@ impl WorldStates { self.values.get(&key).copied().unwrap_or(0) as i32 } + /// Every `(key, value)` currently in the table, in no particular order — the instrument + /// read, and only that. No reference function enumerates this hash: the client's own readers + /// are the point getter above and the `WorldStateUI.dbc` walk, both of which ask for a key + /// they already know. A battleground's whole score lives here as raw dwords nobody has a name + /// for yet, so a probe that cannot dump it cannot say what arrived (`capture::ProbeBgPlugin`). + pub(crate) fn pairs(&self) -> impl Iterator + '_ { + self.values.iter().map(|(&k, &v)| (k, v as i32)) + } + /// The `(map, area)` the last `SMSG_INIT_WORLD_STATES` scoped the table to — the reference's /// `[0xb71e84]`/`[0xb71ea8]`, and the filter its world-state UI list is built against. `None` /// before any init has arrived, which is the reference's `-1` (its rebuild trigger refuses to diff --git a/crates/benilla-app/tests/ui_script_wall.rs b/crates/benilla-app/tests/ui_script_wall.rs index 1799541c..9d65e37a 100644 --- a/crates/benilla-app/tests/ui_script_wall.rs +++ b/crates/benilla-app/tests/ui_script_wall.rs @@ -32,11 +32,17 @@ use std::path::{Path, PathBuf}; /// fifteen sibling probes that read the VM *on purpose*. A probe's whole value is that it asks /// the **stock** binding (`IsInMeetingStoneQueue()`, `MiniMapMeetingStoneFrame:IsShown()`) /// rather than our own mirror of it, so feeding it a model value would make it prove nothing. +////// - **167 (decision 2290)** — `capture/probe_bg.rs`, the inside-a-battleground live probe, the +/// same class again and for the same reason, sharpened: it takes the port through the stock +/// `AcceptBattlefieldPort(slot, 1)` and reads the battleground back through +/// `GetNumWorldStateUI()`, `GetBattlefieldStatus()` and a Lua event tap. A probe that asked our +/// own model instead could not have found what this one did — that the battleground's +/// `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines reach the interface, which no app-side reading shows. /// /// The ratchet is for *production* feed code. Both numbers still went up, and both are written /// down — which is the whole point of it, and why two sessions raising it the same day collided /// here instead of quietly passing each other. -const CEILING: usize = 166; +const CEILING: usize = 167; /// How far under [`CEILING`] the count may sit before the test asks for the ceiling to follow it. const SLACK: usize = 8; diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index a41c5369..1acd7a17 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -488,7 +488,15 @@ fn is_instrument_consumer(rel: &str) -> bool { /// **nothing** in file slot 0, so a shared material can only ever seed white however faithfully it /// is ticked. They were already `pub` for the world streamer; what crosses here is the game naming /// them, and it is the same crossing the UV half makes one line up. -const CEILING: usize = 189; +/// And 189 → 190: `terrain_stream::CurrentArea`, taken by `capture/probe_bg.rs` (decision 2290). +/// It is the engine's own area authority — the `AreaTable.dbc` leaf under the player's feet, which +/// the engine already publishes for its own audio and zone-text consumers — and the probe reports +/// it for one reason: a battleground census has to be able to say *where the body actually is*, +/// and a map id alone cannot. "Map 489" is true of the pen, the field and the graveyard alike; the +/// area id is what told this instrument that the port lands at Silverwing Hold and that the +/// release lands at the graveyard. The alternative was to re-derive the leaf in the probe from +/// tiles the engine already resolved, which is the copy-the-rule drift this wall exists to stop. +const CEILING: usize = 190; /// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be /// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it