the battle map draws, and the talent modifiers land

- inside a battleground the battle map draws its terrain, overlays, arrow and blips: the world map's selection carries the instance maps as the reference's third cell (2298)
- talent spell modifiers land: the 45 packets a level-60 login threw away fill the reference's two tables, and a spell's power cost reads them, so a cast you can afford is no longer refused (2298)
- a ghost's Accept on the spirit-heal dialog can no longer be eaten by frame order, and a latched spirit guide no longer follows you into the next character's session (2298)
- the battleground probe refuses GM mode, exits when done, and reads the scoreboard before toggling it (2298)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:45:36 +07:00
co-authored by Claude Fable 5.1
parent 7eea9dcbcf
commit a1355ea02b
34 changed files with 2843 additions and 370 deletions
+478 -124
View File
@@ -27,20 +27,25 @@
//! 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).
//! constructor, so a server restart clears it whatever happens here.
//!
//! Note that `.debug bg` is a **toggle, not a set**, and it announces itself to the whole realm
//! `.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.
//!
//! **It is turned back off once the match is running** (see `DOORS_SAMPLE`), because leaving it on
//! makes a solo battleground unendable: vmangos decrements its premature-finish countdown inside an
//! `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`), so while testing is on
//! the countdown is armed and then **frozen**. Decision 2290 said the opposite, from reading
//! `GetPrematureFinishTime()` without the branch that consumes it; 2296 corrects it, having watched
//! a run sit in Warsong Gulch for 492 s with `GetBattlefieldWinner()` nil the whole way.
//! **The lever is tracked by PARITY, not by state** ([`BgProbe::toggles_sent`]), and put back by
//! [`leave_testing_as_found`] — which every road to [`Phase::Done`] goes through, failures
//! included. "What is it now?" can only be answered by a world-text reply that may never arrive;
//! "how far from where I found it?" needs no reply at all. It is restored **as soon as the probe
//! is inside the instance**, because that is the moment the lever has finished its job: it is
//! needed for `CheckNormalMatch` to *form* the match, not to run one.
//!
//! Leaving it on also makes a solo battleground unendable: vmangos decrements its premature-finish
//! countdown inside an `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`),
//! so while testing is on the countdown is armed and then **frozen**. Decision 2290 said the
//! opposite, from reading `GetPrematureFinishTime()` without the branch that consumes it; 2296
//! corrects it, having watched a run sit in Warsong Gulch for 492 s with `GetBattlefieldWinner()`
//! nil the whole way.
//!
//! ## What the census reports, and why each column is there
//!
@@ -56,16 +61,33 @@
//! | `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 |
//! | `dropped` | every opcode the codec threw on the floor, by name |
//! | `events` | the Lua event tap's window — or an explicit "the tap is broken", never silence |
//! | `mapinfo` | (at Report) `GetMapInfo()` and the overlay/landmark counts — whether the battle
//! map has anything to **draw**, which `IsShown()` cannot tell you |
//!
//! There is deliberately **no `lua` column**; see the note at the end of [`census`] for why the one
//! that used to be here could not measure what it claimed.
//!
//! ## What this probe cannot see
//!
//! It calls [`crate::ui_chat::idle::LastInput::stamp_present`] every frame, which suppresses the
//! whole idle band — auto-sit, auto-AFK and camp alike (`ui_chat::idle::idle_action`). That is
//! deliberate and necessary (see the note at the top of [`bg_probe`]), but it means the one
//! long-running instrument that otherwise sits still for minutes can never exercise the idle
//! handler. A regression there has to be caught somewhere else.
//!
//! ## The run recipe
//!
//! ```sh
//! cd <slot> && 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:'
//! timeout 600 cargo run -p benilla 2>&1 | grep -E 'PROBE bg:'
//! ```
//!
//! Raise the `timeout` with [`census_samples`]: a default run needs ~5 min including login, and a
//! run sized to reach a match's ending needs ~10.
//!
//! `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).
@@ -161,6 +183,32 @@ const MAX_REJOINS: u32 = 3;
/// interaction check; this only keeps the probe from greeting a battlemaster it has not reached.
const NEAR_YD: f32 = 20.0;
/// `PLAYER_FLAGS_GM` — the bit that says the body carries GM mode's faction-35 re-template
/// (`crate::probe_shield` names the same constant).
const PLAYER_FLAGS_GM: u32 = 0x0000_0008;
/// How long the exit waits for the battleground leave to land before going anyway. The teleport
/// out is a server round trip plus a worldport; five seconds is far more than a local server needs
/// and is a backstop, not a budget.
const LEAVE_GRACE: f64 = 5.0;
/// How long to wait for `probe_shield`'s `.gm off` to land before refusing the run.
///
/// **Sized by that module's own sequencing, not by guesswork** — five seconds was measured as too
/// tight and refused a run that had `WOW_GM=off` set and was about to comply. `probe_shield` waits
/// up to `NAME_WAIT_SECS` (3 s) for the body's name to resolve, then spaces its commands
/// `STEP_SECS` (0.8 s) apart with the god line *before* the `.gm off` (the shield must be up first,
/// 0679), and the flag only clears once the server's field update comes back. Twenty seconds
/// clears all of that with room, and still fails fast against a run that is never going to comply.
const GM_DROP_WAIT: f64 = 20.0;
/// How long to keep listening for `.debug bg`'s world-text reply before giving up and saying so.
///
/// The reply crosses the network, the chat pipeline and the Lua event dispatch before it reaches
/// the tap. The old code took **one** destructive look 1.5 s after the send, which is both too
/// narrow to be reliable and, being a single sample, unable to tell "not yet" from "never".
const TOGGLE_REPLY_WINDOW: f64 = 6.0;
/// How often a census line is printed once inside.
const CENSUS_EVERY: f64 = 12.0;
@@ -170,11 +218,11 @@ const CENSUS_EVERY: f64 = 12.0;
/// 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.
/// the pen and report it as the whole battleground.
///
/// Twelve × 12 s = **144 s**: the full prep, the doors dropping at ~116 s, and two samples past
/// them, which leaves room for the flag and graveyard legs inside the same run. Reaching the
/// *ending* is a different window — see [`census_samples`].
const CENSUS_SAMPLES_DEFAULT: u32 = 12;
/// The census sample count, overridable with `WOW_PROBE_BG_SAMPLES`.
@@ -183,14 +231,25 @@ const CENSUS_SAMPLES_DEFAULT: u32 = 12;
/// 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.
/// **with no winner** — `WINNER_NONE` = 2, not a team (`BattleGroundDefines.h:204`).
///
/// **Sizing it is arithmetic, not taste.** The countdown is armed when the battleground reaches
/// `STATUS_IN_PROGRESS` — the doors, ≈T+120 s — and runs 300 s from there, so the ending lands at
/// ≈T+420 s ≈ **35 samples**. `WOW_PROBE_BG_SAMPLES=36` reaches it; the 30 this doc used to
/// recommend stops 60 s short and reports "the match never ended", which reads as a defect rather
/// than as a window that was too small.
///
/// **Alterac Valley can never be ended this way at all**: the whole premature-finish block is
/// guarded by `GetTypeID() != BATTLEGROUND_AV` (`BattleGround.cpp:318`).
///
/// A value below 1 is meaningless and is clamped to 1 — the first sample is also where the testing
/// lever is put back, and a run that never censuses never restores it.
fn census_samples() -> u32 {
std::env::var("WOW_PROBE_BG_SAMPLES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(CENSUS_SAMPLES_DEFAULT)
.max(1)
}
/// How often the ghost leg samples, and for how long. The battleground resurrect wave is a 30 s
@@ -203,10 +262,6 @@ const GHOST_SAMPLES: u32 = 11;
/// How many 3 s samples the flag leg takes after the use.
const FLAG_SAMPLES: u32 = 5;
/// The census sample by which the doors have opened (they drop ~116 s after entry, and the census
/// ticks every 12 s). The testing flag is turned back off here — see the `Inside` arm.
const DOORS_SAMPLE: u32 = 11;
/// `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;
@@ -246,12 +301,22 @@ BenillaBgTap:RegisterEvent("ZONE_CHANGED_NEW_AREA");
"#;
/// Drain the tap: everything it recorded since the last census, then empty it.
///
/// **It reports the tap's own liveness first**, and that is not decoration. `BenillaBgLog` is set
/// on `EVENT_TAP`'s first line, so a tap that raised *after* that line — or a tap installed into a
/// VM that has since been thrown away and minted fresh — leaves a table that drains cleanly to `""`
/// forever. An empty string would then be indistinguishable from "the battleground fired nothing",
/// which is the one answer this instrument must never give by accident. The sentinel makes the
/// difference readable, and [`bg_probe`] re-installs on seeing it.
const TAP_GONE: &str = "<tap-gone>";
/// See [`TAP_GONE`]: both globals are checked, because the frame is what actually receives events
/// and the table is what holds them — either one missing means the readings are not trustworthy.
const EVENT_DRAIN: &str = r#"
local out = "";
if BenillaBgLog then
for i = 1, table.getn(BenillaBgLog) do out = out .. " " .. BenillaBgLog[i]; end
BenillaBgLog = {};
end
if not BenillaBgLog or not BenillaBgTap then return "<tap-gone>" end
for i = 1, table.getn(BenillaBgLog) do out = out .. " " .. BenillaBgLog[i]; end
BenillaBgLog = {};
return out;
"#;
@@ -259,17 +324,54 @@ pub(crate) struct ProbeBgPlugin;
impl Plugin for ProbeBgPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BgProbe>().add_systems(Update, bg_probe);
app.init_resource::<BgProbe>()
.add_systems(Update, (bg_probe, bg_probe_exit).chain());
}
}
#[derive(Resource, Default)]
struct BgProbe {
phase: Phase,
/// The Lua event tap is in (or failed once and said so).
/// The Lua event tap is in and believed live. **Not a latch** — the drain re-checks the tap's
/// own global every sample and clears this if the VM went away underneath it, because the
/// interface VM is minted fresh on every world entry (`ui_script::lifecycle::mint_entry_vm`)
/// and a tap installed into the boot VM is silently gone after the port. A dead tap that still
/// reads `tap_installed` prints `EVENTS (none)` forever, which is the exact inversion this
/// instrument exists to avoid.
tap_installed: bool,
/// The tap raised on install and cannot be trusted. Kept apart from `tap_installed` so an
/// `EVENTS` line can say *"the tap is broken"* rather than *"the battleground fired nothing"*.
tap_broken: bool,
/// **How many `.debug bg` toggles this run has sent — parity, not state.**
///
/// `.debug bg` is a toggle, so "what is it now?" is the wrong question and the one the old code
/// asked: it can only be answered by reading a world-text reply that may never arrive. "How far
/// from where I found it?" is always answerable, needs no reply, and is what a restore actually
/// needs. An odd count means this run left the lever flipped and owes the realm one more send;
/// an even count means it is already as found — including the normal road, where the run turns
/// testing on to queue and off again once inside.
toggles_sent: u32,
/// What the tap last said the lever reads: `Some(true)` = on ("…for debugging"), `Some(false)` =
/// off ("…normal playercount"), `None` = no reply seen yet. Consumed once so a second pass
/// cannot re-decide on a stale marker.
testing_seen: Option<bool>,
/// The lever was **read back** as on before the join, rather than merely assumed. The `Joined`
/// failure text says which, because "the flag was confirmed ON, so look elsewhere" sends the
/// reader past the actual suspect when nothing was ever confirmed.
testing_confirmed: bool,
/// How many times the queue has been dropped and re-taken (see the `Joined` arm).
rejoins: u32,
/// The `AppExit` has been written — `Done` is re-entered every frame until the app actually
/// stops, and a second exit write would be noise.
exited: bool,
/// When [`Phase::Done`] was first seen, so the exit can wait for the leave to land.
done_at: Option<f64>,
/// When the `Wait` arm first saw a body, so the GM-mode gate can time out rather than hang.
waiting_since: Option<f64>,
/// Events drained before the first census (during the toggle read-back and the queue road).
/// Carried rather than discarded: the old code threw them away and then attributed them to the
/// first 12 s census window, which is a reading about the wrong interval.
pending_events: String,
}
/// `Wait` → (levelled, hop sent) `Hopped` → (`.debug bg` sent) `Toggled` → (reply read, testing
@@ -287,6 +389,13 @@ enum Phase {
sent_at: f64,
corrected: bool,
},
/// Looking for the battlemaster, **after** the testing lever is settled. Split out of `Toggled`
/// so the reply read happens once: the old code re-ran its destructive drain every frame of the
/// 15 s scan, which threw away every tap event that arrived during it and re-printed a
/// "TESTING unread" warning over the "TESTING on" line it had already printed.
Scanning {
since: f64,
},
Greeted {
master: u64,
sent_at: f64,
@@ -343,6 +452,76 @@ fn gm(net: &NetCommands, text: impl Into<String>) {
});
}
/// Drain the event tap, distinguishing its three outcomes rather than flattening them to `""`.
///
/// Returns the events, or a bracketed reason. A `TAP_GONE` sentinel also **clears
/// `tap_installed`**, so the next frame re-installs into whatever VM is live now — which is the
/// self-heal for the one-frame world-entry window where the tap can land in a boot VM that is
/// about to be discarded (`ui_script::lifecycle`).
fn drain_events(probe: &mut BgProbe, script: &mut UiScript) -> String {
match script.eval::<String>(EVENT_DRAIN) {
Ok(s) if s.trim() == TAP_GONE => {
probe.tap_installed = false;
format!(" {TAP_GONE} — the VM was replaced under the tap; re-installing")
}
Ok(s) => s,
Err(e) => format!(" <drain raised: {e}>"),
}
}
/// Put the realm's `.debug bg` lever back if **this run** is the one that moved it.
///
/// Every road to [`Phase::Done`] goes through this, and that is the whole point. `.debug bg` is a
/// realm-global, in-memory toggle (`BattleGroundMgr::ToggleTesting`) that announces itself to every
/// player in the world, and it is a **toggle, not a set** — so a run that ends without restoring it
/// hands the next run the opposite of what it expects. That is not hypothetical: it is how runs 2,
/// 4 and 5 of this probe failed (2290 §2). The old code restored it at exactly one place, an
/// equality test on the census counter, which every failure path and every
/// `WOW_PROBE_BG_SAMPLES < DOORS_SAMPLE` run skipped silently.
fn leave_testing_as_found(probe: &mut BgProbe, net: &NetCommands) {
if probe.toggles_sent % 2 == 1 {
info!(
"PROBE bg: TESTING — {} toggle(s) sent this run; one more puts the realm's \
`.debug bg` lever back as found",
probe.toggles_sent
);
toggle_testing(probe, net);
} else {
info!(
"PROBE bg: TESTING — {} toggle(s) sent this run; the lever is already as found",
probe.toggles_sent
);
}
}
/// Send `.debug bg` and count it. **Every** send goes through here — the parity in
/// [`leave_testing_as_found`] is only as good as the counting.
fn toggle_testing(probe: &mut BgProbe, net: &NetCommands) {
probe.toggles_sent += 1;
gm(net, ".debug bg");
}
/// End the run, restoring anything this run changed on the server first.
fn finish(probe: &mut BgProbe, net: &NetCommands) {
leave_testing_as_found(probe, net);
probe.phase = Phase::Done;
}
/// A world state, distinguishing **absent** from **zero**.
///
/// [`WorldStates::get`] is reference-faithful: a key the server never sent reads `0`, because that
/// is what the real client's table does. That is right for the client and wrong for an instrument —
/// `ws2338=0` reads as a well-formed "the flag is at its base" when it actually means "no
/// `SMSG_INIT_WORLD_STATES` ever carried this key". vmangos writes only 1 or 2 into the flag rows
/// (`BattleGroundWS.cpp:509-515`, all eight call sites), so a `0` there is *always* a missing
/// reading — and the person reading the log is exactly the person who does not know that yet.
fn ws(states: &WorldStates, key: u32) -> String {
states
.pairs()
.find(|(k, _)| *k == key)
.map_or_else(|| "absent".to_string(), |(_, v)| v.to_string())
}
// 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(
@@ -392,6 +571,7 @@ fn bg_probe(
}
Err(e) => {
probe.tap_installed = true; // a raise will not fix itself; say so once
probe.tap_broken = true; // …and every later EVENTS line says so too
error!("PROBE bg: TAP FAILURE — {e}");
}
}
@@ -400,11 +580,50 @@ fn bg_probe(
match probe.phase {
Phase::Wait => {
// **GM mode makes every objective in this probe fail silently, so refuse to start in
// it.** vmangos's `Player::CanUseBattleGroundObject` opens with
// `if (IsGameMaster()) return false;` (`Player.cpp:20576`), which means the flag click,
// the Arathi Basin banners and Alterac Valley's towers are all rejected server-side
// with no reply — while the queue, the port, the census, the world states and the
// scoreboard all keep working perfectly. That is the worst possible failure shape: a
// run that looks 90% right and is wrong exactly where the objectives are.
//
// MEASURED, by getting it wrong: a run without `WOW_GM=off` reached the Warsong Flag at
// 1.8 yd, sent `CMSG_GAMEOBJ_USE`, and reported `ws2339=1` with no banner aura and no
// `CHAT_MSG_BG_SYSTEM_ALLIANCE` — indistinguishable, from the probe's own lines, from a
// client that cannot pick up flags. `probe_shield` drops GM mode when `WOW_GM=off` is
// set (0679), so this WAITS for the flag to clear rather than refusing outright: the
// shield arms and drops GM a few hundred ms into the world, after this arm first runs.
if store.0.player_flags() & PLAYER_FLAGS_GM != 0 {
let since = *probe.waiting_since.get_or_insert(now);
if now - since < GM_DROP_WAIT {
return;
}
error!(
"PROBE bg: FAILURE — GM mode is still on after {GM_DROP_WAIT:.0}s. A \
battleground is the one place it cannot be left on: \
`CanUseBattleGroundObject` refuses a GM outright (vmangos \
`Player.cpp:20576`), so every objective fails silently while the queue, the \
port, the census and the scoreboard all keep working. Re-run with \
`WOW_GM=off` — and if it WAS set, look for `probe-shield:` lines: the drop \
is sequenced behind the shield and a refused command shows up there."
);
finish(&mut probe, &net);
return;
}
// 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);
// **Wait for the real level rather than assuming one.** `unit_level()` is `None` until
// `UNIT_FIELD_LEVEL` is in the store, and this arm fires on the first frame the
// `SelfPlayer` entity is queryable. Reading a missing field as `0` sends
// `.levelup 60` to a body that may already be 60 — which makes it **120**, above every
// bracket ceiling (`battleground_template.max_lvl = 60`), and the run then dies at
// `Greeted` blaming a level *under* the floor. A missing reading is not a zero.
let Some(level) = store.0.unit_level() else {
return;
};
if level < QUEUE_LEVEL {
gm(&net, format!(".levelup {}", QUEUE_LEVEL - level));
}
@@ -436,45 +655,76 @@ fn bg_probe(
// 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");
toggle_testing(&mut probe, &net);
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
// **Drain every frame and keep what comes back**, rather than taking one destructive
// look 1.5 s in. The reply is a world text that crosses the network, reaches the chat
// pipeline and then the tap; a single window wide enough to be reliable is wider than
// one worth waiting on every run, and a single window narrow enough to be quick misses
// the reply often enough to matter — a missed reply is how this probe misdiagnoses
// itself. Anything drained here that is *not* the reply is kept for the first census
// instead of being thrown on the floor.
if let Some(s) = script.as_deref_mut() {
let seen = drain_events(&mut probe, s);
// The LAST marker in the chunk wins, because both can appear in one drain when the
// correcting send's reply arrives alongside the first. `contains` cannot express
// that and was the reason a corrected run could read its own first reply again.
let on = seen.rfind("debugging");
let off = seen.rfind("normal playercount");
match (on, off) {
(Some(a), Some(b)) => probe.testing_seen = Some(a > b),
(Some(_), None) => probe.testing_seen = Some(true),
(None, Some(_)) => probe.testing_seen = Some(false),
(None, None) => {}
}
probe.pending_events.push_str(&seen);
}
let seen = script
.as_deref_mut()
.and_then(|s| s.eval::<String>(EVENT_DRAIN).ok())
.unwrap_or_default();
if seen.contains("normal playercount") {
if corrected {
match probe.testing_seen.take() {
Some(true) => {
info!(
"PROBE bg: TESTING on (1v0) — confirmed from the tap after {:.1}s",
now - sent_at
);
probe.testing_confirmed = true;
probe.phase = Phase::Scanning { since: now };
}
Some(false) 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;
finish(&mut probe, &net);
}
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"
);
Some(false) => {
info!("PROBE bg: TESTING was on; that send turned it OFF — sending once more");
toggle_testing(&mut probe, &net);
probe.phase = Phase::Toggled {
sent_at: now,
corrected: true,
};
}
None if now - sent_at > TOGGLE_REPLY_WINDOW => {
// Unread is a THIRD outcome, and it is carried forward rather than rounded to
// "on": the `Joined` failure text used to assert the flag "was confirmed ON
// before the join" even on this path, sending the reader to look at Deserter
// and brackets when the lever was the actual suspect.
warn!(
"PROBE bg: TESTING unread — no `.debug bg` reply reached the tap in \
{TOGGLE_REPLY_WINDOW:.0}s; continuing, but if the queue never pops, this \
is the first thing to doubt"
);
probe.phase = Phase::Scanning { since: now };
}
// Still inside the window: stay in `Toggled` and look again next frame.
None => {}
}
}
Phase::Scanning { since: sent_at } => {
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)| {
@@ -495,10 +745,10 @@ fn bg_probe(
};
} else if now - sent_at > 15.0 {
error!(
"PROBE bg: FAILURE — {} (entry {}) never streamed within 15 yd in 15 s",
"PROBE bg: FAILURE — {} (entry {}) never streamed within {NEAR_YD:.0} yd in 15 s",
arena.npc_name, arena.npc_entry
);
probe.phase = Phase::Done;
finish(&mut probe, &net);
}
}
Phase::Greeted { master, sent_at } => {
@@ -518,17 +768,23 @@ fn bg_probe(
"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;
finish(&mut probe, &net);
}
}
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));
// **Matched on the MAP as well as the status**, because a slot is not ours just because
// it is ready. A killed run can leave this character queued for another battleground,
// and vmangos re-sends that `SMSG_BATTLEFIELD_STATUS` at login — so a status-2 slot for
// Arathi Basin would be taken here, port the body into map 529, and the `Ported` arm
// would then time out complaining that the map "is still 529, not 489". The cause would
// read as a broken worldport; it is a stale queue entry the probe confirmed on purpose.
let ready = queue.slots().iter().position(|s| {
s.as_ref()
.is_some_and(|(st, _)| st.status == 2 && st.map_id == arena.map)
});
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)");
@@ -536,12 +792,12 @@ fn bg_probe(
if let Err(e) = script.eval::<()>(&format!("AcceptBattlefieldPort({slot}, 1)"))
{
error!("PROBE bg: FAILURE — AcceptBattlefieldPort raised: {e}");
probe.phase = Phase::Done;
finish(&mut probe, &net);
return;
}
} else {
error!("PROBE bg: FAILURE — no VM to take the port through");
probe.phase = Phase::Done;
finish(&mut probe, &net);
return;
}
probe.phase = Phase::Ported { sent_at: now };
@@ -581,13 +837,21 @@ fn bg_probe(
})
.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 bg: FAILURE — no slot reached status 2 in 30 s (slots: {}). {} \
Also worth checking: 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(" "),
if probe.testing_confirmed {
"The testing flag was READ BACK as on before the join, so the cause is \
probably not that."
} else {
"The testing flag was NEVER READ BACK (no `.debug bg` reply reached the \
tap), so it is the first suspect: a previous run that died before its \
own restore leaves the lever inverted, and a queue entry is only ever \
evaluated when a join or a leave schedules it."
},
);
probe.phase = Phase::Done;
finish(&mut probe, &net);
}
}
Phase::Ported { sent_at } => {
@@ -609,7 +873,7 @@ fn bg_probe(
"PROBE bg: FAILURE — 30 s after the port the map is still {here}, not {}",
arena.map
);
probe.phase = Phase::Done;
finish(&mut probe, &net);
}
}
Phase::Inside {
@@ -622,9 +886,15 @@ fn bg_probe(
}
let Some(mut script) = script else {
error!("PROBE bg: FAILURE — the VM went away inside the battleground");
probe.phase = Phase::Done;
finish(&mut probe, &net);
return;
};
// Drained HERE, not inside `census`, so the tap's self-heal can reach `probe` — and so
// everything the tap caught on the queue road is attributed to the window it actually
// happened in rather than being folded into the first census.
let mut events = std::mem::take(&mut probe.pending_events);
events.push_str(&drain_events(&mut probe, &mut script));
let tap_broken = probe.tap_broken;
census(
now - entered_at,
arena,
@@ -638,26 +908,29 @@ fn bg_probe(
&go_templates,
&queue,
&mut script,
&events,
tap_broken,
);
let samples = samples + 1;
// **Flip the testing flag back OFF once the match is running**, which is the only way
// a solo battleground can ever end. vmangos decrements `m_prematureCountDownTimer`
// inside an `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`),
// so while testing is on the countdown is armed and **frozen** — the arm above it runs
// once, the fire below it never reaches its condition, and a one-player Warsong Gulch
// runs forever (vanilla WSG has no time limit; three captures is its only other end).
// A run that measured this stayed in for 492 s with `winner` nil the whole way.
// **Once inside, the lever has done its job — put it back immediately.**
//
// Turned off at the first sample past the doors, so the flag is only on for the queue
// that needed it. The countdown then runs its full `BattleGround.PrematureFinishTimer`
// (5 min) from that moment, which is what [`census_samples`] has to cover.
if samples == DOORS_SAMPLE {
// It was an equality test on `DOORS_SAMPLE` (11), and that was wrong twice over. Any
// `WOW_PROBE_BG_SAMPLES` below 11 — a documented, user-facing dial — skipped it
// entirely and left the realm-global flag flipped for the next run, which is the exact
// breakage 2290 §2 records as having cost runs 2, 4 and 5. And there was no reason to
// wait: the flag is needed only for `CheckNormalMatch` to *form* the match, which has
// already happened by the time we are standing in it. vmangos arms the premature
// countdown when the battleground reaches `STATUS_IN_PROGRESS` (the doors) and
// decrements it inside `else if (!sBattleGroundMgr.isTesting())` (`BattleGround.cpp:337`),
// so restoring it now simply lets that clock run from the doors rather than from
// sample 11 — twelve seconds *earlier* to an ending, not later.
if samples == 1 {
info!(
"PROBE bg: TESTING off — the premature-finish countdown is frozen while it is \
on, so a solo match could never end"
"PROBE bg: TESTING — inside the instance; the lever has done its job and the \
premature-finish countdown is frozen while it is on"
);
gm(&net, ".debug bg");
leave_testing_as_found(&mut probe, &net);
}
if samples >= census_samples() {
@@ -692,7 +965,7 @@ fn bg_probe(
}
let Some(master) = battlefield.battlemaster() else {
error!("PROBE bg: FAILURE — the battlemaster list went away before the rejoin");
probe.phase = Phase::Done;
finish(&mut probe, &net);
return;
};
let _ = net.0.send(ClientCommand::BattlemasterJoin {
@@ -708,7 +981,7 @@ fn bg_probe(
return; // the hop crosses the map; let the far base stream in
}
let Some((entry, _)) = arena.flag else {
probe.phase = Phase::Done;
finish(&mut probe, &net);
return;
};
let found = units.iter().find(|(guid, kind, _, tf)| {
@@ -744,8 +1017,9 @@ fn bg_probe(
if now - at < f64::from(samples + 1) * 3.0 {
return;
}
let Some(script) = script else {
probe.phase = Phase::Done;
let Some(mut script) = script else {
error!("PROBE bg: FAILURE — the VM went away during the flag leg");
finish(&mut probe, &net);
return;
};
// **The two readings that say the flag is on the body.** World state 2339 is the
@@ -767,14 +1041,14 @@ fn bg_probe(
"#,
)
.unwrap_or_else(|e| format!("<raised: {e}>"));
let events = script.eval::<String>(EVENT_DRAIN).unwrap_or_default();
let events = drain_events(&mut probe, &mut script);
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),
ws(&states, 2338),
ws(&states, 2339),
ws(&states, 1581),
ws(&states, 1582),
auras.trim(),
if events.trim().is_empty() {
String::new()
@@ -804,7 +1078,7 @@ fn bg_probe(
error!(
"PROBE bg: FAILURE — still alive 15 s after `.die` (is the shield re-armed?)"
);
probe.phase = Phase::Done;
finish(&mut probe, &net);
}
}
Phase::Ghost {
@@ -816,7 +1090,7 @@ fn bg_probe(
return;
}
let Some(mut script) = script else {
probe.phase = Phase::Done;
finish(&mut probe, &net);
return;
};
// The whole point of this leg, in four readings: are we a ghost, is a spirit guide in
@@ -838,7 +1112,7 @@ fn bg_probe(
tostring(StaticPopup_Visible and StaticPopup_Visible(\"AREA_SPIRIT_HEAL\"))",
)
.unwrap_or_else(|e| format!("<raised: {e}>"));
let events = script.eval::<String>(EVENT_DRAIN).unwrap_or_default();
let events = drain_events(&mut probe, &mut script);
info!(
"PROBE bg: GHOST t={:.0}s ghost={ghost} pos=({:.0},{:.0},{:.0}) area={:?} guide={} wave={wave}{}",
now - released_at,
@@ -846,10 +1120,7 @@ fn bg_probe(
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")
),
nearest.map_or_else(|| "none".to_string(), |(g, d)| format!("{g:#x}@{d:.1}yd")),
if events.trim().is_empty() {
String::new()
} else {
@@ -865,7 +1136,7 @@ fn bg_probe(
Err(e) => error!("PROBE bg: GHOST AcceptAreaSpiritHeal() raised: {e}"),
}
report(arena, &net, &queue, &mut script);
probe.phase = Phase::Done;
finish(&mut probe, &net);
} else {
probe.phase = Phase::Ghost {
released_at,
@@ -878,6 +1149,58 @@ fn bg_probe(
}
}
/// **Reaching `Done` ends the process.** This probe was the one live probe here without it
/// (`probe_auction`'s tail and `ProbeExitPlugin::fire_probe_exit` are the established shape), so
/// the client simply idled at `Done` until whatever external `timeout` wrapped the run killed it:
/// a four-minute Warsong Gulch census cost a fifteen-minute wall clock, and a sequence of runs
/// spent most of its time watching a finished probe stand still.
///
/// It is its own system rather than an arm of [`bg_probe`] only because that system is already at
/// Bevy's parameter ceiling — there is nothing conditional about the split.
///
/// **It waits for the leave to land before it goes**, and that wait is the whole reason this is not
/// a one-liner. [`report`] ends by teleporting the body out to map 0, and `AppExit` stops the
/// Update schedule — so exiting on the next frame can cut the connection before the worldport
/// completes and strand the body **inside a live battleground**, which is precisely the poisoned
/// state this probe warns the next run about. So: leave first, confirmed by the map actually
/// changing, then exit; with [`LEAVE_GRACE`] as the backstop for a leave that never lands, because
/// hanging forever would be worse than logging out somewhere awkward.
///
/// The polite `AppExit` plus a hard backstop on its own OS thread is the pattern, not belt and
/// braces: `AppExit` stops the Update schedule, so an in-schedule backstop could never fire, and a
/// net/winit teardown hang would otherwise leave a zombie client holding the probe account.
fn bg_probe_exit(
time: ProbeClock,
mut probe: ResMut<BgProbe>,
map: Option<Res<CurrentMap>>,
mut exit: MessageWriter<AppExit>,
) {
if probe.phase != Phase::Done || probe.exited {
return;
}
let now = time.elapsed_secs_f64();
let since = *probe.done_at.get_or_insert(now);
let left = map.as_ref().is_none_or(|m| m.0 != arena().map);
if !left && now - since < LEAVE_GRACE {
return;
}
if !left {
warn!(
"PROBE bg: still on map {} {LEAVE_GRACE:.0}s after the leave — exiting anyway, but \
this body may still be registered in the battleground",
arena().map
);
}
probe.exited = true;
info!("PROBE bg: exiting");
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE bg: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
/// 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.
@@ -895,6 +1218,8 @@ fn census(
go_templates: &crate::go_templates::GameObjectTemplates,
queue: &BattlefieldQueue,
script: &mut UiScript,
events: &str,
tap_broken: bool,
) {
use benilla_protocol::EntityKind as K;
@@ -909,8 +1234,11 @@ fn census(
},
);
info!(
// `map=none` rather than `map=0`: 0 is Eastern Kingdoms, a real answer, and printing it
// for "there is no `CurrentMap` resource" is the same missing-reads-as-a-value mistake
// the world-state column had.
"PROBE bg: CENSUS t={t:.0}s map={} area={:?} pos=({:.0},{:.0},{:.0}) terrain={terrain}",
map.map_or(0, |m| m.0),
map.map_or_else(|| "none".to_string(), |m| m.0.to_string()),
area.and_then(|a| a.0),
p.x,
p.y,
@@ -1067,28 +1395,29 @@ fn census(
);
// 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::<String>(EVENT_DRAIN)
.unwrap_or_else(|e| format!("<raised: {e}>"));
// sample. An empty window while the server is running the start countdown is a finding —
// **provided the tap is alive**, which is why a broken tap says so instead of printing
// `(none)`. "The battleground fired nothing" and "nobody was listening" are opposite
// conclusions and used to produce identical lines.
info!(
"PROBE bg: EVENTS{}",
if events.trim().is_empty() {
if tap_broken {
" <tap broken — see TAP FAILURE above; this is NOT 'no events'>".to_string()
} else if events.trim().is_empty() {
" (none)".to_string()
} else {
events
events.to_string()
}
);
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;
// **No `LUA` column, deliberately.** It used to print `LUA clean` off `take_errors()`, which
// reads as "the interface raised nothing inside a battleground for the last 12 s". It cannot
// mean that: `take_errors` is a DRAIN, and two systems already drain it every frame
// (`ui_script::input` and `ui_script::extract::extract`), so this call could only ever see
// whatever our own `eval`s in this same frame had just raised. A column that reports a 12 s
// window while measuring a fraction of one frame is worse than no column, because it reads as
// evidence. Engine-side Lua raises appear as `WARN ui_script:` lines — grep for those.
let _ = (arena, script);
}
/// The run's last act: drive the two battleground windows the census cannot reach by watching
@@ -1105,6 +1434,24 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m
//
// Through `getglobal`, because `BattlefieldMinimap` is LoadOnDemand: indexing it before the
// toggle that loads it is a nil-index raise, and "not loaded yet" is a reading, not an error.
// **Does the battle map have anything to draw?** `IsShown()` alone cannot say: the stock
// `BattlefieldMinimap_Update` bails on its fourth line when `GetMapInfo()` is nil, so a window
// that is up and completely empty reports exactly the same `IsShown=1` as one full of terrain.
// These four readings are the difference, and they are the ones the minimap round never took.
let mapinfo = script
.eval::<String>(
r#"
local f, sx, sy, ox, oy = GetMapInfo();
return "file=" .. tostring(f)
.. " continent=" .. tostring(GetCurrentMapContinent())
.. " zone=" .. tostring(GetCurrentMapZone())
.. " overlays=" .. tostring(GetNumMapOverlays())
.. " landmarks=" .. tostring(GetNumMapLandmarks());
"#,
)
.unwrap_or_else(|e| format!("<raised: {e}>"));
info!("PROBE bg: MAPINFO {mapinfo}");
for name in ["WorldStateScoreFrame", "BattlefieldMinimap"] {
let shown = script
.eval::<String>(&format!(
@@ -1148,8 +1495,15 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m
queue.active_map()
);
let _ = script.eval::<()>("LeaveBattlefield()");
gm(net, ".recall");
// The lever was already put back at `DOORS_SAMPLE` (see the `Inside` arm) — a second toggle
// here would turn it back ON and leave it that way for the next run.
// **NOT `.recall`.** vmangos saves the recall point on *every* `.go`
// (`HandleGoHelper` → `SaveRecallPosition()`, `TeleportCommands.cpp:755`), so the flag leg's
// `.go xyz … 489` overwrote it with a position **inside the battleground**. `.recall` then
// resolves to the same map and `Player::TeleportTo` takes its near-teleport arm — the probe
// printed `LEAVING map 489` and went nowhere, leaving the body logged out inside a live
// instance and the queue slot uncleared. That is the poisoned state the `Joined` failure text
// warns the *next* run about, manufactured by this one. An explicit hop to the battlemaster
// alcove on map 0 is a real map change, which is what clears the slot.
let [x, y, z] = arena.at;
gm(net, format!(".go xyz {x} {y} {z} 0"));
info!("PROBE bg: DONE");
}
+11 -8
View File
@@ -1,14 +1,16 @@
//! `ChrClasses.dbc`, loaded once and read by everything that asks the client a question about a
//! class.
//!
//! Two consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag — which is exactly why the
//! table does not live inside either of them. It used to live in the pet book, back when the pet
//! Three consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag, and
//! [`crate::spell_mods`] wants field 15's class spell-family — which is exactly why the
//! table does not live inside any of them. It used to live in the pet book, back when the pet
//! token was the only column anyone read.
//!
//! The resource is **absent**, not empty, when the load fails. Both readers then fall to the
//! reference's own degraded answer (`"PET"` for the token, no relic slot for any class), so a
//! missing table costs a warlock the word "Demon" and a paladin their relic branches rather than
//! The resource is **absent**, not empty, when the load fails. Every reader then falls to the
//! reference's own degraded answer (`"PET"` for the token, no relic slot for any class, family 0
//! — which the modifier gate's first conjunct refuses), so a missing table costs a warlock the
//! word "Demon", a paladin their relic branches and everyone their talent modifiers rather than
//! taking the client down.
use bevy::prelude::*;
@@ -39,8 +41,9 @@ fn load_chr_classes(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
Ok(table) => commands.insert_resource(ChrClassTable(table)),
Err(e) => warn!(
"chr_classes: ChrClasses.dbc failed to load — every pet book tab reads the client's \
own \"PET\" fallback, so a warlock's says Pet rather than Demon, and no class reads \
as having a relic slot: {e:#}"
own \"PET\" fallback, so a warlock's says Pet rather than Demon, no class reads \
as having a relic slot, and no talent spell modifier can apply (the gate has no \
class family to match): {e:#}"
),
}
}
+40 -13
View File
@@ -262,6 +262,11 @@ impl PluginGroup for GamePlugins {
// `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an
// attack target stands and no swing lands.
.add(crate::swing_refusal::SwingRefusalPlugin)
// The talent spell-modifier tables (`SMSG_SET_FLAT_/PCT_SPELL_MODIFIER`): the wire
// fills them, world-enter clears them, and `usable::power_cost` reads op 14 out of
// them. Beside the swing refusal because both are the same shape — a small wire-fed
// store with a world-entry reset — and neither has a feed of its own.
.add(crate::spell_mods::SpellModsPlugin)
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
// server-asked question whose only wire answer is yes — and here for that reason.
@@ -839,7 +844,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,705 actionable. The largest part of those is `Transform` on disjoint lanes (716 pairs
/// 3,699 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
@@ -867,17 +872,37 @@ 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.
/// **3,288 (decision 2291, corrected)** — the area-spirit-healer poll. Its **four** remaining
/// pairs, named one by one because the first version of this paragraph named the wrong ones:
/// `portrait::booth::face_booth_billboards`, `quest_markers::bake_seat_scale` and
/// `entities::live_display::tick_scale_ease` over `Transform`, and
/// `entities::live_display::refresh_live_display` over **`NetEntity`** — a row read, not a
/// `Transform` pair at all. All four are the poll seeing a unit one mover early or late, which
/// moves a 20 yd acquire decision by at most one frame of walking; 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.
///
/// **The correction is the part worth keeping.** This paragraph first claimed "+6, all
/// `&Transform`", and that the poll's "one write-write pair … is *declared*". Both were wrong,
/// and the second one hid a real defect. There were **two** undeclared write-write pairs, and
/// the system on the other side of both was `drain_latch_verbs` — the one that turns
/// `AcceptAreaSpiritHeal()` into `CMSG_AREA_SPIRIT_HEALER_QUEUE`. It met the poll *and*
/// `target::click::act_on_right_click` over `AreaSpiritHealer`, because `.after(UiInput)`
/// orders it against neither (`UiInput` precedes `WorldStage::Input`; `TargetUpdate` follows
/// it). The drain takes its presses unconditionally and only then reads the healer, so that
/// undeclared order decided whether a ghost's Accept became a packet or vanished with no
/// message. Declaring `drain_latch_verbs.before(TargetUpdate)` — the reference's own order, the
/// dialog's Lua handler running in the UI dispatch ahead of `CGWorldFrame`'s poll and pick —
/// removed exactly those two.
///
/// **Re-measured at 3,289 on the rebased tree**, not carried across as arithmetic. This branch
/// measured 3,290 → 3,288 on its own base; a neighbour landed 3,291 first, and the resolution
/// is the number the dump prints on the merged tree — which happens to agree with 3,291 − 2
/// this time, and is right for the reason that it was read rather than that it adds up.
///
/// A ceiling raised with the wrong reason is a ratchet that has stopped meaning anything. The
/// lesson this one cost: **read the dump, do not reason about what the new pairs must be.**
///
/// **3,291 (decision 2300)** — the glue create/main-menu scene's material lane: exactly **one**
/// new pair, `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene`
@@ -894,7 +919,7 @@ pub(crate) mod schedule_tests {
/// 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_291;
const UPDATE_ACTIONABLE_CEILING: usize = 3_289;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
@@ -1189,6 +1214,8 @@ pub(crate) mod schedule_tests {
const EXEMPT: &[(&str, &str, Because, &str)] = &[
("bindings.rs", "sync_dispatch", Because::MemoLatched,
"`seen_generation` is a `VmMemo`: a new VM reads `None`, rebuilds and re-fires UPDATE_BINDINGS"),
("capture/probe_bg.rs", "bg_probe", Because::SelfHealing,
"the battleground probe: dev-only (`WOW_PROBE_BG`, `cfg(feature = \"dev\")`) so it is not in a player build at all, and its `mem::take` is of its OWN pending-events string, not a queue anything else fills. Its one real VM dependency is the Lua event tap, which self-heals: `EVENT_DRAIN` returns a `<tap-gone>` sentinel when the tap's globals are missing — the case this window causes, since a tap installed in the boot VM is discarded when `mint_entry_vm` builds the interface — and the probe re-installs on the next frame"),
("death.rs", "feed_death", Because::MemoLatched,
"`feed.vm: VmMemo<DeathAnnounced>`: a fresh memo makes the first snapshot an edge and re-announces a held offer, confirm and corpse range"),
("screenshot.rs", "ask_for_captures", Because::FilledByVm,
+3
View File
@@ -119,6 +119,9 @@ mod opaque2d;
mod shutdown;
mod smart_rect;
mod sound;
/// The two talent spell-modifier tables (`SMSG_SET_FLAT_/PCT_SPELL_MODIFIER`) and the read that
/// puts them on a number.
mod spell_mods;
/// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`).
mod swing_refusal;
mod target;
+9 -2
View File
@@ -53,8 +53,8 @@ use quests::{
use spells::{
action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update,
clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, removed_spell,
spell_book, spell_chain_targets, spell_cooldowns, spell_delayed, spell_failed_other, spell_go,
spell_start, superceded_spell,
set_spell_modifier, spell_book, spell_chain_targets, spell_cooldowns, spell_delayed,
spell_failed_other, spell_go, spell_start, superceded_spell,
};
/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982).
@@ -144,6 +144,7 @@ pub(crate) fn apply_net_updates(
mut ui_error_texts,
mut queued_melee,
mut aura_durations,
mut spell_mods,
} = actions;
let AnimWriters {
mut server_sounds,
@@ -1649,6 +1650,12 @@ pub(crate) fn apply_net_updates(
&mut aura_durations,
real_clock.elapsed_secs_f64(),
),
SessionEvent::SpellModifier {
flat,
mask_bit,
op,
value,
} => set_spell_modifier(flat, mask_bit, op, value, &mut spell_mods),
SessionEvent::PlaySpellVisual { unit, kit_id } => {
anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut kit_pushes)
}
@@ -298,6 +298,9 @@ pub(crate) struct ActionStores<'w> {
/// `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped on
/// [`Clocks::real`] for the `ui_aura` slot-join.
pub aura_durations: ResMut<'w, crate::ui_aura::AuraDurations>,
/// The two talent spell-modifier tables (`crate::spell_mods`) — one wire packet is one
/// absolute cell, and this is its only writer.
pub spell_mods: ResMut<'w, crate::spell_mods::SpellModifiers>,
}
/// Every animation-bearing or audible message the drain emits, in packet order, plus the
@@ -1046,6 +1046,23 @@ pub(super) fn aura_duration(
durations.set(slot, remaining_ms, now_secs);
}
/// One cell of one talent spell-modifier table (`SMSG_SET_FLAT_SPELL_MODIFIER` /
/// `SMSG_SET_PCT_SPELL_MODIFIER`) — `HandleSetSpellModifier 0x6e9950`'s whole body, which is a
/// single store: the server sends the absolute value of that `(family bit, op)` pair, never a
/// delta, so there is nothing to accumulate and nothing to invalidate.
///
/// The out-of-range refusal lives on the store ([`crate::spell_mods::SpellModifiers::set`], which
/// documents why it is ours and not the reference's).
pub(super) fn set_spell_modifier(
flat: bool,
mask_bit: u8,
op: u8,
value: i32,
mods: &mut crate::spell_mods::SpellModifiers,
) {
mods.set(flat, mask_bit, op, value);
}
/// The caster's chain-target hop array, filled from a wire target list — the reference's
/// `0x605780` (decision 0955): it **clears before it fills** and **skips any entry equal to the
/// unit's own guid** (`0x6057bf`/`0x6057c9`). Targets not streamed to us drop out here: an endpoint
@@ -1365,6 +1382,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
@@ -1657,6 +1675,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
@@ -1825,6 +1844,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
@@ -1959,6 +1979,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
@@ -2089,6 +2110,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<AutoRepeatActive>();
let self_e = app
@@ -2370,6 +2392,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
@@ -2559,6 +2582,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_action::ChainCasts>();
let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id();
+564
View File
@@ -0,0 +1,564 @@
//! The **talent spell-modifier tables** — the client-side half of every talent that cheapens a
//! spell, shortens its cast or cooldown, extends its range or radius, or lengthens its duration.
//!
//! Two `i32[64][29]` tables (`SPELLMOD_FLAT 0xcead60`, `SPELLMOD_PCT 0xcecb30`), one handler that
//! fills them (`Spell_C::HandleSetSpellModifier 0x6e9950`, registered for both
//! `SMSG_SET_FLAT_SPELL_MODIFIER` and `SMSG_SET_PCT_SPELL_MODIFIER` by `Spell_C::SystemInitialize
//! 0x6e7150`), and one reader that answers them (`GetSpellModifiers 0x6e6b30`). Every law here is
//! byte-VERIFIED in wow-re `system/spell/scratch/spellmod-table-law.md`; this module is that note
//! turned into the one place benilla asks "does a talent change this number?".
//!
//! **The index is `mask_bit * 29 + op`, in both directions, and it is the thing to get right.**
//! The writer multiplies its FIRST wire byte (`6e9993: imul eax,eax,0x1d`); the reader
//! strength-reduces the same address to `4*op + 0x74*i` over a loop counter bounded by
//! `cmp eax,0x40`, which forces the first factor to be the 0..63 `SpellFamilyFlags` **bit index**
//! and the second the 0..28 SpellModOp. The transposed form addresses 47% of the array
//! (`29*28 + 63 = 875` against 1856 cells) and makes writer and reader touch systematically
//! disjoint cells — every modifier silently dropped, nothing to see in a log. wow-re's own
//! `wave-handlers.md` carried the transposed form, `verified`, for months. [`index`] is the single
//! expression both sides here go through, so they cannot drift apart.
//!
//! The server agrees, independently and in source: vmangos `Player::SendSpellMod`
//! (`Objects/Player.cpp:17815`) loops `for (int eff = 0; eff < 64; ++eff)` over the modifier's own
//! 64-bit mask and writes `uint8(eff)`, `uint8(mod->op)`, `int32(val)` for each set bit — first
//! byte the family-flag bit, second the op — with `MAX_SPELLMOD = 29` as the enum's size.
//!
//! **The value is absolute.** The store is a plain `mov`, and vmangos `Player::SendSpellMod` sends
//! one packet per set mask bit carrying that `(bit, op)` pair's total. Nothing accumulates, nothing
//! expires, and there is no talent-change clear: a respec is a fresh set of absolute cells.
//!
//! **The read is three conjunct gates and then a sum over all 64 bits** ([`SpellModifiers::apply`]
//! via [`SpellModifiers::modifiers`]): the spell's `SpellFamilyName` must be nonzero, must equal
//! the local player's own class family ([`SpellModifiers::class_family`]), and the spell must not
//! carry `AttributesEx3` bit 29. Then every set bit of the spell's 64-bit `SpellFamilyFlags`
//! contributes one cell from each table — **no early break**, and the high dword is live (322
//! shipped spells set more than one bit; the highest index in the file is 35).
//!
//! **The trap this module's shape exists to make unrepresentable.** `0x6e6b30` has four exits.
//! Three write `(flat, pct) = (0, 100)`; the fourth — *the loop ran and nothing accumulated*, which
//! is the COMMON case — leaves `pct = 0`, because it never reaches the `add ebx,0x64`. The
//! reference gets away with it because all three appliers gate on the returned boolean. A
//! re-implementation that hands a caller a bare `(flat, pct)` pair multiplies every unmodified
//! spell by **zero**: every spell would cost 0 mana. So [`SpellModifiers::modifiers`] returns
//! `Option<SpellMod>` — `None` IS that exit, and it is not a pair anyone can multiply by — and
//! [`SpellMod`] is private with [`SpellMod::apply`] its only verb. `apply` is the reference's own
//! integer applier `0x6e6af0`, boolean gate included.
//!
//! ## The 29 ops
//!
//! All 29 are stored: the stride is what the index arithmetic is made of, and nine of them have no
//! reader anywhere in the reference image while the handler will happily write them. The numbers
//! are VERIFIED (every one of the 35 call sites passes a literal); the names are vmangos
//! corroboration, and **op 24's name is INFERRED and suspect** — the client routes aura 65
//! (*casting speed*) to it while vmangos calls it `SPELL_BONUS_DAMAGE`, so key on the number.
//!
//! | op | name | benilla consumer |
//! |---|---|---|
//! | 0 | DAMAGE | — |
//! | 1 | DURATION | — |
//! | 2 | THREAT | — |
//! | 3 | ATTACK_POWER | — |
//! | 4 | CHARGES | — |
//! | 5 | RANGE | — |
//! | 6 | RADIUS | — |
//! | 7 | CRITICAL_CHANCE | never read by the reference |
//! | 8 | ALL_EFFECTS | — |
//! | 9 | NOT_LOSE_CASTING_TIME | never read |
//! | 10 | CASTING_TIME | — |
//! | 11 | COOLDOWN | — |
//! | 12 | SPEED | — |
//! | 13 | *(no enumerator)* | never read |
//! | **14** | **COST** | [`OP_COST`] — `ui_action::usable::power_cost` |
//! | 15 | CRIT_DAMAGE_BONUS | never read |
//! | 16 | RESIST_MISS_CHANCE | never read |
//! | 17 | JUMP_TARGETS | — |
//! | 18 | CHANCE_OF_SUCCESS | — |
//! | 19 | ACTIVATION_TIME | — |
//! | 20 | EFFECT_PAST_FIRST | never read |
//! | 21 | GLOBAL_COOLDOWN | — |
//! | 22 | DOT | — |
//! | 23 | HASTE | — |
//! | 24 | *(aura-65 op; name INFERRED)* | — |
//! | 25 | *(no enumerator)* | never read |
//! | 26 | *(no enumerator)* | never read |
//! | 27 | MULTIPLE_VALUE | — |
//! | 28 | RESIST_DISPEL_CHANCE | never read |
//!
//! ## Where this diverges from the reference, and why
//!
//! - **Both wire bytes are range-checked.** The reference checks neither: `field1 = 64, field2 = 19`
//! writes at byte 7500 from the FLAT table's base — 76 past its end, straight onto `[0xcecaac]`,
//! the class-family global the reader itself compares against — and `field1 = 65, field2 = 23`
//! lands on element 0 of the PCT table. That is a buffer overrun, not a behaviour, and reproducing it is not
//! fidelity. [`SpellModifiers::set`] drops the packet with a `warn!` naming both values.
//! - **The lifecycle hangs off the session edge, not a process global.** The reference clears both
//! tables (and the class family) in `0x6e7150`, which runs once per **world-enter** — the Enter
//! World click, through `CGlueMgr::Update 0x46b930` — and nowhere else; its teardown
//! `Spell_C::Destroy 0x6e99e0` deliberately does not touch them. [`clear_on_world_enter`] is that
//! same edge, `OnEnter(ClientState::InWorld)`.
use bevy::prelude::*;
use benilla_formats::SpellDisplay;
use crate::char_select::ClientState;
use crate::chr_classes::ChrClassTable;
use crate::net::{ObjectStore, SelfPlayer};
/// The number of SpellModOps — the table's column count, and therefore the index **stride**
/// (`imul eax,eax,0x1d`, and the reader's `add edx,0x74` = `29*4`).
const OPS: usize = 29;
/// The number of `SpellFamilyFlags` bits — the table's row count (`cmp eax,0x40`).
const BITS: usize = 64;
/// `0x740` dwords per table, exactly (`6e72ba: mov ecx,0x740` before each `rep stosd`), and
/// `29*63 + 28 == 1855` fills it with nothing left over — which is itself one of the three
/// discriminators that settle the index's factor roles.
const CELLS: usize = BITS * OPS;
/// `SPELLMOD_COST` — the one op benilla reads today ([`crate::ui_action::usable::power_cost`], the
/// reference's `GetPowerCost 0x6e31b0` @ `6e32e3`). The module header's table names the other 28.
pub(crate) const OP_COST: u8 = 14;
/// `AttributesEx3` bit 29 (`SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS`, vmangos
/// `SpellDefines.h:940`) — the third gate, `0x6e6b52: test [SpellRec+0x24], 0x20000000`. A spell
/// carrying it takes no modifier at all, whatever the tables say.
const ATTR_EX3_IGNORE_CASTER_MODIFIERS: u32 = 0x2000_0000;
/// A cell's offset in either table. **The one expression**, so the writer and the reader can never
/// disagree about which of the two bytes is the row — the exact failure this system is famous for.
///
/// Callers have already range-checked both arguments ([`SpellModifiers::set`] refuses the packet,
/// [`SpellModifiers::modifiers`] refuses the op and iterates the bit itself), so this cannot leave
/// the array.
fn index(mask_bit: u8, op: u8) -> usize {
usize::from(mask_bit) * OPS + usize::from(op)
}
/// What a talent does to one number: a flat term added first, then a percentage applied — the
/// reference's `(value + flat) * pct / 100`.
///
/// Private, and [`Self::apply`] is the only way to spend it, because a `(flat, pct)` pair loose in
/// the codebase is exactly the shape that multiplies an unmodified spell by zero (module header).
/// [`Default`] is the identity, matching the three gate-failure exits that write `(0, 100)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SpellMod {
flat: i32,
/// Already `max(0, 100 + Σ)` — the `+100` happens **once**, inside the reader (`6e6bb4`), and
/// never in an applier. So this is a raw percentage multiplier: 100 is "unchanged".
pct: i32,
}
impl Default for SpellMod {
fn default() -> Self {
SpellMod { flat: 0, pct: 100 }
}
}
impl SpellMod {
/// The reference's integer applier `0x6e6af0`: `*v = (*v + flat) * pct / 100`, with the flat
/// term added **before** the percentage and the division truncating **toward zero** (C signed
/// division — `6e6b14`'s magic-multiply/`sar 5`/`shr 31`/`add` sequence is exactly that).
///
/// Computed in `i64` and saturated back, where the reference's 32-bit `imul` simply wraps: a
/// wrap here would be a debug panic on a packet nobody sent, and no real modifier comes within
/// several orders of magnitude of the boundary (a cost is a few hundred, a pct a few hundred).
fn apply(self, value: i32) -> i32 {
let scaled = (i64::from(value) + i64::from(self.flat)) * i64::from(self.pct) / 100;
scaled.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
}
}
/// The two tables and the class family the gate compares against — benilla's `0xcead60` /
/// `0xcecb30` / `0xcecaac`.
///
/// Filled only by the wire (`net::apply::spells::set_spell_modifier`), cleared only at world-enter
/// ([`clear_on_world_enter`]), and read live at every call site: like the reference, there is no
/// derived cache to invalidate.
#[derive(Resource)]
pub(crate) struct SpellModifiers {
flat: [i32; CELLS],
pct: [i32; CELLS],
/// The local player's class spell-family (`ChrClasses.dbc` field 15). `0` until the avatar
/// resolves, which is why the reader's first conjunct (`SpellFamilyName != 0`) exists at all:
/// it covers exactly that window, where a family-0 spell would otherwise match.
class_family: u32,
}
impl Default for SpellModifiers {
fn default() -> Self {
SpellModifiers {
flat: [0; CELLS],
pct: [0; CELLS],
class_family: 0,
}
}
}
impl SpellModifiers {
/// Store one cell, absolutely — `HandleSetSpellModifier 0x6e9950`'s whole body.
///
/// The range check is benilla's, not the reference's (module header): a `mask_bit` of 64 is a
/// write into the neighbouring global there, and we would rather drop a packet than reproduce
/// an overrun. Nothing legitimate trips it — vmangos derives both bytes from its own enums.
pub(crate) fn set(&mut self, flat: bool, mask_bit: u8, op: u8, value: i32) {
if usize::from(mask_bit) >= BITS || usize::from(op) >= OPS {
warn!(
"spell_mods: dropping an out-of-range {} modifier — mask bit {mask_bit} \
(0..=63), op {op} (0..=28), value {value}",
if flat { "flat" } else { "pct" }
);
return;
}
let cell = index(mask_bit, op);
if flat {
self.flat[cell] = value;
} else {
self.pct[cell] = value;
}
}
/// Set the gate's right-hand side — the reference's `0x6e6ca0`, whose only caller is the
/// local-player create-finalise. [`track_class_family`] is that caller here.
pub(crate) fn set_class_family(&mut self, family: u32) {
self.class_family = family;
}
/// Drop everything — both tables and the class family, the exact set `0x6e7150` zeroes.
pub(crate) fn clear(&mut self) {
self.flat = [0; CELLS];
self.pct = [0; CELLS];
self.class_family = 0;
}
/// `GetSpellModifiers 0x6e6b30` for one spell and one op: the three gates, then the sum over
/// every set family bit.
///
/// `None` = "no modifier applies", covering **all four** of the reference's false exits — the
/// three gate failures and the both-sums-are-zero one (`6e6ba8`/`6e6bad`). The caller uses its
/// base value unchanged; see the module header for why this is an `Option` and not a pair.
///
/// The op is range-checked here too. The reference does not check it either (`shl edx,2` on an
/// unmasked argument) and is safe only because all 35 of its call sites pass a literal ≤ 27 —
/// so do ours, and this costs one compare to make that structural rather than conventional.
fn modifiers(&self, d: &SpellDisplay, op: u8) -> Option<SpellMod> {
if d.spell_family == 0
|| d.spell_family != self.class_family
|| d.attributes_ex3 & ATTR_EX3_IGNORE_CASTER_MODIFIERS != 0
|| usize::from(op) >= OPS
{
return None;
}
// All 64 iterations, no early break: `n` set bits accumulate `n` cells from each table
// (`6e6b8f`/`6e6b97` are `add [mem],reg`, and the only branch in the body is the skip).
// `wrapping_add` because the reference's accumulators do, and because a server we do not
// control should not be able to panic a debug build.
let (mut flat, mut pct) = (0i32, 0i32);
for bit in 0..BITS as u8 {
if d.spell_family_flags >> bit & 1 == 0 {
continue;
}
let cell = index(bit, op);
flat = flat.wrapping_add(self.flat[cell]);
pct = pct.wrapping_add(self.pct[cell]);
}
if flat == 0 && pct == 0 {
return None;
}
// The `+100` and the clamp, once, here — never in an applier (`6e6bb4` … `6e6bc7`).
Some(SpellMod {
flat,
pct: pct.wrapping_add(100).max(0),
})
}
/// Put a spell's modifier for `op` onto `value`, or hand `value` back untouched — the
/// reference's applier plus its `test al,al` gate, in one verb so a caller cannot skip the
/// gate.
pub(crate) fn apply(&self, d: &SpellDisplay, op: u8, value: i32) -> i32 {
self.modifiers(d, op).map_or(value, |m| m.apply(value))
}
}
/// Entering the world drops both tables and the class family — `Spell_C::SystemInitialize
/// 0x6e7150`'s two `rep stosd`s and its `[0xcecaac] = 0`, which the §5 walked to
/// `CGlueMgr::Update 0x46b930` and pinned to **once per world entry**, not once per process.
///
/// There is deliberately nothing else: no talent-change clear (the server re-sends absolute cells),
/// and no world-LEAVE clear (the reference's own teardown `0x6e99e0` never touches them, and the
/// next entry is what zeroes them).
fn clear_on_world_enter(mut mods: ResMut<SpellModifiers>) {
mods.clear();
}
/// Keep the gate's class family in step with the avatar — the reference's `0x6e6ca0`, whose sole
/// caller is the local-player create-finalise `0x5debcc`: it reads `UNIT_FIELD_BYTES_0` byte 1 and
/// stores `ChrClasses[class]` field 15.
///
/// One writer rather than a line wherever the avatar is built, for the reason
/// `char_select::publish_world_live` gives: a mirror a future transition can forget is worse than
/// the coupling it replaces. And it **never writes 0 back** — an absent avatar leaves the last
/// value standing, because a cross-map worldport drops the entity mid-session while everything the
/// server told us stays true (decision 0900's shape). Only the world-enter clear zeroes it.
fn track_class_family(
mut mods: ResMut<SpellModifiers>,
classes: Option<Res<ChrClassTable>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
) {
let Some(classes) = classes else { return };
let Some(class) = self_q.iter().next().and_then(|s| s.0.unit_class()) else {
return;
};
let family = classes.0.spell_family(u32::from(class));
// Guarded so the write is a real edge: an unconditional store would mark the resource changed
// every frame, and the tooltip feed rebuilds on exactly that signal.
if mods.class_family != family {
mods.set_class_family(family);
}
}
pub(crate) struct SpellModsPlugin;
impl Plugin for SpellModsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SpellModifiers>()
.add_systems(
Update,
// After the net stage that merges the avatar's descriptor, and before the feeds
// that read a cost through it — so the family is this frame's, never last
// frame's, on the frame the avatar first resolves.
track_class_family
.after(benilla_world::schedule::WorldStage::Net)
.before(crate::ui_unit::UnitFeed),
)
.add_systems(OnEnter(ClientState::InWorld), clear_on_world_enter);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A mage spell (family 3) with the given bits set, against a mage player.
fn mage(bits: &[u8]) -> SpellDisplay {
SpellDisplay {
spell_family: 3,
spell_family_flags: bits.iter().fold(0u64, |m, b| m | 1 << b),
..Default::default()
}
}
fn mage_tables() -> SpellModifiers {
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
mods
}
/// **The index, and the transposition that would pass a lazier test.** The cell the server
/// wrote for `(bit 5, op 14)` must be the cell the reader reads for a spell with bit 5 set —
/// and, crucially, must NOT be the one it reads for a spell with bit 14 set, which is where
/// `op * 29 + bit` lands the same packet.
///
/// Both halves are needed: a symmetric pair like `(14, 14)` agrees under either arithmetic, so
/// this uses 5 and 14 and asserts the cross-read is empty.
#[test]
fn the_index_is_mask_bit_times_29_plus_op() {
assert_eq!(index(5, 14), 5 * 29 + 14);
assert_ne!(
index(5, 14),
14 * 29 + 5,
"the transposed form is a different cell"
);
let mut mods = mage_tables();
mods.set(true, 5, OP_COST, -30);
// The spell the packet is about: bit 5 set, op 14 read.
assert_eq!(mods.apply(&mage(&[5]), OP_COST, 100), 70);
// The spell the TRANSPOSED index would have served: bit 14 set. Under `op*29 + bit` the
// write would have landed where this read looks, and this would be 70.
assert_eq!(
mods.apply(&mage(&[14]), OP_COST, 100),
100,
"bit 14 is not bit 5 — a transposed index would have crossed them"
);
}
/// **The pct-0 trap.** The common path is "this spell has no talent on it": the reader's
/// scanned-and-nothing-matched exit leaves `pct = 0` in the reference, and a consumer that
/// ignored the boolean would multiply by it. Here the exit is `None`, so the base value
/// survives — for a spell with no bits at all, for a spell whose bits are all zero cells, and
/// for each of the three gate failures.
#[test]
fn nothing_applying_leaves_the_value_exactly_alone() {
let mut mods = mage_tables();
// A live modifier somewhere else in the table, so "the tables are empty" is not what is
// being tested.
mods.set(false, 30, OP_COST, -100);
assert_eq!(mods.apply(&mage(&[]), OP_COST, 100), 100, "no family bits");
assert_eq!(
mods.apply(&mage(&[5]), OP_COST, 100),
100,
"bit 5's cells are zero"
);
assert_eq!(
mods.apply(&mage(&[30]), OP_COST, 100),
0,
"and the live cell really is live — a −100% cost, not an inert table"
);
// Gate 1: SpellFamilyName == 0 (every creature and item spell in the file).
let mut d = mage(&[30]);
d.spell_family = 0;
assert_eq!(mods.apply(&d, OP_COST, 100), 100);
// Gate 2: another class's spell.
let mut d = mage(&[30]);
d.spell_family = 4;
assert_eq!(mods.apply(&d, OP_COST, 100), 100);
// Gate 3: IGNORE_CASTER_MODIFIERS.
let mut d = mage(&[30]);
d.attributes_ex3 = ATTR_EX3_IGNORE_CASTER_MODIFIERS;
assert_eq!(mods.apply(&d, OP_COST, 100), 100);
// An op with no cells is not a wipe either.
assert_eq!(mods.apply(&mage(&[30]), 0, 100), 100);
}
/// The sum runs over **every** set bit of a 64-bit mask — both dwords — and the flat term is
/// added before the percentage.
#[test]
fn every_set_bit_contributes_and_flat_lands_before_pct() {
let mut mods = mage_tables();
mods.set(true, 5, OP_COST, -10);
mods.set(true, 35, OP_COST, -15);
mods.set(false, 5, OP_COST, -20);
mods.set(false, 35, OP_COST, -30);
// Bit 35 alone: the HIGH dword, which a 32-bit mask read would drop entirely.
assert_eq!(
mods.apply(&mage(&[35]), OP_COST, 100),
(100 - 15) * 70 / 100
);
// Both: flat −25 first, then 100 − 50 = 50 percent.
assert_eq!(
mods.apply(&mage(&[5, 35]), OP_COST, 100),
(100 - 25) * 50 / 100
);
// Order matters — `value * pct / 100 + flat` would give 25, not 37.
assert_eq!(mods.apply(&mage(&[5, 35]), OP_COST, 100), 37);
}
/// The division truncates **toward zero**, both signs — C's `/`, not a floor and not a round.
#[test]
fn the_division_truncates_toward_zero() {
let mut mods = mage_tables();
// 99 × 50% = 49.5 → 49, not 50.
mods.set(false, 5, OP_COST, -50);
assert_eq!(mods.apply(&mage(&[5]), OP_COST, 99), 49);
// The negative side: −99 × 50% = −49.5 → −49 (toward zero), where a floor gives −50.
assert_eq!(SpellMod { flat: 0, pct: 50 }.apply(-99), -49);
// And the identity really is the identity.
assert_eq!(SpellMod::default().apply(-7), -7);
}
/// The percentage sum is clamped at 0 — a −150% modifier zeroes the value, it does not invert
/// it (`6e6bbc`'s `sets`/`dec`/`and`, on the SIGNED sum).
#[test]
fn a_percentage_below_minus_one_hundred_clamps_to_zero() {
let mut mods = mage_tables();
mods.set(false, 5, OP_COST, -150);
assert_eq!(mods.apply(&mage(&[5]), OP_COST, 200), 0);
}
/// Out-of-range wire bytes are dropped, not stored — and dropped without disturbing the cell
/// the reference's own overrun would have hit.
#[test]
fn an_out_of_range_packet_is_dropped() {
let mut mods = mage_tables();
// `(64, 19)` is the reference's documented overrun into the class-family global.
mods.set(true, 64, 19, 999);
// `(65, 23)` is the one that lands on PCT element 0 there.
mods.set(true, 65, 23, 999);
mods.set(true, 0, 29, 999);
mods.set(true, 255, 255, 999);
assert_eq!(mods.class_family, 3, "the class family is untouched");
assert!(
mods.flat.iter().chain(mods.pct.iter()).all(|&v| v == 0),
"not one cell was written"
);
}
/// World-enter drops everything — both tables and the family — which is the only clear there
/// is.
#[test]
fn world_enter_clears_both_tables_and_the_family() {
// A bare app: the plugin's only Update system takes both of its inputs as an `Option`/an
// empty query, so the state machine the clear hangs off is all this needs.
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::Login)
.add_plugins(SpellModsPlugin);
{
let mut mods = app.world_mut().resource_mut::<SpellModifiers>();
mods.set_class_family(3);
mods.set(true, 5, OP_COST, -30);
mods.set(false, 35, OP_COST, -50);
assert_eq!(mods.apply(&mage(&[5]), OP_COST, 100), 70);
}
app.world_mut()
.resource_mut::<NextState<ClientState>>()
.set(ClientState::InWorld);
app.update();
let mods = app.world().resource::<SpellModifiers>();
assert_eq!(mods.class_family, 0);
assert!(mods.flat.iter().chain(mods.pct.iter()).all(|&v| v == 0));
}
/// The three worked multi-bit spells from the real `Spell.dbc`, driven through the whole
/// module: the same `(bit, op)` cells the server would send, read back through each spell's
/// own shipped family mask. Cleanse 4987 is the one that matters — bits 12 **and** 33 — and it
/// fails under any read that stops at 32 bits. Skips without client data.
#[test]
fn real_spells_sum_their_own_family_bits() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell.dbc");
// A paladin: Cleanse 4987 is family 10, bits 12 and 33.
let mut mods = SpellModifiers::default();
mods.set_class_family(10);
mods.set(true, 12, OP_COST, -5);
mods.set(true, 33, OP_COST, -7);
mods.set(false, 33, OP_COST, -25);
let cleanse = cat.get(4987).expect("Cleanse");
assert_eq!(
mods.apply(cleanse, OP_COST, 100),
(100 - 12) * 75 / 100,
"both dwords contribute"
);
// Cure Poison 526 is a SHAMAN spell (family 11), and bit 35 is the only bit it sets — so
// even with that exact cell live, the paladin's family gate refuses it. Same row, wrong
// family: conjunct 2 is what keeps one class's talents off another's spells.
mods.set(true, 35, OP_COST, -50);
assert_eq!(
mods.apply(cat.get(526).expect("Cure Poison"), OP_COST, 100),
100
);
// A mage: Frostbolt 116, bits 5/19/20/30 — four cells, summed.
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
for bit in [5u8, 19, 20, 30] {
mods.set(true, bit, OP_COST, -1);
}
assert_eq!(
mods.apply(cat.get(116).expect("Frostbolt"), OP_COST, 100),
96,
"four set bits, four cells"
);
}
}
+3 -2
View File
@@ -18,8 +18,9 @@
//! per-effect `radius + casterLevel × perLevel` over **EffectRadiusIndex[0] and [1] only**
//! (slot 2 is never read), max with candidate-1 winning ties/NaN. **Out of range forces the
//! radius to 0.0** — the decal shrinks to the 1.3888889 default *and* turns red. `r == 0` (no
//! radius rows — a dest spell with no area) also draws at the default. Class-6 spell modifiers
//! don't exist yet (the same residual as the range gate, 0792).
//! radius rows — a dest spell with no area) also draws at the default. Spell-mod op 6
//! (SPELLMOD_RADIUS) is not folded in: the tables are live ([`crate::spell_mods`]), this consumer
//! is not wired to them (the same residual as the range gate, 0792).
//!
//! **States**: in range → Acceptable at `r`; out of range → Unacceptable at the default size;
//! cursor over sky / no world hit → **nothing is drawn** (the ref resets its draw state every
@@ -102,6 +102,9 @@ pub(crate) struct CastLadder<'w, 's> {
pub(crate) pending: ResMut<'w, crate::ui_cast::PendingCast>,
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
pub(crate) cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>,
/// The talent spell-modifier tables — rung 2's cost goes through them
/// ([`super::usable::power_cost`]).
pub(crate) spell_mods: Res<'w, crate::spell_mods::SpellModifiers>,
pub(crate) cast_errors: ResMut<'w, CastErrors>,
pub(crate) auto_repeat: ResMut<'w, AutoRepeatActive>,
pub(crate) trade_skill_opens: ResMut<'w, crate::ui_tradeskill::TradeSkillOpens>,
@@ -242,6 +245,7 @@ impl CastLadder<'_, '_> {
&mut self.pending,
&mut self.queued_melee,
&mut self.cooldowns,
&self.spell_mods,
&mut self.cast_errors,
&mut self.auto_repeat,
&mut self.trade_skill_opens,
@@ -282,6 +286,7 @@ fn send_spell_cast(
pending: &mut crate::ui_cast::PendingCast,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
cooldowns: &mut crate::cooldowns::Cooldowns,
spell_mods: &crate::spell_mods::SpellModifiers,
cast_errors: &mut CastErrors,
auto_repeat: &mut AutoRepeatActive,
trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens,
@@ -520,7 +525,7 @@ fn send_spell_cast(
// rage-starved spam press (the 0946 campaign's live capture of the loop).
if !commit.is_item() {
if let (Some(d), Some(store)) = (def, ctx.rel.self_store) {
if !super::usable::can_afford(d, store) {
if !super::usable::can_afford(d, store, spell_mods) {
debug!("ui_action: cast {spell_id} refused locally — not enough power (0x4d)");
cast_errors.push_local(spell_id, 0x4d);
return;
@@ -860,6 +865,7 @@ mod tests {
world.init_resource::<crate::ui_cast::PendingCast>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::cooldowns::Cooldowns>();
world.init_resource::<crate::spell_mods::SpellModifiers>();
world.init_resource::<CastErrors>();
world.init_resource::<AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
+4 -3
View File
@@ -211,9 +211,10 @@ impl Spells {
/// `wave-cooldown.md`/`moving-cast-gate.md`, byte-verified): `CastingTimeIndex` resolves the
/// [`Self::cast_times`] row, `base + perLevel·(casterLevel − baseLevel)` floors to the
/// row's minimum (row 1, the all-zero instant sentinel, resolves 0). The level term keys on
/// the `SpellRec+0x70` column ([`SpellDisplay::base_level`]); spellmod op `0xa`
/// (SPELLMOD_CASTING_TIME) is unmodeled — benilla has no spellmod system — a named
/// micro-divergence (a talent-shortened 0-second cast doesn't exist in the 1.12 data).
/// the `SpellRec+0x70` column ([`SpellDisplay::base_level`]). Spell-mod op `0xa`
/// (SPELLMOD_CASTING_TIME) is still unread here — the tables themselves are live
/// ([`crate::spell_mods`]), only this consumer is not wired to them, so a talent-shortened
/// cast still shows its untalented length.
/// A missing row reads 0 (instant), like a failed catalog load everywhere else.
pub(crate) fn cast_time_ms(
&self,
+8 -2
View File
@@ -395,13 +395,15 @@ pub(super) fn feed_action_state(
// One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking — the in-flight
// guard, the queued on-next-swing strike, the running channel, and the awaiting-click
// ground targeting — plus the macro→spell binding the MACRO arm resolves through
// (decision 0983), which rides here for the same ceiling reason.
// (decision 0983) and the talent spell-modifier tables that leg 12's cost reads through,
// both of which ride here for the same ceiling reason.
cast_state: (
Res<crate::ui_cast::PendingCast>,
Res<crate::ui_cast::QueuedMeleeSpell>,
Res<crate::ui_cast::ActiveChannel>,
Res<super::SpellTargeting>,
Res<crate::ui_macro::MacroBoundSpells>,
Res<crate::spell_mods::SpellModifiers>,
),
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
selection: Res<Selection>,
@@ -434,7 +436,7 @@ pub(super) fn feed_action_state(
memory.last_cd_trace = Some(now);
}
let (pending, queued_melee, channel, targeting, bound) = &cast_state;
let (pending, queued_melee, channel, targeting, bound, spell_mods) = &cast_state;
let me = self_q.iter().next();
// The bags, walked ONCE for the frame: every reagent, totem and item-count question below
// reads this table. It used to be one whole walk per question — per reagent per spell slot,
@@ -531,6 +533,7 @@ pub(super) fn feed_action_state(
reputations: &reputations,
cooldowns: &cooldowns,
carried: &carried,
spell_mods,
};
let (u, oom) =
usable::spell_usable(button.action, d, sp, &ctx, &items, &commands);
@@ -596,6 +599,7 @@ pub(super) fn feed_action_state(
reputations: &reputations,
cooldowns: &cooldowns,
carried: &carried,
spell_mods,
};
let (u, oom) = usable::item_usable(
button.action,
@@ -842,6 +846,7 @@ mod tests {
app.insert_resource(actions)
.insert_resource(bound)
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_cast::PendingCast>()
@@ -946,6 +951,7 @@ mod tests {
radii: Default::default(),
})
.init_resource::<Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_cast::PendingCast>()
@@ -45,8 +45,9 @@ fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> {
/// radius: per-effect `radius + casterLevel × perLevel` over **EffectRadiusIndex[0] and [1]
/// only** (slot 2 is never read by the client), the max with candidate 1 winning ties/NaN,
/// clamped to 20.0 (`0x4820f0`'s `[0x804478]` literal — `min`, NaN → 20). `0.0` = no radius
/// rows; the reticle then draws at its literal default size. Class-6 spell modifiers are
/// unmodelled (the 0792 residual, same as the range gate).
/// rows; the reticle then draws at its literal default size. Spell-mod op 6 (SPELLMOD_RADIUS) is
/// not read here — the tables are live ([`crate::spell_mods`]), this consumer is not wired to
/// them (the 0792 residual, same as the range gate).
pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: u32) -> f32 {
let Some(spells) = spells else { return 0.0 };
let Some(d) = spells.catalog.get(spell_id) else {
@@ -177,6 +177,7 @@ mod tests {
world.init_resource::<crate::ui_cast::PendingCast>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::cooldowns::Cooldowns>();
world.init_resource::<crate::spell_mods::SpellModifiers>();
world.init_resource::<crate::ui_action::CastErrors>();
world.init_resource::<crate::ui_action::UiErrorKeys>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
+74 -11
View File
@@ -29,6 +29,7 @@ use benilla_formats::{
use crate::cooldowns::Cooldowns;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, Reputations};
use crate::spell_mods::{SpellModifiers, OP_COST};
use crate::target::{can_attack, ring_reaction, Factions};
use super::Spells;
@@ -49,6 +50,8 @@ pub(crate) struct UsableCtx<'a> {
pub(crate) factions: Option<&'a Factions>,
pub(crate) reputations: &'a Reputations,
pub(crate) cooldowns: &'a Cooldowns,
/// The talent spell-modifier tables — leg 12's cost goes through them ([`power_cost`]).
pub(crate) spell_mods: &'a SpellModifiers,
/// Every carried entry's count, walked ONCE by the caller for the frame
/// ([`crate::ui_items::carried_counts`]) — leg 3 reads reagents and totems off it instead
/// of walking the bags per reagent per slot.
@@ -450,7 +453,7 @@ pub(crate) fn spell_usable(
return (false, false);
}
// Leg 12 (`0x6e3fba`–`0x6e3feb`): the power gate — the SOLE notEnoughMana writer (B2).
if !can_afford(d, ctx.store) {
if !can_afford(d, ctx.store, ctx.spell_mods) {
return (false, true);
}
(true, false)
@@ -461,12 +464,17 @@ pub(crate) fn spell_usable(
/// `CalculatePowerCost` shape, clamped at 0 on the way out; every nonzero row is a creature
/// spell, so the clamp choice is dormant for players), and `ManaCostPercentage` of the pool
/// 0948 chose per type (base mana for mana spells — the vmangos basis — max pool otherwise,
/// health included for negative types). One law, every consumer: the usable walk's leg 12, the
/// press-path power gate (0948), and the tooltip's cost cell (1074) — mirroring the byte fn's
/// own caller set (`0x609657`/`0x60968d`/`0x4e5201`/`0x6e3fdb`/`0x52e8ad`/`0x507de3`). The
/// reference's per-school unit mods and spell-mods (talent cost cuts) are not modeled — 0948's
/// standing gap, now stated once here.
pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore) -> u32 {
/// health included for negative types) — and then the talent cost cut, `SPELLMOD_COST` (op 14),
/// which `0x6e31b0` applies inside itself at `6e32e3` through the integer applier `0x6e6af0`
/// ([`crate::spell_mods`]). One law, every consumer: the usable walk's leg 12, the press-path
/// power gate (0948), and the tooltip's cost cell (1074) — mirroring the byte fn's own caller
/// set (`0x609657`/`0x60968d`/`0x4e5201`/`0x6e3fdb`/`0x52e8ad`/`0x507de3`). The reference's
/// per-school unit mods are still not modeled — what remains of 0948's standing gap.
///
/// **The modifier is why this one is behavioural and not only cosmetic**: the press-path gate
/// refuses a cast it judges unaffordable and sends **no packet at all** (`0x609657`'s failure
/// block), so an unmodified cost makes benilla refuse casts a talented character can afford.
pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore, mods: &SpellModifiers) -> u32 {
let power_type = d.power_type as i32;
let base = if d.mana_cost_pct == 0 {
0
@@ -484,7 +492,11 @@ pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore) -> u32 {
let cost = i64::from(d.mana_cost)
+ level_delta * i64::from(d.mana_cost_per_level)
+ i64::from(base) * i64::from(d.mana_cost_pct) / 100;
u32::try_from(cost).unwrap_or(0)
// The talent cut, on the data terms and before the 0-clamp on the way out. The relative order
// of the two is dormant for players — every nonzero `manaCostPerlevel` row is a creature spell
// (above), so nothing reaches the modifier negative.
let cost = cost.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
u32::try_from(mods.apply(d, OP_COST, cost)).unwrap_or(0)
}
/// Whether the caster can afford `d`'s power cost — the shared availability-vs-cost compare
@@ -492,14 +504,14 @@ pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore) -> u32 {
/// (`0x6094f0` @ `0x60962c`, decision 0948): raw `UNIT_FIELD_POWER[type]` — ANY negative
/// PowerType reads `UNIT_FIELD_HEALTH` instead (the ref's `jl` at `0x609631`; Bloodrage's −2) —
/// signed-compared against [`power_cost`]'s number.
pub(crate) fn can_afford(d: &SpellDisplay, store: &ObjectStore) -> bool {
pub(crate) fn can_afford(d: &SpellDisplay, store: &ObjectStore, mods: &SpellModifiers) -> bool {
let power_type = d.power_type as i32;
let avail = if power_type < 0 {
store.0.unit_health().unwrap_or(0)
} else {
store.0.unit_power(power_type as u8).unwrap_or(0)
};
avail >= power_cost(d, store)
avail >= power_cost(d, store, mods)
}
#[cfg(test)]
@@ -716,6 +728,7 @@ mod tests {
cooldowns: &'a Cooldowns,
reputations: &'a Reputations,
carried: &'a std::collections::HashMap<u32, u32>,
spell_mods: &'a SpellModifiers,
) -> UsableCtx<'a> {
UsableCtx {
store,
@@ -724,6 +737,7 @@ mod tests {
reputations,
cooldowns,
carried,
spell_mods,
}
}
@@ -735,16 +749,64 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let carried = crate::ui_items::carried_counts(&store.0, &items);
let spell_mods = SpellModifiers::default();
spell_usable(
1,
d,
&spells,
&ctx(store, &cooldowns, &reputations, &carried),
&ctx(store, &cooldowns, &reputations, &carried, &spell_mods),
&items,
&commands,
)
}
/// **The behavioural half of the spell-modifier system, and the reason op 14 is the first op
/// wired** (wow-re `spellmod-table-law.md` §9): the press-path gate compares the caster's
/// power against [`power_cost`] and, when short, refuses locally and sends **no**
/// `CMSG_CAST_SPELL` at all (`0x609657`'s fall-through failure block). With the tables unread,
/// a talent-discounted spell the server would happily accept is refused by the client.
///
/// So: a mage with 80 mana and a 100-mana spell cannot cast it; one `SMSG_SET_PCT_SPELL_MODIFIER`
/// for that spell's `(family bit, op 14)` cell flips both the number and the verdict.
#[test]
fn a_talent_cost_cut_reaches_the_cost_and_the_press_path_gate() {
// Family 3 (mage), family bit 5 — the shape a real talent's `SpellFamilyMask` names.
let d = SpellDisplay {
spell_family: 3,
spell_family_flags: 1 << 5,
mana_cost: 100,
..Default::default()
};
let store = ObjectStore(ObjectFields::from_pairs(&[(23, 80)]));
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
assert_eq!(
power_cost(&d, &store, &mods),
100,
"no packet yet — the flat DBC cost"
);
assert!(
!can_afford(&d, &store, &mods),
"80 mana does not buy a 100 mana spell"
);
// The server's cell for (bit 5, op 14): −30%.
mods.set(false, 5, OP_COST, -30);
assert_eq!(power_cost(&d, &store, &mods), 70);
assert!(
can_afford(&d, &store, &mods),
"the talent brings it inside the pool, so TryCast must send the cast"
);
// A warrior's copy of the same spell id takes nothing — the family gate, from this side.
let other_class = SpellDisplay {
spell_family: 4,
..d
};
assert_eq!(power_cost(&other_class, &store, &mods), 100);
}
/// Each modeled gate trips alone, and only the power leg raises notEnoughMana (B2).
#[test]
fn gates_trip_independently_and_only_power_sets_oom() {
@@ -880,6 +942,7 @@ mod tests {
reputations: &reputations,
cooldowns: &cooldowns,
carried: &carried,
spell_mods: &SpellModifiers::default(),
};
assert_eq!(
spell_usable(5308, &execute, &spells, &ctx, &items, &commands),
+245 -12
View File
@@ -14,9 +14,11 @@
//! * **Area spirit healer** — `SMSG_AREA_SPIRIT_HEALER_TIME {guid, ms}` arms the wave clock and
//! fires `AREA_SPIRIT_HEALER_IN_RANGE` when the guid is the cached healer's; Accept sends
//! `0x2E3` with that guid, Cancel is the cancel-aura of spell 2584 plus `_OUT_OF_RANGE`. **The
//! cache has no writer yet**: the reference's per-frame proximity scan (20 yd to acquire, 22 to
//! retain, `0x4923b0`) is not carved for its unit filter, so no healer is ever cached here and
//! Accept stays silent — named in the record, not guessed at.
//! cache has two writers** (2291), which is what made Accept reachable at all: the reference's
//! per-frame proximity scan [`poll_area_spirit_healer`] (`0x4923b0` — ghost-gated, acquire at
//! 20 yd, retain to 22) and the SPIRITGUIDE click arm [`AreaSpiritHealer::click_guide`]
//! (`0x5df950`, whose deliberate cache-bust makes a second click re-ask). Until 2291 this said
//! "no writer yet", which was true when 1963 wrote it and false from the moment the scan landed.
//! * **Battlefield queue** — `SMSG_BATTLEFIELD_STATUS` fills one of three slots and fires
//! `UPDATE_BATTLEFIELD_STATUS`; `AcceptBattlefieldPort(index, accept)` sends the slot's map id
//! with the answer as one byte.
@@ -792,6 +794,24 @@ fn meeting_stone_leave_world(mut stone: ResMut<MeetingStone>) {
stone.leave_world();
}
/// Drop the cached spirit guide when the world does.
///
/// **A resurrect wave is the most session-bound state this module holds**, and it was the one
/// resource here with no leave-world leg. A ghost who logs out at a Warsong Gulch graveyard with a
/// guide latched and a wave armed carried `healer`, `deadline` and `in_range` into the character
/// screen and into the *next* character's session — where, on the first frame the interface came
/// up, [`feed_dialog_verbs`] pushed the stale healer into the fresh VM and could fire
/// `AREA_SPIRIT_HEALER_IN_RANGE` at a living body standing in Stormwind, and the poll's ghost gate
/// then cleared it and sent a `CMSG_CANCEL_AURA(2584)` the reference never sends there.
///
/// The reference does not need this leg because its equivalents are process-lifetime globals whose
/// poll keeps running over the character screen's frames; ours is a resource in a world that goes
/// away. Resetting at the seam is how every other per-session cache here behaves
/// (`crate::ui_aura::end_session_aura_state` is the established shape).
fn area_spirit_healer_leave_world(mut spirit: ResMut<AreaSpiritHealer>) {
*spirit = AreaSpiritHealer::default();
}
pub(crate) fn feed_dialog_verbs(
script: Option<NonSendMut<UiScript>>,
mut pet: ResMut<PetUnlearnState>,
@@ -1116,7 +1136,30 @@ impl Plugin for UiDialogVerbsPlugin {
.before(feed_meeting_stone)
.in_set(crate::char_select::InWorldGated),
feed_meeting_stone.before(UiInput),
drain_latch_verbs.after(UiInput),
// **Before the target chain, not merely after the input pass** — and this one
// is a correctness order, not a tidiness one.
//
// `drain_latch_verbs` takes `AcceptAreaSpiritHeal`'s presses with
// `take_area_spirit_accepts()` **unconditionally**, and only then gates the
// send on `spirit.healer()`. Both systems that can *clear* that healer —
// `poll_area_spirit_healer` (which drops it past the 22 yd retain radius) and
// `act_on_right_click` (which re-points it at another guide) — live in or
// after `TargetUpdate`, and `.after(UiInput)` alone constrains this against
// neither: `UiInput` sits *before* `WorldStage::Input` and `TargetUpdate`
// after it. So with the order undeclared, a ghost who clicks **Accept** on the
// same frame they step out of range either sends
// `CMSG_AREA_SPIRIT_HEALER_QUEUE` or has the press silently eaten — decided by
// graph layout, with no message either way.
//
// `before` is the reference's own answer, not a coin toss: the dialog's Lua
// handler runs inside the UI dispatch, while `0x4923b0` (the poll) and
// `0x48184a` (the pick) both run later in the same frame off `CGWorldFrame`'s
// OnUpdate. The press is resolved against the healer the frame *began* with.
// Its neighbour `drain_meeting_stone_joins` below argued the same hazard and
// declared its way out of it; this one was missed.
drain_latch_verbs
.after(UiInput)
.before(crate::target::TargetUpdate),
drain_queue_verbs.after(UiInput),
// MEETINGSTONE(23)'s use slot (2283): the click resolved the object, this
// runs the validator and sends. Ordered after the **target chain**, not just
@@ -1132,7 +1175,7 @@ impl Plugin for UiDialogVerbsPlugin {
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
meeting_stone_leave_world,
(meeting_stone_leave_world, area_spirit_healer_leave_world),
);
}
}
@@ -1265,14 +1308,204 @@ mod tests {
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.
/// `UNIT_FIELD_FLAGS`, absolute field 46 — `UNIT_FLAG_PVP` (`0x1000`) is what carries a
/// non-player-controlled unit through `can_assist`'s last gate.
const UNIT_FLAGS_FIELD: u16 = 46;
/// `UNIT_NPC_FLAGS`, absolute field 147.
const NPC_FLAGS_FIELD: u16 = 147;
/// `PLAYER_FLAGS`, absolute field 190; bit `0x10` is GHOST.
const PLAYER_FLAGS_FIELD: u16 = 190;
fn fields(pairs: &[(u16, u32)]) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs))
}
/// A world holding exactly what [`poll_area_spirit_healer`] reads, with the body at the origin
/// and one candidate unit at `dist` yards along +X.
///
/// `ghost` drives `PLAYER_FLAGS 0x10`; `guide` drives `UNIT_NPC_FLAGS` bit 6. The candidate
/// carries `UNIT_FLAG_PVP` and faction template **35** ("friendly to all"), against a body on
/// template **1** (PLAYER, Human) — a real friendly pair out of the shipped DBC, so
/// `can_assist` passes for the same reason it passes on the live server rather than by
/// accident. A test that wants the acquire walk to *run* must therefore hand over the real
/// catalog; `None` serves the cases that refuse **before** the walk (not a ghost, not a guide,
/// too far), which is why those need no client data and never skip.
fn poll_world(
ghost: bool,
guide: bool,
dist: f32,
factions: Option<benilla_formats::FactionCatalog>,
) -> (World, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<AreaSpiritHealer>();
world.init_resource::<crate::net::Reputations>();
world.init_resource::<crate::net::GuidIndex>();
if let Some(catalog) = factions {
world.insert_resource(crate::target::Factions::from_catalog(catalog));
}
world.spawn((
SelfPlayer,
fields(&[
(PLAYER_FLAGS_FIELD, if ghost { 0x10 } else { 0 }),
(FACTION_TEMPLATE_FIELD, 1),
]),
Transform::from_xyz(0.0, 0.0, 0.0),
));
world.spawn((
crate::net::Guid(GUIDE),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Unit,
display_id: None,
scale: 1.0,
},
fields(&[
(NPC_FLAGS_FIELD, if guide { 1 << 6 } else { 0 }),
(UNIT_FLAGS_FIELD, 0x1000),
(FACTION_TEMPLATE_FIELD, 35),
]),
Transform::from_xyz(dist, 0.0, 0.0),
));
(world, rx)
}
/// `UNIT_FIELD_FACTIONTEMPLATE`, absolute field 35.
const FACTION_TEMPLATE_FIELD: u16 = 35;
/// The shipped FactionTemplate.dbc, or an early return when this machine has no client data —
/// the same `wow_data_or_skip!` shape `target::ring`'s own reaction tests use.
macro_rules! catalog_or_skip {
() => {{
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
benilla_formats::load_faction_catalog(&mut chain).expect("FactionTemplate.dbc")
}};
}
const GUIDE: u64 = 0x9111;
fn run_poll(world: &mut World) {
use bevy::ecs::system::RunSystemOnce;
world.run_system_once(poll_area_spirit_healer).unwrap();
}
fn queries(rx: &crossbeam_channel::Receiver<ClientCommand>) -> Vec<u64> {
rx.try_iter()
.filter_map(|c| match c {
ClientCommand::AreaSpiritHealerQuery { healer } => Some(healer),
_ => None,
})
.collect()
}
/// **The ghost gate is the poll's first test** (`0x4923b0`, the `0x5df74a`-shaped read of
/// `[[player+0xe68]+8] bit 4`). A living body standing on top of a spirit guide adopts nothing
/// and sends nothing — which is what keeps the resurrect dialog off the screen of everyone who
/// is merely walking through a graveyard.
#[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) };
fn a_living_body_never_adopts_a_spirit_guide() {
let (mut world, rx) = poll_world(false, true, 1.0, None);
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), None);
assert!(queries(&rx).is_empty(), "no clock is asked for");
}
/// The acquire leg: a ghost inside 20 yd adopts, and the adopt is what sends
/// `CMSG_AREA_SPIRIT_HEALER_QUERY` — the packet that was unreachable before 2291.
#[test]
fn a_ghost_adopts_a_guide_inside_the_acquire_radius_and_asks_for_the_clock() {
let catalog = catalog_or_skip!();
let (mut world, rx) = poll_world(true, true, 19.0, Some(catalog));
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), Some(GUIDE));
assert_eq!(
queries(&rx),
vec![GUIDE],
"exactly one query, for that guide"
);
}
/// …and 20 yd is a real boundary, not decoration: one yard further and nothing is adopted.
#[test]
fn a_guide_past_the_acquire_radius_is_not_adopted() {
let (mut world, rx) = poll_world(true, true, 21.0, None);
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), None);
assert!(queries(&rx).is_empty());
}
/// **The hysteresis, which is the whole reason there are two radii.** A guide adopted at 19 yd
/// is RETAINED at 21 — past the acquire radius — and only dropped past 22
/// (`20.0 × 1.1`). Equal radii would re-query every frame a body jittered over the boundary.
/// The drop is not silent: it cancels the wave (`CMSG_CANCEL_AURA` on 2584), which on vmangos
/// takes the player out of the resurrect queue.
#[test]
fn an_adopted_guide_is_retained_past_the_acquire_radius_and_dropped_past_the_retain_one() {
let catalog = catalog_or_skip!();
let (mut world, rx) = poll_world(true, true, 19.0, Some(catalog));
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), Some(GUIDE));
let _ = queries(&rx);
// 21 yd: outside acquire, inside retain — kept, and no second query.
let guide = world
.query_filtered::<Entity, With<crate::net::Guid>>()
.iter(&world)
.next()
.expect("the guide entity");
world
.entity_mut(guide)
.insert(Transform::from_xyz(21.0, 0.0, 0.0));
run_poll(&mut world);
assert_eq!(
world.resource::<AreaSpiritHealer>().healer(),
Some(GUIDE),
"retained between the two radii"
);
assert!(queries(&rx).is_empty(), "a retained guide is not re-asked");
// 23 yd: outside both — dropped.
world
.entity_mut(guide)
.insert(Transform::from_xyz(23.0, 0.0, 0.0));
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), None);
}
/// The unit filter is the `SPIRITGUIDE` bit and nothing else. A unit standing in the same spot
/// without it — a battle master, a herald, another player's corpse-side NPC — is skipped.
#[test]
fn a_unit_without_the_spiritguide_flag_is_never_adopted() {
let (mut world, rx) = poll_world(true, false, 1.0, None);
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), None);
assert!(queries(&rx).is_empty());
}
/// Resurrecting drops the guide. The poll is **level**-triggered, so the release happens on the
/// first frame the ghost flag clears — with the guide still standing right there.
#[test]
fn losing_the_ghost_state_drops_the_guide() {
let catalog = catalog_or_skip!();
let (mut world, _rx) = poll_world(true, true, 5.0, Some(catalog));
run_poll(&mut world);
assert_eq!(world.resource::<AreaSpiritHealer>().healer(), Some(GUIDE));
let me = world
.query_filtered::<Entity, With<SelfPlayer>>()
.iter(&world)
.next()
.expect("the body");
world
.entity_mut(me)
.insert(fields(&[(PLAYER_FLAGS_FIELD, 0)]));
run_poll(&mut world);
assert_eq!(
world.resource::<AreaSpiritHealer>().healer(),
None,
"alive again: the cache clears even though the guide has not moved"
);
}
/// The four client-side refusals of MEETINGSTONE(23)'s use slot (`0x5f69d0`, decision 2283),
+2
View File
@@ -906,6 +906,7 @@ mod tests {
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
@@ -1654,6 +1655,7 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<crate::spell_mods::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
+2
View File
@@ -240,6 +240,7 @@ fn feed_shapeshift_bar(
items: Res<Items>,
commands: Res<NetCommands>,
clock: Res<crate::ui_script::UiClock>,
spell_mods: Res<crate::spell_mods::SpellModifiers>,
mut memory: Local<crate::ui_script::VmMemo<StanceMemory>>,
) {
let Some(mut script) = script else {
@@ -302,6 +303,7 @@ fn feed_shapeshift_bar(
reputations: &reputations,
cooldowns: &cooldowns,
carried: &carried,
spell_mods: &spell_mods,
};
usable::spell_usable(id, d, &spells, &ctx, &items, &commands).0
});
+23 -4
View File
@@ -60,6 +60,9 @@ struct ViewCtx<'a> {
items: &'a mut Items,
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
/// The talent spell-modifier tables — the cost cell shows the RESOLVED cost, which since
/// `SPELLMOD_COST` landed means the modified one (`crate::ui_action::usable::power_cost`).
spell_mods: &'a crate::spell_mods::SpellModifiers,
/// The VM's own `GlobalStrings.lua` (decision 2045) — every cell this builder composes is a
/// key, and this is where they resolve. `text` is the `%d`-filling twin the `$`-engine's
/// keyed tokens take (`benilla_formats::TokenContext::text`).
@@ -118,9 +121,9 @@ fn spell_tooltip_view(
// replaces was unfaithful (B152). No store (a DBC-only view) degrades to the flat cost.
// HAPPINESS_COST has no GlobalStrings entry and no 5875 player spell reaches powerType 4;
// the unit word is a dead arm kept for the array's shape.
let resolved_cost = vctx
.store
.map_or(d.mana_cost, |s| crate::ui_action::usable::power_cost(d, s));
let resolved_cost = vctx.store.map_or(d.mana_cost, |s| {
crate::ui_action::usable::power_cost(d, s, vctx.spell_mods)
});
let cost = {
// The one `0x6e7130` table, not a local `if power_type == 1` — decision 2117 found three
// hand-rolled copies of it and one of them had been applied at a single site out of four.
@@ -437,10 +440,17 @@ fn feed_spell_tooltips(
home_bind: Option<Res<crate::net::HomeBind>>,
area_names: Option<Res<crate::ui_quest_log::QuestHeaderNamesRes>>,
mut items: ResMut<Items>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
// One tuple param (Bevy's 16-SystemParam ceiling): the two lookups the view builder reads
// straight through — the item sub-class names for the required-item line, and the talent
// spell-modifier tables the cost cell resolves through.
lookups: (
Option<Res<crate::ui_items::ItemSubClasses>>,
Res<crate::spell_mods::SpellModifiers>,
),
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
) {
let (sub_classes, spell_mods) = &lookups;
let Some(mut script) = script else {
return;
};
@@ -566,6 +576,14 @@ fn feed_spell_tooltips(
memory.combat_reach = Some(reaches);
wanted.extend(memory.pushed.drain());
}
// …and so does the cost cell, whose resolved number now goes through the talent
// spell-modifier tables: a respec changes what every affected spell costs, and this feed is
// the one consumer of `power_cost` that memoizes its build (the action bar recomputes every
// frame). The reference has no cache here at all — it reads the tables live at every call
// site — so this is what keeps the cell honest about the same change (`crate::spell_mods`).
if spell_mods.is_changed() {
wanted.extend(memory.pushed.drain());
}
let watched: Vec<u32> = memory.reagents.keys().copied().collect();
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = watched
.into_iter()
@@ -597,6 +615,7 @@ fn feed_spell_tooltips(
items: &mut items,
commands: &commands,
sub_classes: sub_classes.as_deref().map(|c| &c.0),
spell_mods,
get: &get,
text: &text,
};
@@ -19,6 +19,9 @@ struct TestCtx {
/// glue tests onto the loader's own assembly.
get: Box<Getter>,
text: Box<Filler>,
/// Empty tables, which is the un-talented character every cell here is graded as: the cost
/// cell's modifier hop must be the identity when nothing has been sent.
spell_mods: crate::spell_mods::SpellModifiers,
}
/// The two lookup shapes, named so the harness's fields read.
@@ -36,6 +39,7 @@ impl TestCtx {
commands: NetCommands(tx),
_rx: rx,
get: Box::new(move |key| benilla_ui::strings::global(for_get.lua(), key)),
spell_mods: crate::spell_mods::SpellModifiers::default(),
text: Box::new(move |key, args: &[i64]| {
let template = benilla_ui::strings::global(for_text.lua(), key)?;
let args: Vec<_> = args
@@ -85,6 +89,7 @@ impl TestCtx {
items: &mut self.items,
commands: &self.commands,
sub_classes,
spell_mods: &self.spell_mods,
get: self.get.as_ref(),
text: self.text.as_ref(),
}
+583 -147
View File
@@ -10,14 +10,20 @@
//! `Map.dbc` localized names ("Eastern Kingdoms", not the art folder's "Azeroth"); zones
//! sorted case-insensitively by AreaTable localized name (the `0x4a6390` comparator's
//! `SStrCmpI`); the zone grids remapped from raw AreaTable ids by the client's one-hop parent
//! rollup + (mapId, areaId) match — here straight to 1-based zone indices.
//! rollup + (mapId, areaId) match — here straight to 1-based zone indices. Beside that it
//! builds the **orphan list** ([`DirectAreaEntry`]) — the instance maps, which belong to no
//! continent and so appear in neither list above; the reference fills the same second container
//! in the same walk (`0x4a5d00`), and it is what the `−2` direct-area selection resolves
//! against.
//! - [`feed_world_map`] (every frame): reads the engine-owned selection back, projects the
//! player's world position onto the displayed map via [`benilla_world::map_proj`] (world sheet:
//! WorldMapContinent constants; continent/zone: the WorldMapArea rect lerp), resolves the
//! player's `(continent, zone)` from `CurrentArea` through the AreaTable parent chain, and
//! pushes the trio + facing. (The client matches its zone-level area global directly —
//! `0x4a6650`; our MCNK `CurrentArea` is the leaf sub-area, so the parent walk lands on the
//! same zone.) It also runs the **landmark pass** — the reference's `0x4a67a0` builder
//! WorldMapContinent constants; continent/zone: the WorldMapArea rect lerp; an instance map:
//! that row's own rect), resolves where the player's own position puts the selection
//! ([`resolve_player_selection`], the reference's `0x4a6650` — continent × zone, then the
//! orphan list) from `CurrentArea` through the AreaTable parent chain, and pushes the trio +
//! facing. (The client matches its zone-level area global directly; our MCNK `CurrentArea` is
//! the leaf sub-area, so the parent walk lands on the same zone.) It also runs the **landmark
//! pass** — the reference's `0x4a67a0` builder
//! ([`landmark_gates_pass`], decision 1586): the `AreaPOI.dbc` rows the displayed level admits,
//! then the guard-directions marker. That pass is keyed rather than per-frame ([`LandmarkKey`]),
//! because the reference rebuilds it on events, not on a clock.
@@ -50,6 +56,11 @@ use benilla_world::world_map::CurrentMap;
#[derive(Resource)]
pub(crate) struct WorldMapUiData {
continents: Vec<ContinentEntry>,
/// The **orphan list** — the instance maps, which are no continent's child and so appear in
/// neither list above. `0x4a5d00`'s third array, in `WorldMapArea.dbc` file order with no
/// sort; the rows the `−2` direct-area selection resolves against (see [`DirectAreaEntry`]).
/// It also carries what the engine is pushed, so the two copies cannot drift apart.
direct: Vec<DirectAreaEntry>,
}
pub(crate) struct ContinentEntry {
@@ -64,6 +75,24 @@ struct ZoneEntry {
rect: ZoneRect,
}
/// One orphan row: an instance map (a battleground, a dungeon) selected **directly** rather than
/// through the continent/zone pair. In 5875 there are exactly three, all battlegrounds.
struct DirectAreaEntry {
/// The `WorldMapArea` row **ID** — the value the reference's third selection cell
/// `[0x845074]` holds verbatim (`0x4a6717 mov edx,[esi]`). Not a position in this list:
/// nothing indexes it, the resolver looks rows up by id.
id: u32,
/// The row's `Map.dbc` id. The resolver matches the player's own map against this
/// (`0x4a66f6`), and the projection admits a body only when its map is this one
/// (`0x4a7437`) — which is what makes a teammate elsewhere answer the (0,0) hide sentinel.
map_id: u32,
/// The row's own loc rect — the window `0x4a7360`'s direct-area leg projects through, the
/// same tail the zone case uses.
rect: ZoneRect,
/// What the engine is handed for this row (`GetMapInfo`'s art folder, the overlays).
view: WorldMapZoneView,
}
fn zone_rect(a: &WorldMapArea) -> ZoneRect {
ZoneRect {
left: a.loc_left,
@@ -73,6 +102,55 @@ fn zone_rect(a: &WorldMapArea) -> ZoneRect {
}
}
/// One `WorldMapArea` row as the engine sees it — a zone, a city, or an instance map: the client
/// makes no structural distinction (the same `0x4a6cf0` id lookup answers `GetMapInfo` for all
/// three, and the same overlay gate reveals their art), so neither do we.
///
/// `name` is passed in because the zone list sorts on it before the views are built.
fn map_row_view(
wma_id: u32,
a: &WorldMapArea,
name: String,
areas: &benilla_formats::AreaTableCatalog,
wmo: &benilla_formats::WorldMapOverlayCatalog,
) -> WorldMapZoneView {
WorldMapZoneView {
name,
area_id: a.area_id,
map_file: a.name.clone(),
loc_rect: (a.loc_left, a.loc_right, a.loc_top, a.loc_bottom),
// The row's discovery overlays: WorldMapOverlay rows joined by its WMA id, their reveal
// bits = each covered area's AreaTable exploreFlag.
overlays: wmo
.for_area(wma_id)
.iter()
.map(|o| WorldMapOverlayView {
texture: format!("Interface\\WorldMap\\{}\\{}", a.name, o.texture_name),
width: o.texture_width,
height: o.texture_height,
offset_x: o.offset_x,
offset_y: o.offset_y,
explore_bits: o
.area_id
.iter()
.filter(|&&aid| aid != 0)
.filter_map(|&aid| areas.get(aid).map(|r| r.explore_flag))
.collect(),
hit_rect: (
o.hit_rect_top,
o.hit_rect_left,
o.hit_rect_bottom,
o.hit_rect_right,
),
// The zone-level hover's label inside that rect: the FIRST area slot's AreaTable
// name — `0x4a7fa0` reads `+0x8` only (wow-re 15b2a8ea §1d); a slot that resolves
// to no row makes the overlay invisible to the hover, never to the draw.
area_name: areas.name(o.area_id[0]).map(str::to_string),
})
.collect(),
}
}
/// Build the catalog off the patch chain — the pure half of [`seed_world_map_catalog`], split out
/// so a test can drive the REAL `WorldMapArea` × `AreaTable` × `WorldMapContinent` × `Map` ×
/// `.zmp` build rather than a hand-written stand-in. A fixture catalog can only ever prove the
@@ -84,7 +162,7 @@ pub(crate) fn build_catalog(
chain: &mut benilla_formats::Chain,
areas: &benilla_formats::AreaTableCatalog,
maps: &MapCatalogRes,
) -> Option<(Vec<WorldMapContinentView>, Vec<ContinentEntry>)> {
) -> Option<(Vec<WorldMapContinentView>, WorldMapUiData)> {
let loaded = load_world_map_area_catalog(&mut *chain).and_then(|wma| {
let wmc = load_world_map_continent_catalog(&mut *chain)?;
let wmo = load_world_map_overlay_catalog(&mut *chain)?;
@@ -184,42 +262,7 @@ pub(crate) fn build_catalog(
zone_grid,
zones: zones
.iter()
.map(|(wma_id, a, name)| WorldMapZoneView {
name: name.clone(),
area_id: a.area_id,
map_file: a.name.clone(),
loc_rect: (a.loc_left, a.loc_right, a.loc_top, a.loc_bottom),
// The zone's discovery overlays: WorldMapOverlay rows joined by the zone's
// WMA id, their reveal bits = each covered area's AreaTable exploreFlag.
overlays: wmo
.for_area(*wma_id)
.iter()
.map(|o| WorldMapOverlayView {
texture: format!("Interface\\WorldMap\\{}\\{}", a.name, o.texture_name),
width: o.texture_width,
height: o.texture_height,
offset_x: o.offset_x,
offset_y: o.offset_y,
explore_bits: o
.area_id
.iter()
.filter(|&&aid| aid != 0)
.filter_map(|&aid| areas.get(aid).map(|r| r.explore_flag))
.collect(),
hit_rect: (
o.hit_rect_top,
o.hit_rect_left,
o.hit_rect_bottom,
o.hit_rect_right,
),
// The zone-level hover's label inside that rect: the FIRST area
// slot's AreaTable name — `0x4a7fa0` reads `+0x8` only (wow-re
// 15b2a8ea §1d); a slot that resolves to no row makes the overlay
// invisible to the hover, never to the draw.
area_name: areas.name(o.area_id[0]).map(str::to_string),
})
.collect(),
})
.map(|(wma_id, a, name)| map_row_view(*wma_id, a, name.clone(), areas, &wmo))
.collect(),
});
entries.push(ContinentEntry {
@@ -235,7 +278,43 @@ pub(crate) fn build_catalog(
.collect(),
});
}
Some((views, entries))
// ── The THIRD list: the orphans — the instance maps (wow-re
// `system/ui/scratch/worldmap-direct-area-selection.md` §2). `0x4a5d00` fills a container
// beside the continents with every row that has `areaID != 0` and whose mapID matches **no**
// continent record's mapID, in `WorldMapArea.dbc` **file order with no sort** — the passes at
// `0x4a6130` (count) and `0x4a61c9` (fill), whose predicate is the exact complement of the
// child rule above. In 5875 that is Alterac Valley (WMA 401, map 30), Warsong Gulch (443,
// 489) and Arathi Basin (461, 529), and nothing else.
//
// The `areaID != 0` gate is carried although it is **provably inert on the shipped data**:
// every `areaID == 0` row becomes a continent record and so matches its own mapID at
// `0x4a6170`. It is the reference's predicate, not a filter we inferred from the data.
let continent_maps: Vec<u32> = entries.iter().map(|c| c.map_id).collect();
let direct: Vec<DirectAreaEntry> = wma
.iter()
.filter(|(_, a)| a.area_id != 0 && !continent_maps.contains(&a.map_id))
.map(|(id, a)| DirectAreaEntry {
id,
map_id: a.map_id,
rect: zone_rect(a),
view: map_row_view(
id,
a,
areas.name(a.area_id).unwrap_or(a.name.as_str()).to_string(),
areas,
&wmo,
),
})
.collect();
Some((
views,
WorldMapUiData {
continents: entries,
direct,
},
))
}
/// The built catalog, kept for the life of the process. A static `WorldMapArea` × `AreaTable` ×
@@ -247,6 +326,9 @@ pub(crate) fn build_catalog(
/// the test plants a catalog and drives the entry edge, which is the ordering question without
/// the DBC walk (the walk itself is covered against the real chain by
/// `world_map_tests::the_real_feralas_catalog_names_dire_maul_under_the_cursor`).
///
/// The **continent** half only: the orphan half of the same build rides [`WorldMapUiData`], where
/// each instance map's row already sits beside its projection rect.
#[derive(Resource)]
pub(crate) struct WorldMapCatalog(pub(crate) Vec<WorldMapContinentView>);
@@ -269,32 +351,41 @@ pub(crate) struct WorldMapCatalog(pub(crate) Vec<WorldMapContinentView>);
/// load, and the getters read it whenever they are asked.
pub(crate) fn seed_world_map_catalog(world: &mut World, script: &mut UiScript) {
if !world.contains_resource::<WorldMapCatalog>() {
let Some((views, entries)) = build_catalog_from_world(world) else {
let Some((views, data)) = build_catalog_from_world(world) else {
return;
};
info!(
"world map: catalog — {} continents, {} zones",
"world map: catalog — {} continents, {} zones, {} instance maps",
views.len(),
views.iter().map(|c| c.zones.len()).sum::<usize>()
views.iter().map(|c| c.zones.len()).sum::<usize>(),
data.direct.len()
);
world.insert_resource(WorldMapUiData {
continents: entries,
});
world.insert_resource(data);
world.insert_resource(WorldMapCatalog(views));
}
let Some(catalog) = world.get_resource::<WorldMapCatalog>() else {
return;
};
script.set_world_map_catalog(catalog.0.clone());
// The orphan half of the same build, pushed at the same edge and for the same reason (the
// reference fills both containers in one walk). It rides [`WorldMapUiData`] rather than
// [`WorldMapCatalog`] because that is where the rows and their projection rects already live
// together — one list, one order, nothing to drift.
if let Some(data) = world.get_resource::<WorldMapUiData>() {
script.set_world_map_direct_areas(
data.direct
.iter()
.map(|d| (d.id, d.view.clone()))
.collect::<Vec<_>>(),
);
}
}
/// [`build_catalog`] over the resources the app holds — `None` when the patch chain or either DBC
/// catalog is missing, which in a real run cannot happen at this edge: all three are `Startup`
/// systems and the initial state transition is after `PostStartup` (decision 1038). A bare test
/// world takes the `None`.
fn build_catalog_from_world(
world: &World,
) -> Option<(Vec<WorldMapContinentView>, Vec<ContinentEntry>)> {
fn build_catalog_from_world(world: &World) -> Option<(Vec<WorldMapContinentView>, WorldMapUiData)> {
let assets = world.get_resource::<WorldAssets>()?;
let maps = world.get_resource::<MapCatalogRes>()?;
let areas = world.get_resource::<crate::area::AreaTableRes>()?;
@@ -302,31 +393,37 @@ fn build_catalog_from_world(
build_catalog(&mut chain, &areas.0, maps)
}
/// Which of the map's three levels is displayed — the reference's `(continent, zone)` globals
/// `[0x84506c]`/`[0x845070]` reduced to the thing every landmark gate actually branches on.
/// Which of the map's levels is displayed — the reference's three selection cells
/// `[0x84506c]`/`[0x845070]`/`[0x845074]` reduced to the thing every landmark gate actually
/// branches on.
///
/// The reference has a fourth, `ORPHAN` (`continent == -2`, a map selected directly rather than
/// through the continent/zone pair). We have no way to reach it: [`UiScript::world_map_selection`]
/// is a `(u32, u32)` pair with `0` for "whole", so a direct-area selection is unrepresentable.
/// Where the reference's gates test "zone **or** orphan", ours test zone — the same answer for
/// every state our map can be in.
/// **The direct-area state answers [`MapLevel::Zone`], and that is the reference's own law rather
/// than an approximation** (wow-re `worldmap-direct-area-selection.md` §6). Both gates that read
/// this enum test "zone **or** orphan": the AreaPOI level flag falls into the zone-level
/// `test [rec+0x20],0x4` at `-2` (`0x4a79de cmp eax,-2; jne`), and `GetMapLandmarkInfo`'s
/// `textureIndex` substitution is reached when the zone cell is `-1` **and** the continent is `-2`
/// (`0x4a8856`/`0x4a885f` both fall through to `0x4a8868`). A fourth variant would be two arms
/// that copy `Zone` exactly — so the fact is recorded here instead.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum MapLevel {
/// Both continents on one sheet (the reference's `continent == -1`).
World,
/// One continent, no zone selected (`continent >= 0, zone == -1`).
Continent,
/// One zone (`continent >= 0, zone >= 0`).
/// One zone (`continent >= 0, zone >= 0`) — or an instance map (`continent == -2`).
Zone,
}
impl MapLevel {
/// Our 1-based `(continent, zone)` selection, `0` = "whole", mapped onto the reference's
/// `-1`-for-whole pair.
fn of(continent: u32, zone: u32) -> Self {
match (continent, zone) {
(0, _) => Self::World,
(_, 0) => Self::Continent,
/// The engine's selection ([`UiScript::world_map_selection`]) — our 1-based
/// `(continent, zone)` with `0` for "whole", plus the direct-area row id — mapped onto the
/// reference's levels. The direct cell is read FIRST: it shares the `(0, 0)` pair with the
/// world sheet and overrides it.
fn of((continent, zone, direct): (u32, u32, Option<u32>)) -> Self {
match (continent, zone, direct) {
(_, _, Some(_)) => Self::Zone,
(0, _, None) => Self::World,
(_, 0, None) => Self::Continent,
_ => Self::Zone,
}
}
@@ -346,7 +443,9 @@ impl MapLevel {
/// input that silently hides map icons when it is wrong.
#[derive(PartialEq, Eq)]
struct LandmarkKey {
selection: (u32, u32),
/// All three selection cells — the direct-area one included, or an instance map's landmark
/// list would never rebuild when the selection moved onto it.
selection: (u32, u32, Option<u32>),
map: u32,
states: u64,
/// Owned rather than borrowed, and compared before it is ever cloned — see
@@ -364,7 +463,7 @@ impl LandmarkKey {
/// changed — compares without allocating.
fn matches(
&self,
selection: (u32, u32),
selection: (u32, u32, Option<u32>),
map: u32,
states: u64,
explored: &[u32],
@@ -467,27 +566,57 @@ fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u3
}
}
/// `SetMapToCurrentZone`'s resolver, whose two halves fail SEPARATELY — see the call site in
/// [`feed_world_map`] for the byte citation. `map_id` is the player's `Map.dbc` id; `top_zone` is
/// their current area walked up to its top-level zone, or `None` while the area feed has not
/// answered. The answer is the 1-based `(continent, zone)` the Lua selection verbs speak, with
/// **zone 0 meaning the continent map** — the reference's `SetMap(continent, −1)` as
/// `GetCurrentMapZone` reads it back.
fn resolve_player_zone(
/// `0x4a6650`'s answer — which map the player's own position selects — in the Lua-visible
/// encoding. Its two legs are mutually exclusive by construction: the resolver runs the continent
/// loop first and reaches the orphan loop only on a miss (`0x4a66c3`).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
struct PlayerSelection {
/// The continent/zone leg: the 1-based pair, with **zone 0 meaning the continent map** (the
/// reference's `SetMap(continent, −1)` as `GetCurrentMapZone` reads it back). `None` = no
/// continent record carried the player's map.
zone: Option<(u32, u32)>,
/// The orphan leg: the `WorldMapArea` row **ID** whose mapID is the player's own map.
direct: Option<u32>,
}
/// `SetMapToCurrentZone`'s resolver (`0x4a7e20` is `call 0x4a6650`, and the engine's own
/// world-enter sync `0x4947ac` calls the same function — there is no second resolver).
///
/// Three exits, in the reference's own precedence:
///
/// 1. **A continent whose mapID is the player's**, and then its child zones — whose two halves
/// fail SEPARATELY (see the call site in [`feed_world_map`] for the byte citation).
/// 2. **Only on a continent miss**, the orphan list, matched on `row.mapID == playerMapId`, first
/// match wins: the direct-area selection `SetMap(-2, row.id)` (`0x4a6717`–`0x4a671e`). This is
/// the leg a battleground takes — map 489 is no continent's, so nothing above can match.
/// 3. Neither: the world view (both cells `None`), which is also what an empty orphan list gives.
fn resolve_player_selection(
data: &WorldMapUiData,
map_id: u32,
top_zone: Option<u32>,
) -> Option<(u32, u32)> {
let ci = data.continents.iter().position(|c| c.map_id == map_id)?;
let zone = top_zone
.and_then(|top| {
data.continents[ci]
.zones
.iter()
.position(|z| z.area_id == top)
})
.map_or(0, |zi| zi as u32 + 1);
Some((ci as u32 + 1, zone))
) -> PlayerSelection {
if let Some(ci) = data.continents.iter().position(|c| c.map_id == map_id) {
let zone = top_zone
.and_then(|top| {
data.continents[ci]
.zones
.iter()
.position(|z| z.area_id == top)
})
.map_or(0, |zi| zi as u32 + 1);
return PlayerSelection {
zone: Some((ci as u32 + 1, zone)),
direct: None,
};
}
PlayerSelection {
zone: None,
direct: data
.direct
.iter()
.find(|d| d.map_id == map_id)
.map(|d| d.id),
}
}
/// The engine-side world-enter sync's gate and answer — `0x494780`'s `old == 0` leg into the
@@ -502,39 +631,56 @@ fn resolve_player_zone(
/// would sync on frame 1 to the bare continent and — since the reference never re-syncs — stay
/// there.
/// - **Every exit of `0x4a6650` writes the selection**, including its two failure legs
/// (`0x4a667e`/`0x4a670b` ⇒ `SetMap(−1, −1)`). So an unresolvable player — an instance map, no
/// continent row — still counts as synced, at the world view, rather than re-arming the gate
/// every frame. That is `player_zone.unwrap_or((0, 0))`: our `None` IS the reference's
/// `(−1, −1)`, which `GetCurrentMapContinent` reads back to Lua as `0`.
/// (`0x4a667e`/`0x4a670b` ⇒ `SetMap(−1, −1)`). So an unresolvable player — a map in neither
/// list — still counts as synced, at the world view, rather than re-arming the gate every
/// frame. That is the all-`None` [`PlayerSelection`]: it IS the reference's `(−1, −1)`, which
/// `GetCurrentMapContinent` reads back to Lua as `0`.
///
/// A player logging straight into a battleground takes the *direct* leg here, which is why this
/// hands the whole answer on rather than the pair: the sync happens once per session, so dropping
/// the third cell would strand such a session on the world sheet for its whole life.
fn world_enter_selection(
synced: bool,
top_zone: Option<u32>,
player_zone: Option<(u32, u32)>,
) -> Option<(u32, u32)> {
(!synced && top_zone.is_some()).then(|| player_zone.unwrap_or((0, 0)))
player: PlayerSelection,
) -> Option<PlayerSelection> {
(!synced && top_zone.is_some()).then_some(player)
}
/// Per frame: selection read-back → projection → feed push (see the module doc).
/// One projection law for every blip on the DISPLAYED map: world-sheet mode (selection
/// `(0, _)`) projects through the POSITION's own map's continent constants; continent and zone
/// mode through the selected rect, gated to that continent's map. Off-map — the wrong continent,
/// an instance map, outside the rect — is `None`, the reference's `(0, 0)` hide sentinel once it
/// mode through the selected rect, gated to that continent's map; **direct-area mode through the
/// selected row's own rect**, gated to that row's map. Off-map — the wrong continent, a body in
/// another instance, outside the rect — is `None`, the reference's `(0, 0)` hide sentinel once it
/// reaches Lua.
///
/// The direct arm is `0x4a7360`'s own (`0x4a73e0 jl 0x4a7414`): it reads `[0x845074]`, resolves
/// the row, requires `[row+0x4] == mapId` (`0x4a7437`) and then runs the **same tail the zone case
/// uses** — it never touches the continent record array. The gate is what makes it right for
/// every caller and not just the player: `0x4a7870` passes the *unit's own* map id, so a
/// teammate standing in the battleground projects and one who is elsewhere answers `(0, 0)`.
pub(crate) fn project_on_displayed(
data: &WorldMapUiData,
selection: (u32, u32),
selection: (u32, u32, Option<u32>),
pos_map: u32,
px: f32,
py: f32,
) -> Option<(f32, f32)> {
match selection {
(0, _) => data
// The direct cell first: it shares the `(0, 0)` pair with the world sheet.
(_, _, Some(id)) => data
.direct
.iter()
.find(|d| d.id == id && d.map_id == pos_map)
.map(|d| map_proj::zone_uv(d.rect, px, py)),
(0, _, None) => data
.continents
.iter()
.find(|cont| cont.map_id == pos_map)
.and_then(|cont| cont.proj)
.map(|p| map_proj::world_uv(p, px, py)),
(c, z) => data
(c, z, None) => data
.continents
.get(c as usize - 1)
.filter(|cont| cont.map_id == pos_map)
@@ -625,11 +771,15 @@ fn feed_world_map(
// every consumer that assumes a zone uv — Astrolabe, and so every addon built on it — silently
// mis-scales it.
//
// NOT modelled, and deliberately: the `−2` orphan/direct-area leg for a map that is no
// continent's child (the instance maps). We have no orphan list, so those still land on the
// world view rather than the reference's direct-area selection.
// And when NO continent matched, the resolver falls through to its third leg: the orphan
// list, i.e. the instance maps (`0x4a66c3`). Inside a battleground that is the only leg that
// can answer — map 489 is no continent's child — and it selects the battleground's own
// `WorldMapArea` row directly, `SetMap(-2, row.id)`. Both halves travel to the engine
// together; with only the pair, `GetMapInfo()` answers nil in there and the stock
// `Blizzard_BattlefieldMinimap.lua:83-86` returns on the fourth line of its update, which is
// a battle map and a world map that draw nothing at all.
let top_zone = world.area().and_then(|aid| areas.0.top_zone(aid));
let player_zone = resolve_player_zone(&data, map.0, top_zone);
let player_sel = resolve_player_selection(&data, map.0, top_zone);
// ── First world-enter: the ENGINE selects the player's zone, with no Lua in the loop.
//
@@ -656,19 +806,20 @@ fn feed_world_map(
// precondition. Per **VM**, not per process: a new login is a new session's first enter.
{
let memo = memo.get(&script);
if let Some((c, z)) = world_enter_selection(memo.map_synced, top_zone, player_zone) {
if let Some(sel) = world_enter_selection(memo.map_synced, top_zone, player_sel) {
memo.map_synced = true;
script.sync_world_map_to_player_zone(c, z);
let (c, z) = sel.zone.unwrap_or((0, 0));
script.sync_world_map_to_player_zone(c, z, sel.direct);
}
}
// The player's UV on the DISPLAYED map. Off-map (wrong continent, outside the rect, or an
// instance map) resolves to None/(0,0) — the reference's hide-the-blip sentinel.
let (c, z) = script.world_map_selection();
// The player's UV on the DISPLAYED map. Off-map (wrong continent, another instance, outside
// the rect) resolves to None/(0,0) — the reference's hide-the-blip sentinel.
let selection = script.world_map_selection();
// One projection law for every blip on the displayed map (the player now, the corpse below,
// the battleground teammates in `ui_battlefield_positions`): [`project_on_displayed`].
let project =
|pos_map: u32, px: f32, py: f32| project_on_displayed(&data, (c, z), pos_map, px, py);
|pos_map: u32, px: f32, py: f32| project_on_displayed(&data, selection, pos_map, px, py);
let uv = project(map.0, wx, wy);
// The corpse marker (decision 0308 §5): the query answer's DISPLAY position/map (a dungeon
// corpse projects at its entrance — the server rewrote it). `zone_uv`'s outside-the-rect
@@ -691,9 +842,9 @@ fn feed_world_map(
.get(&script)
.landmarks
.as_ref()
.is_some_and(|k| k.matches((c, z), map.0, states_gen, &explored, marker));
.is_some_and(|k| k.matches(selection, map.0, states_gen, &explored, marker));
if !unchanged {
let level = MapLevel::of(c, z);
let level = MapLevel::of(selection);
let mut landmarks = Vec::new();
// The DBC rows first, in file order, each through the builder's gate chain — that order
// IS the Lua landmark index (`0x4a6819`'s walk over `[0xc0e054]`).
@@ -734,7 +885,7 @@ fn feed_world_map(
}
}
memo.get(&script).landmarks = Some(LandmarkKey {
selection: (c, z),
selection,
map: map.0,
states: states_gen,
explored: explored.clone(),
@@ -787,13 +938,18 @@ fn feed_world_map(
};
script.set_world_map_feed(
player_zone,
player_sel.zone,
uv,
player.facing(),
corpse_uv,
party_uv,
raid_uv,
);
// The orphan leg of the same resolver answer, beside the pair: `SetMapToCurrentZone` reads
// both back, and the stock battlefield minimap calls it itself on `PLAYER_ENTERING_WORLD`
// while it is shown — the call that lands a player zoning into a battleground on the
// battleground's own map.
script.set_world_map_player_direct_area(player_sel.direct);
}
/// **Alt+click the world map to go there** — the dev jump.
@@ -847,28 +1003,40 @@ fn dev_map_jump(
else {
return;
};
let (c, z) = script.world_map_selection();
let Some(cont) = c
.checked_sub(1)
.and_then(|i| data.continents.get(i as usize))
else {
info!(
"map-jump: no exact click→world law at the world level — zoom into a continent first"
);
return;
};
let rect = match z.checked_sub(1) {
None => cont.rect,
Some(i) => match cont.zones.get(i as usize) {
Some(zone) => zone.rect,
let (c, z, direct) = script.world_map_selection();
// An instance map is a rect like any other — the same `0x4a7100` zone-mode inverse — so the
// jump works off a battleground map too; only the world sheet has no exact inverse.
let (rect, map_id) = match direct {
Some(id) => match data.direct.iter().find(|d| d.id == id) {
Some(d) => (d.rect, d.map_id),
None => return,
},
None => {
let Some(cont) = c
.checked_sub(1)
.and_then(|i| data.continents.get(i as usize))
else {
info!(
"map-jump: no exact click→world law at the world level — zoom into a \
continent first"
);
return;
};
let rect = match z.checked_sub(1) {
None => cont.rect,
Some(i) => match cont.zones.get(i as usize) {
Some(zone) => zone.rect,
None => return,
},
};
(rect, cont.map_id)
}
};
// `zone_world` lerps BOTH axes by its single `t` (the binary's own shape) — so it is called
// once per axis, which is how the reference's own callers consume it.
let (_, wy) = map_proj::zone_world(rect, u);
let (wx, _) = map_proj::zone_world(rect, v);
let text = format!(".go xy {wx:.2} {wy:.2} {}", cont.map_id);
let text = format!(".go xy {wx:.2} {wy:.2} {map_id}");
info!("map-jump: {text}");
let _ = net.0.send(crate::net::ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
@@ -1065,13 +1233,21 @@ mod tests {
assert!(!is_degenerate((1e-6, 0.0)));
}
/// Our `(continent, zone)` pair, `0` = whole, onto the reference's levels.
/// The engine's three selection cells — our `(continent, zone)` pair with `0` = whole, plus
/// the direct-area id — onto the reference's levels.
#[test]
fn the_selection_pair_maps_onto_the_reference_levels() {
assert_eq!(MapLevel::of(0, 0), MapLevel::World);
assert_eq!(MapLevel::of(0, 3), MapLevel::World, "continent 0 wins");
assert_eq!(MapLevel::of(2, 0), MapLevel::Continent);
assert_eq!(MapLevel::of(2, 7), MapLevel::Zone);
assert_eq!(MapLevel::of((0, 0, None)), MapLevel::World);
assert_eq!(
MapLevel::of((0, 3, None)),
MapLevel::World,
"continent 0 wins"
);
assert_eq!(MapLevel::of((2, 0, None)), MapLevel::Continent);
assert_eq!(MapLevel::of((2, 7, None)), MapLevel::Zone);
// An instance map takes the ZONE arms of both gates it feeds — `0x4a79de`'s level flag
// and `0x4a8856`'s forced textureIndex 15 — even though it shares the world sheet's pair.
assert_eq!(MapLevel::of((0, 0, Some(443))), MapLevel::Zone);
}
/// The REAL `AreaPOI.dbc`, run through the whole gate chain the way the feed does — the
@@ -1208,7 +1384,9 @@ mod player_zone_tests {
}
}
/// Azeroth (map 0) with Elwynn at catalog index 9 (→ Lua zone 10), plus Kalimdor (map 1).
/// Azeroth (map 0) with Elwynn at catalog index 9 (→ Lua zone 10), plus Kalimdor (map 1) —
/// and one orphan, Warsong Gulch's real row (`WorldMapArea` 443 on map 489), which is in
/// neither continent's list because no continent record carries its map.
fn data() -> WorldMapUiData {
WorldMapUiData {
continents: vec![
@@ -1231,6 +1409,18 @@ mod player_zone_tests {
.collect(),
},
],
direct: vec![DirectAreaEntry {
id: 443,
map_id: 489,
rect: rect(),
view: WorldMapZoneView {
name: "Warsong Gulch".into(),
area_id: 3277,
map_file: "WarsongGulch".into(),
loc_rect: (1.0, -1.0, 1.0, -1.0),
overlays: Vec::new(),
},
}],
}
}
@@ -1243,27 +1433,92 @@ mod player_zone_tests {
#[test]
fn an_unresolved_zone_falls_back_to_the_continent_not_the_world() {
let d = data();
let on_continent = |c, z| PlayerSelection {
zone: Some((c, z)),
direct: None,
};
// Both halves resolve: Elwynn is continent 2, zone 10.
assert_eq!(resolve_player_zone(&d, 0, Some(12)), Some((2, 10)));
assert_eq!(
resolve_player_selection(&d, 0, Some(12)),
on_continent(2, 10)
);
// The continent resolves, the zone does not — an area the catalog has no row for.
assert_eq!(
resolve_player_zone(&d, 0, Some(4242)),
Some((2, 0)),
resolve_player_selection(&d, 0, Some(4242)),
on_continent(2, 0),
"SetMap(continent, -1); GetCurrentMapZone reads that back as 0"
);
// The area feed has not answered yet — same leg, not the world view.
assert_eq!(
resolve_player_zone(&d, 0, None),
Some((2, 0)),
resolve_player_selection(&d, 0, None),
on_continent(2, 0),
"a zone we do not know YET is still this continent"
);
// No continent match at all (an instance map) — the one leg that is genuinely None, and
// whose reference counterpart is the -2 orphan list we do not model.
assert_eq!(resolve_player_zone(&d, 389, Some(12)), None);
// No continent match and no orphan either (map 389 is Ragefire Chasm — a dungeon with no
// `WorldMapArea` row at all): both cells empty, which IS the reference's `SetMap(-1, -1)`.
assert_eq!(
resolve_player_selection(&d, 389, Some(12)),
PlayerSelection::default()
);
}
/// **The third leg — a battleground.** No continent record carries map 489, so `0x4a6650`'s
/// continent loop misses and only then does the orphan loop run, matching on the row's own
/// mapID and selecting `SetMap(-2, 443)` — the row's **ID**, not its place in the list.
/// Everything that draws the battle map hangs off that: `GetMapInfo()` answers nil without it,
/// and `Blizzard_BattlefieldMinimap.lua:83-86` returns before it draws a single tile.
#[test]
fn a_battleground_takes_the_orphan_leg_and_projects_on_its_own_rect() {
let d = data();
assert_eq!(
resolve_player_selection(&d, 489, Some(3277)),
PlayerSelection {
zone: None,
direct: Some(443),
},
"the orphan leg selects the WorldMapArea row id"
);
// The continent loop runs FIRST: a map that IS a continent's never reaches the orphan
// list, even were a row to shadow it.
assert_eq!(
resolve_player_selection(&d, 0, Some(12)).direct,
None,
"a continent match short-circuits before the orphan loop"
);
// The projection: the direct state goes through the selected row's OWN rect (the same
// tail the zone case uses), gated to that row's map. The fixture rect is [-1, 1] on both
// axes, so the origin is its centre.
let sel = (0, 0, Some(443));
assert_eq!(
project_on_displayed(&d, sel, 489, 0.0, 0.0),
Some((0.5, 0.5)),
"a body in the battleground lands on the battleground map"
);
assert_eq!(
project_on_displayed(&d, sel, 0, 0.0, 0.0),
None,
"`0x4a7437`: a body on another map is refused, which is the (0,0) hide sentinel"
);
assert_eq!(
project_on_displayed(&d, (0, 0, Some(9999)), 489, 0.0, 0.0),
None,
"an id no row carries resolves to nothing rather than indexing anything"
);
// And the direct cell WINS over the pair it shares its `(0, 0)` with — read the pair
// alone and an instance map projects as the world sheet.
assert_eq!(
project_on_displayed(&d, (0, 0, None), 489, 0.0, 0.0),
None,
"the world sheet has no continent for map 489 at all"
);
assert_eq!(MapLevel::of(sel), MapLevel::Zone);
assert_eq!(MapLevel::of((0, 0, None)), MapLevel::World);
}
/// **The engine selects the player's zone itself, on the first world-enter** — the side call
@@ -1279,25 +1534,206 @@ mod player_zone_tests {
/// perfectly non-zero pair.
#[test]
fn the_engine_selects_the_players_zone_on_first_world_enter() {
let on_continent = |c, z| PlayerSelection {
zone: Some((c, z)),
direct: None,
};
// Frame 1, no area yet: the reference cannot be here at all (`0x67e510` bails before
// `0x494780` on a zero zone id), so neither are we — syncing now would take the
// continent leg and, since the engine never re-syncs, strand us there.
assert_eq!(world_enter_selection(false, None, Some((2, 0))), None);
assert_eq!(world_enter_selection(false, None, on_continent(2, 0)), None);
// The area answers: sync, once, to the player's own zone.
assert_eq!(
world_enter_selection(false, Some(12), Some((2, 10))),
Some((2, 10))
world_enter_selection(false, Some(12), on_continent(2, 10)),
Some(on_continent(2, 10))
);
// And never again. A later zone change — even cross-continent — leaves the selection
// alone: the guard is `old == 0`, and only the world-session teardown `0x491180`
// re-zeroes `[0xb4e314]`.
assert_eq!(world_enter_selection(true, Some(40), Some((2, 22))), None);
assert_eq!(
world_enter_selection(true, Some(40), on_continent(2, 22)),
None
);
// An unresolvable player still counts as synced rather than re-arming the gate every
// frame: EVERY exit of `0x4a6650` writes the selection, and its two failure legs write
// `SetMap(-1, -1)` — which `GetCurrentMapContinent` reads back to Lua as `0`.
assert_eq!(world_enter_selection(false, Some(12), None), Some((0, 0)));
assert_eq!(
world_enter_selection(false, Some(12), PlayerSelection::default()),
Some(PlayerSelection::default())
);
// Logging STRAIGHT INTO a battleground: the once-per-session sync carries the direct cell
// too. Dropping it here would strand that whole session on the world sheet, because
// nothing re-arms the gate.
let bg = PlayerSelection {
zone: None,
direct: Some(443),
};
assert_eq!(world_enter_selection(false, Some(3277), bg), Some(bg));
}
}
#[cfg(test)]
mod direct_area_tests {
use super::*;
/// The real catalog off the player's own chain, or a skip.
fn real_catalog() -> Option<(Vec<WorldMapContinentView>, WorldMapUiData)> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("chain");
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
let maps = benilla_assets::MapCatalogRes(
benilla_formats::load_map_catalog(&mut chain).expect("Map"),
);
Some(build_catalog(&mut chain, &areas, &maps).expect("the real catalog"))
}
/// **The whole population of the direct-area state in 1.12.1**, off the shipped
/// `WorldMapArea.dbc` (51 rows): the three battlegrounds, in the file order `0x4a5d00`'s fill
/// pass appends in — Alterac Valley, Warsong Gulch, Arathi Basin — and nothing else. The
/// complement rule is what produces them: `areaID != 0` (so not a continent row) and a mapID
/// no continent record carries (so not a zone either). Skips without client data.
#[test]
fn the_real_dbc_carries_exactly_the_three_battleground_orphans() {
let Some((views, data)) = real_catalog() else {
return;
};
assert_eq!(
data.direct
.iter()
.map(|d| (d.id, d.map_id, d.view.map_file.as_str()))
.collect::<Vec<_>>(),
vec![
(401, 30, "AlteracValley"),
(443, 489, "WarsongGulch"),
(461, 529, "ArathiBasin"),
],
"file order, no sort — the reference's fill pass appends as it walks"
);
// They are in NEITHER continent list: that is the whole reason a third state exists.
for d in &data.direct {
assert!(
!views
.iter()
.any(|c| c.zones.iter().any(|z| z.map_file == d.view.map_file)),
"{} is no continent's child",
d.view.map_file
);
}
assert!(
data.continents
.iter()
.all(|c| c.map_id == 0 || c.map_id == 1),
"and the continents are still only Azeroth and Kalimdor"
);
// Alterac Valley is the only battleground with overlay art (3 rows keyed on WMA 401);
// the other two have none, so a fully explored character still sees bare detail tiles.
let overlays = |folder: &str| {
data.direct
.iter()
.find(|d| d.view.map_file == folder)
.map(|d| d.view.overlays.len())
};
assert_eq!(overlays("AlteracValley"), Some(3));
assert_eq!(overlays("WarsongGulch"), Some(0));
assert_eq!(overlays("ArathiBasin"), Some(0));
}
/// **The bug, end to end, on the real data**: a body standing in Warsong Gulch (map 489)
/// resolves to the direct area, the engine selects it with no Lua in the loop, and
/// `GetMapInfo()` answers `"WarsongGulch"` — the art-folder identifier the stock
/// `Blizzard_BattlefieldMinimap.lua:83-86` needs before it will draw a single tile, and the
/// name that closes `Interface\WorldMap\WarsongGulch\WarsongGulch1..12` (all twelve ship).
/// The continent reads back `-1` (`-2 + 1`) and the zone `0`.
///
/// The player's own blip comes with it: the rect centre projects to the middle of the map.
/// Skips without client data.
#[test]
fn a_body_in_warsong_gulch_lands_on_the_warsong_gulch_map() {
let Some((views, data)) = real_catalog() else {
return;
};
// The resolver: map 489 is no continent's, so the orphan loop answers with the row id.
let player = resolve_player_selection(&data, 489, Some(3277));
assert_eq!(
player,
PlayerSelection {
zone: None,
direct: Some(443),
}
);
let mut script = UiScript::new().expect("a bare engine");
script.set_world_map_catalog(views);
script.set_world_map_direct_areas(
data.direct
.iter()
.map(|d| (d.id, d.view.clone()))
.collect::<Vec<_>>(),
);
// The engine-side first-world-enter sync — `0x4947ac` into the same resolver, no Lua.
let (c, z) = player.zone.unwrap_or((0, 0));
script.sync_world_map_to_player_zone(c, z, player.direct);
assert_eq!(
script
.eval::<(String, i64, i64)>(
"return GetMapInfo(), GetCurrentMapContinent(), GetCurrentMapZone()"
)
.expect("the getters answer"),
("WarsongGulch".into(), -1, 0)
);
// The blip. The WSG row's rect centre in world coords → the middle of the map art; a body
// on Azeroth is refused outright (the reference's `(0, 0)` hide sentinel).
let wsg = data
.direct
.iter()
.find(|d| d.id == 443)
.expect("the Warsong Gulch row");
let (wx, wy) = (
(wsg.rect.top + wsg.rect.bottom) / 2.0,
(wsg.rect.left + wsg.rect.right) / 2.0,
);
let selection = script.world_map_selection();
assert_eq!(selection, (0, 0, Some(443)));
let uv = project_on_displayed(&data, selection, 489, wx, wy).expect("inside the rect");
assert!(
(uv.0 - 0.5).abs() < 1e-5 && (uv.1 - 0.5).abs() < 1e-5,
"the rect centre is the middle of the battle map, got {uv:?}"
);
assert_eq!(project_on_displayed(&data, selection, 0, wx, wy), None);
// THE REFRESH TRAP: a world-state push or an exploration update re-selects, and a
// re-selection built from the continent/zone pair alone would silently drop the battle
// map for the world view (the reference re-passes `direct != -1 ? direct : zone` at all
// five of its in-place refresh sites).
script.set_world_map_explored(vec![u32::MAX; 64]);
script.set_world_map_player_direct_area(player.direct);
script
.run("SetMapToCurrentZone()")
.expect("the OnShow verb");
assert_eq!(
script
.eval::<(String, i64)>("return GetMapInfo(), GetCurrentMapContinent()")
.expect("the getters answer"),
("WarsongGulch".into(), -1),
"the instance selection survives every refresh path"
);
// And a continent selection clears it — `0x4a67a0`'s other legs all store `-1` there.
script.run("SetMapZoom(1)").expect("SetMapZoom");
assert_eq!(
script.world_map_selection(),
(1, 0, None),
"selecting a continent clears the direct cell"
);
}
}
+65 -4
View File
@@ -1,8 +1,10 @@
//! `ChrClasses.dbc` — the per-class client table, and the two columns anything here reads off it.
//! `ChrClasses.dbc` — the per-class client table, and the three columns anything here reads off
//! it.
//!
//! One file, one parse. Both columns are answers the *engine* gives Lua about a class, and both
//! are read by address off the same class-indexed record table at `ds:0xc0def4`, bounded by the
//! max id at `ds:0xc0def8` (store `0xc0deec`, loader `0x542360`, filename `0x85838c`).
//! One file, one parse. Two of the three are answers the *engine* gives Lua about a class and the
//! third is the talent spell-modifier gate's right-hand side; all three are read by address off
//! the same class-indexed record table at `ds:0xc0def4`, bounded by the max id at `ds:0xc0def8`
//! (store `0xc0deec`, loader `0x542360`, filename `0x85838c`).
//!
//! ## Field 4 — the pet name token
//!
@@ -56,6 +58,22 @@
//! requires `(InventoryType == 28 RELIC) == hasRelicSlot`, so **INVSLOT 17 takes a relic for those
//! three classes and a ranged weapon for everyone else** — one slot, two meanings. 1.12 has no
//! *separate* relic slot, which is the one true fragment inside the old belief.
//!
//! ## Field 15 — the class's spell family
//!
//! `GetSpellModifiers 0x6e6b30`'s second gate: a talent modifier applies to a spell only when that
//! spell's own `SpellFamilyName` (`Spell.dbc` column 160) equals the LOCAL PLAYER's class family,
//! which the client caches in the global `[0xcecaac]`. That global has exactly one
//! non-zeroing writer, `0x6e6ca0` (sole caller `0x5debcc`, the local-player create-finalise): it
//! takes `UNIT_FIELD_BYTES_0` byte 1 — the class, the same walk both sections above make — and
//! stores `ChrClasses[class] + 0x3c`, i.e. **field 15** (wow-re
//! `system/spell/scratch/spellmod-table-law.md` §8).
//!
//! Anchored on the shipped 5875 file, which is also a could-have-failed control on the column: the
//! nine rows read Warrior 4, Paladin 10, Hunter 9, Rogue 8, Priest 6, Shaman 11, Mage 3, Warlock 5,
//! Druid 7 — the vmangos `SpellFamilyNames` values exactly, in an order no neighbouring column
//! could reproduce (field 14 is a name-flags word reading 8323199 on every row, field 16 is the
//! relic flag above).
use std::collections::HashMap;
@@ -79,6 +97,10 @@ const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
/// The relic-slot column — byte `0x40` in `UnitHasRelicSlot`'s read, i.e. field 16.
const RELIC_SLOT_FIELD: usize = 0x40 / 4;
/// The class spell-family column — byte `0x3c` in `0x6e6ca0`'s read, i.e. field 15. See the module
/// header for the walk and the shipped-file anchor.
const SPELL_FAMILY_FIELD: usize = 0x3c / 4;
/// The literal the client pushes when the player object does not resolve (`0x846a40`). Also the
/// value nine of the ten class rows carry, which is why an unloaded table degrades invisibly.
pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
@@ -88,6 +110,7 @@ pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
struct ChrClass {
pet_name_token: Option<String>,
has_relic_slot: bool,
spell_family: u32,
}
/// `ChrClasses.dbc` → class id ⇒ the columns we read.
@@ -114,6 +137,17 @@ impl ChrClasses {
self.0.get(&class).is_some_and(|c| c.has_relic_slot)
}
/// The class's spell family — the value the reference caches in `[0xcecaac]` and compares
/// every spell's `SpellFamilyName` against before any talent modifier may apply.
///
/// A class with no row answers `0`, which is the reference's own degraded state: `0x6e7150`
/// zeroes the global at world-enter (`6e7317`, `esi = 0`) and it stays 0 until the local
/// player resolves — and `GetSpellModifiers`' first conjunct (`SpellFamilyName != 0`) exists
/// precisely so nothing matches during that window.
pub fn spell_family(&self, class: u32) -> u32 {
self.0.get(&class).map_or(0, |c| c.spell_family)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
@@ -148,6 +182,7 @@ pub fn load_chr_classes(chain: &mut Chain) -> Result<ChrClasses> {
ChrClass {
pet_name_token: str_at(&rs, r, PET_NAME_TOKEN_FIELD),
has_relic_slot: u32_at(r, RELIC_SLOT_FIELD).is_some_and(|v| v != 0),
spell_family: u32_at(r, SPELL_FAMILY_FIELD).unwrap_or(0),
},
);
}
@@ -205,4 +240,30 @@ mod tests {
assert!(!t.has_relic_slot(6));
assert!(!t.has_relic_slot(0));
}
/// Field 15, the spell-family gate's right-hand side. The whole column is asserted because
/// that is what makes it a column pin rather than a coincidence: nine distinct values in nine
/// rows, each matching the vmangos `SpellFamilyNames` enum, is a shape no neighbour has
/// (field 14 is 8323199 on every row, field 16 is the three-row relic flag).
#[test]
fn every_class_row_carries_its_vmangos_spell_family() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
for (class, family, who) in [
(1u32, 4u32, "Warrior"),
(2, 10, "Paladin"),
(3, 9, "Hunter"),
(4, 8, "Rogue"),
(5, 6, "Priest"),
(7, 11, "Shaman"),
(8, 3, "Mage"),
(9, 5, "Warlock"),
(11, 7, "Druid"),
] {
assert_eq!(t.spell_family(class), family, "{who}");
}
// No row: the reference's zeroed global, which conjunct 1 of the gate refuses.
assert_eq!(t.spell_family(6), 0);
assert_eq!(t.spell_family(0), 0);
}
}
@@ -1550,3 +1550,77 @@ fn real_channel_bar_name_law() {
"only the two Blood Siphons suppress the bar"
);
}
/// The talent spell-modifier gate's two columns — `SpellFamilyName` 160 and the `SpellFamilyFlags`
/// pair 161/162 — against the shipped 5875 `Spell.dbc`.
///
/// Three things are asserted, and each would catch a different slip. The **family histogram** is
/// the column pin: the eleven nonzero values are exactly the vmangos `SpellFamilyNames` set, and
/// their counts are the shipped file's own (a one-column slip lands on 159/163, neither of which
/// looks like this). The **popcount census** is what makes the reader's 64-iteration walk
/// load-bearing rather than defensive — 322 rows set more than one bit, and the highest index in
/// the whole table is 35, so the HIGH dword is live. The **three worked spells** pin the join
/// direction: 4987 sets 12 and 33, which a low-dword-only read or a swapped pair both get wrong.
///
/// Numbers from wow-re `system/spell/scratch/spellmod-table-law.md` (Provenance), re-measured here
/// off the file this catalog actually loads. Skips without client data.
#[test]
fn real_spell_family_columns_carry_the_modifier_gate() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
let mut families: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
let mut popcounts: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
let mut max_bit = 0u32;
for (_, d) in cat.iter() {
*families.entry(d.spell_family).or_default() += 1;
*popcounts
.entry(d.spell_family_flags.count_ones())
.or_default() += 1;
if d.spell_family_flags != 0 {
max_bit = max_bit.max(63 - d.spell_family_flags.leading_zeros());
}
}
assert_eq!(
families,
[
(0, 18243),
(1, 47),
(3, 563),
(4, 350),
(5, 466),
(6, 493),
(7, 450),
(8, 316),
(9, 393),
(10, 362),
(11, 500),
(13, 174),
]
.into_iter()
.collect::<std::collections::BTreeMap<u32, usize>>(),
"SpellFamilyName histogram — the vmangos SpellFamilyNames set, and only it"
);
assert_eq!(
popcounts.values().skip(1).sum::<usize>() - popcounts[&1],
322,
"rows setting MORE than one family bit — the reader's sum is live"
);
assert_eq!(max_bit, 35, "the highest family bit index in the file");
// The three worked examples. Frostbolt spans one dword, Cleanse spans BOTH, Cure Poison lives
// entirely in the high one.
for (id, family, bits) in [
(116u32, 3u32, &[5u32, 19, 20, 30][..]),
(4987, 10, &[12, 33]),
(526, 11, &[35]),
] {
let d = cat.get(id).unwrap_or_else(|| panic!("spell {id}"));
assert_eq!(d.spell_family, family, "{id} {:?} family", d.name);
let set: Vec<u32> = (0..64)
.filter(|b| d.spell_family_flags >> b & 1 == 1)
.collect();
assert_eq!(set, bits, "{id} {:?} family bits", d.name);
}
}
@@ -61,6 +61,26 @@ pub struct SpellDisplay {
/// search's **hand restriction** (main-hand-only / off-hand-only), which is where
/// `0x5f0c50`'s slot mask comes from (decision 1903).
pub attributes_ex3: u32,
/// **`SpellFamilyName`** (column 160, `SpellRec+0x280`) — which class's talent tree may modify
/// this spell. `0` on 18243 of the 22357 shipped rows (creature and world spells); the rest
/// carry a `SpellFamilyNames` value, which for a player spell is its own class's.
///
/// The first two of `GetSpellModifiers 0x6e6b30`'s three conjunct gates read it and nothing
/// else: `!= 0` (`6e6b38`), then `== [0xcecaac]`, the local player's own class family
/// (`6e6b46` — [`crate::ChrClasses::spell_family`]). A spell that fails either takes no
/// modifier at all, which is how a mage's talent stays off a warrior's ability and off every
/// item/creature spell in the file. wow-re `system/spell/scratch/spellmod-table-law.md` §5.1.
pub spell_family: u32,
/// **`SpellFamilyFlags`** (columns 161/162, `SpellRec+0x284`/`+0x288`) — the 64-bit bit-set
/// naming which of its family's modifier rows this spell subscribes to, low dword first.
///
/// `GetSpellModifiers` walks **all 64 bits** with no early break and SUMS one cell per set bit
/// out of each table (`6e6b72`–`6e6ba6`), so a multi-bit spell accumulates several — and the
/// high dword is genuinely live rather than unused width: measured on the shipped 5875 file,
/// 1794 rows set exactly one bit, **322 set more than one**, and the highest bit index in the
/// whole table is **35**. Frostbolt 116 sets 5/19/20/30, Cleanse 4987 sets 12 **and 33** (it
/// needs both dwords), Cure Poison 526 sets 35 alone.
pub spell_family_flags: u64,
/// **`PreventionType`** (column 165, `SpellRec+0x294`) — which crowd-control flag refuses this
/// spell **locally**, before any packet: `1` = silence, `2` = pacify, `0` = neither. The
/// client's CC validator `0x6094f0` (called from `TryCast 0x6e4b60`, bailing at `0x6e4f42`)
@@ -337,6 +357,8 @@ impl Default for SpellDisplay {
attributes_ex2: 0,
modal_next_spell: 0,
attributes_ex3: 0,
spell_family: 0,
spell_family_flags: 0,
prevention_type: 0,
passive: false,
cast_ui: 0,
+13
View File
@@ -210,6 +210,14 @@ const COL_START_RECOVERY_TIME: usize = 158;
/// `PreventionType` (`SpellRec+0x294`, `0x294/4 == 165`) — see [`SpellDisplay::prevention_type`]
/// for the two-way pin that separates it from its `DmgClass` neighbour at 164.
const COL_PREVENTION_TYPE: usize = 165;
/// `SpellFamilyName` (`SpellRec+0x280`, `0x280/4 == 160`) / `SpellFamilyFlags` low+high
/// (`+0x284`/`+0x288`, 161/162) — the talent spell-modifier gate and its row selector, read by
/// `GetSpellModifiers 0x6e6b30` at `6e6b38`/`6e6b46` and `6e6b83` (wow-re
/// `system/spell/scratch/spellmod-table-law.md` §5). See [`SpellDisplay::spell_family`] and
/// [`SpellDisplay::spell_family_flags`]; the shipped-file anchors are in
/// [`catalog_tests`].
const COL_SPELL_FAMILY_NAME: usize = 160;
const COL_SPELL_FAMILY_FLAGS_LOW: usize = 161;
/// `Targets` (`SpellRec+0x34`, `0x34/4 == 13`) — the wire `TARGET_FLAG_*` seed mask the cast-arm
/// loads into its targeting flag_word (`0x6e525a`, wow-re `wave-cast.md`, VERIFIED). Empirical
/// pin against the binder's bit semantics: Resurrection 2006 = `0x8000` (corpse-ally bit 15),
@@ -817,6 +825,11 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
u32_at(r, COL_EFFECT_MECHANIC_1 + i).unwrap_or(0)
}),
prevention_type: u32_at(r, COL_PREVENTION_TYPE).unwrap_or(0),
spell_family: u32_at(r, COL_SPELL_FAMILY_NAME).unwrap_or(0),
// Low dword first: bit `i >= 32` lives in column 162, which the reference reads as
// `[edi + 4*(i>>5) + 0x284]` — the same little-endian pair this join makes.
spell_family_flags: u64::from(u32_at(r, COL_SPELL_FAMILY_FLAGS_LOW).unwrap_or(0))
| u64::from(u32_at(r, COL_SPELL_FAMILY_FLAGS_LOW + 1).unwrap_or(0)) << 32,
passive: attributes & ATTR_PASSIVE != 0,
cast_ui: u32_at(r, COL_CAST_UI).unwrap_or(0),
effects: [0, 1, 2].map(|i| u32_at(r, COL_EFFECT_1 + i).unwrap_or(0)),
+13
View File
@@ -915,6 +915,19 @@ pub enum SessionEvent {
slot: u32,
seconds: u32,
},
/// One cell of a talent spell-modifier table, absolutely (`SMSG_SET_FLAT_SPELL_MODIFIER` /
/// `SMSG_SET_PCT_SPELL_MODIFIER`): `flat` picks the table, `mask_bit` is the spell's
/// `SpellFamilyFlags` **bit index** (the row) and `op` the SpellModOp (the column). Neither
/// byte is bounded on the wire — the consumer (`benilla::spell_mods`) refuses an out-of-range
/// pair rather than reproducing the reference's own overrun. `value` is **absolute**, not a
/// delta; the wire shape and the `bit * 29 + op` index law are on
/// [`crate::messages::ServerPacket::SpellModifier`].
SpellModifier {
flat: bool,
mask_bit: u8,
op: u8,
value: i32,
},
/// Start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's parked timers now
/// (`SMSG_COOLDOWN_EVENT`).
CooldownEvent { spell_id: u32, caster: u64 },
@@ -287,6 +287,17 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
slot,
seconds,
}],
ServerPacket::SpellModifier {
flat,
mask_bit,
op,
value,
} => vec![SessionEvent::SpellModifier {
flat,
mask_bit,
op,
value,
}],
ServerPacket::CooldownEvent { spell_id, caster } => {
vec![SessionEvent::CooldownEvent { spell_id, caster }]
}
@@ -362,6 +362,23 @@ pub const SMSG_SPELL_FAILED_OTHER: u16 = 0x02A6; // 678
/// chain `CharProc` consumes once and zeroes (decision 0955). Body in [`super::spells`].
pub const SMSG_SPELL_UPDATE_CHAIN_TARGETS: u16 = 0x0330; // 816
/// The **talent spell-modifier** pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 614/615) — the only
/// feed for every talent that cheapens a spell, shortens its cast or cooldown, extends its range
/// or radius, or lengthens its duration. Body in [`super::spells::read_set_spell_modifier`];
/// decision-level law in wow-re `system/spell/scratch/spellmod-table-law.md`.
///
/// **One handler, two tables.** Both opcodes register to `Spell_C::HandleSetSpellModifier
/// 0x6e9950` (`0x6e7245`/`0x6e7255`), which reads the same 6-byte body either way and forks on the
/// opcode alone (`6e9989: cmp edi,0x267`): `0x266` stores into the FLAT table `0xcead60`, `0x267`
/// into the PCT table `0xcecb30`, each `i32[64][29]`. The store is a plain `mov`, so the server
/// sends the **absolute** value of one cell, never a delta (vmangos `Player::SendSpellMod` sends
/// one packet per set mask bit carrying that pair's total).
///
/// That is why the two share a variant here rather than taking one each: the wire shape is
/// identical and the opcode IS the discriminant, exactly as the reference treats it.
pub const SMSG_SET_FLAT_SPELL_MODIFIER: u16 = 0x0266; // 614
pub const SMSG_SET_PCT_SPELL_MODIFIER: u16 = 0x0267; // 615
// The cooldown wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 308/176/309/478/481; the client
// handlers are byte-verified in wow-re `wave-handlers.md` — 0x6e9460/0x6e95d0/0x6e9670/0x6e9730;
// decision 0137 phase 4). Bodies in [`super::spells`].
@@ -764,6 +764,26 @@ pub enum ServerPacket {
slot: u32,
seconds: u32,
},
/// `SMSG_SET_FLAT_SPELL_MODIFIER` / `SMSG_SET_PCT_SPELL_MODIFIER` — one cell of one of the two
/// talent spell-modifier tables, absolutely (layout + the index law in
/// [`super::spells::read_set_spell_modifier`]).
///
/// One variant for both opcodes because the reference has one handler for both
/// (`0x6e9950`), reading the identical body and forking on the opcode alone to pick the table
/// — so `flat` IS the opcode, and nothing else differs.
SpellModifier {
/// `true` = `0x266`, the FLAT table (summed and added); `false` = `0x267`, the PCT table
/// (summed, then `+100`, clamped at 0, then used as a raw multiplier).
flat: bool,
/// The spell's `SpellFamilyFlags` **bit index**, 0..=63 — the table's row. The wire does
/// not bound it (the reference's own store overruns into the neighbouring global on a
/// 64); the consumer refuses it.
mask_bit: u8,
/// The SpellModOp, 0..=28 — the table's column. Likewise unbounded on the wire.
op: u8,
/// The cell's new value, **signed** and **absolute** — never a delta.
value: i32,
},
/// `SMSG_COOLDOWN_EVENT` — start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's
/// parked timers now (layout in [`super::spellbook::read_cooldown_event`]).
CooldownEvent {
@@ -1752,6 +1772,14 @@ impl ServerPacket {
ServerPacket::ItemCooldown { .. } => "SMSG_ITEM_COOLDOWN".into(),
ServerPacket::ItemTime { .. } => "SMSG_ITEM_TIME_UPDATE".into(),
ServerPacket::ItemEnchantTime { .. } => "SMSG_ITEM_ENCHANT_TIME_UPDATE".into(),
// The opcode IS the table, so the name recovers it — unlike the collapsed
// `AttackSwingError` arm above, nothing here is lost.
ServerPacket::SpellModifier { flat, .. } => if *flat {
"SMSG_SET_FLAT_SPELL_MODIFIER"
} else {
"SMSG_SET_PCT_SPELL_MODIFIER"
}
.into(),
ServerPacket::CooldownEvent { .. } => "SMSG_COOLDOWN_EVENT".into(),
ServerPacket::ClearCooldown { .. } => "SMSG_CLEAR_COOLDOWN".into(),
ServerPacket::CooldownCheat { .. } => "SMSG_COOLDOWN_CHEAT".into(),
@@ -815,6 +815,16 @@ fn parse_server_body(
seconds,
}
}
// One body, two tables — the opcode picks, exactly as `0x6e9950`'s `cmp edi,0x267` does.
opcode::SMSG_SET_FLAT_SPELL_MODIFIER | opcode::SMSG_SET_PCT_SPELL_MODIFIER => {
let (mask_bit, op, value) = spells::read_set_spell_modifier(&mut r)?;
ServerPacket::SpellModifier {
flat: opcode == opcode::SMSG_SET_FLAT_SPELL_MODIFIER,
mask_bit,
op,
value,
}
}
opcode::SMSG_COOLDOWN_EVENT => {
let (spell_id, caster) = spellbook::read_cooldown_event(&mut r)?;
ServerPacket::CooldownEvent { spell_id, caster }
+66 -2
View File
@@ -24,8 +24,8 @@
use std::io::{self, Read};
use crate::wire::{
capacity_hint, read_cstring, read_packed_guid, read_u16_le, read_u32_le, read_u64_le, read_u8,
Vector3d,
capacity_hint, read_cstring, read_i32_le, read_packed_guid, read_u16_le, read_u32_le,
read_u64_le, read_u8, Vector3d,
};
/// `SMSG_CAST_RESULT`'s verdict: `u32 spellId, u8 status` — status `0` (`SPELL_RESULT_STATUS_OKAY`)
@@ -315,6 +315,39 @@ pub(super) fn read_channel_update(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `SMSG_SET_FLAT_SPELL_MODIFIER` / `SMSG_SET_PCT_SPELL_MODIFIER` → `(mask_bit, op, value)`.
///
/// **Exactly 6 bytes, and the first byte is the one that is easy to get backwards.** The single
/// handler `Spell_C::HandleSetSpellModifier 0x6e9950` reads `u8` (`6e9963`), `u8` (`6e996e`), `i32`
/// (`6e9979`) — the widths are the stream accessors' own (`0x418cb0` advances the cursor by 1,
/// `0x418e30` by 4) — and then stores at `imul eax,eax,0x1d; add eax,ecx`, i.e.
/// **`index = field1 * 29 + field2`**. The reader `GetSpellModifiers 0x6e6b30` strength-reduces
/// the same index to `4*op + 0x74*i` over a loop counter `i` bounded by `cmp eax,0x40`, which
/// forces `field1 = i` (the `SpellFamilyFlags` **bit index**, 0..63) and `field2 = op` (the
/// SpellModOp, 0..28) uniquely — and only that assignment fills the 64×29 table exactly
/// (`29*63 + 28 == 1855`). The transposed reading addresses 47% of the array and would make the
/// writer and the reader touch systematically disjoint cells, dropping every modifier silently.
/// wow-re `system/spell/scratch/spellmod-table-law.md` §1/§3 — which was written to correct
/// exactly that inversion in a note that had carried it, `verified`, for months.
///
/// **vmangos's writer says the same thing from the other end**, which is worth having because the
/// client-side roles were published inverted once already: `Player::SendSpellMod`
/// (`Objects/Player.cpp:17815`) loops `for (int eff = 0; eff < 64; ++eff)` over the modifier's
/// 64-bit `mask` and, for each set bit, writes `uint8(eff)`, `uint8(mod->op)`, `int32(val)` — one
/// packet per set bit, `val` the SUM of every matching modifier, i.e. absolute. `MAX_SPELLMOD`
/// there is **29** (`Spells/SpellDefines.h`), the stride.
///
/// Both bytes are `movzx`-ed (unsigned); the value's signedness is fixed three independent ways by
/// the reader (`sets dl` on the pct sum, `fild dword`, and a signed magic-divide). Neither byte is
/// bounds-checked by the reference — that is the consumer's job, and
/// `benilla::spell_mods::SpellModifiers` does it; this decode owns the shape only.
pub(super) fn read_set_spell_modifier(r: &mut impl Read) -> io::Result<(u8, u8, i32)> {
let mask_bit = read_u8(r)?;
let op = read_u8(r)?;
let value = read_i32_le(r)?;
Ok((mask_bit, op, value))
}
/// Read `SMSG_UPDATE_AURA_DURATION` → `(slot, remaining_ms)` (vmangos
/// `SpellAuraHolder::UpdateAuraDuration`, `SpellAuras.cpp:7511-7523`): a `u8` `UNIT_FIELD_AURA` slot
/// index and a `u32` of milliseconds left. **Self-only** — it goes to the aura's target, never to
@@ -517,6 +550,37 @@ mod tests {
"the body is exactly 5 bytes — slot is a byte, not a dword"
);
}
/// The spell-modifier body, byte for byte — and the field ORDER, which is the whole trap.
#[test]
fn set_spell_modifier_body_golden() {
// mask_bit 35 (0x23) · op 14 (0x0e, SPELLMOD_COST) · value -30 (0xFFFF_FFE2 LE).
let body = [0x23, 0x0E, 0xE2, 0xFF, 0xFF, 0xFF];
let mut r = &body[..];
assert_eq!(read_set_spell_modifier(&mut r).unwrap(), (35, 14, -30));
assert!(
r.is_empty(),
"the body is exactly 6 bytes — two bytes and a dword, never three dwords"
);
// The two bytes are NOT interchangeable, and this body is chosen so that a swap is
// detectable rather than plausible: 35 is a legal mask bit (0..=63) and an ILLEGAL op
// (0..=28), so a decode that read them the other way round would hand the consumer an op
// of 35 — off the end of the table's 29-wide axis.
let (mask_bit, op, _) = read_set_spell_modifier(&mut &body[..]).unwrap();
assert!(
mask_bit >= 29,
"field 1 would be an out-of-range op if swapped"
);
assert!(op < 29, "field 2 is a legal op as read");
// Both bytes are unsigned (`movzx`) and the value is signed: 0xFF in field 1 is bit 255,
// not -1, and it is the consumer that must refuse it.
assert_eq!(
read_set_spell_modifier(&mut &[0xFF, 0xFF, 0x01, 0x00, 0x00, 0x00][..]).unwrap(),
(255, 255, 1)
);
}
}
/// One decoded `SMSG_SPELL_UPDATE_CHAIN_TARGETS` — the **beam's hop list** (decision 0955).
+52
View File
@@ -1384,3 +1384,55 @@ fn combat_log_completeness_wire() {
));
assert!(messages::parse_server(messages::opcode::SMSG_SPELLLOGEXECUTE, &body).is_err());
}
/// The talent spell-modifier pair, end to end: both opcodes through `parse_server` and on into a
/// [`SessionEvent`]. One handler, one body, and the opcode as the only discriminant — so the two
/// bodies here are byte-identical and only the `flat` flag differs.
#[test]
fn spell_modifier_wire() {
// mask_bit 35 · op 14 (SPELLMOD_COST) · value -30. Bit 35 is deliberately past the low dword:
// it is a real shipped value (Cure Poison 526's only bit) and it is the half a 32-bit mask
// would quietly drop.
let body = hx("230ee2ffffff");
for (wire_op, is_flat) in [
(messages::opcode::SMSG_SET_FLAT_SPELL_MODIFIER, true),
(messages::opcode::SMSG_SET_PCT_SPELL_MODIFIER, false),
] {
let packet = messages::parse_server(wire_op, &body).unwrap();
// The name recovers the opcode, which the collapsed-arm families cannot.
assert_eq!(
packet.name(),
if is_flat {
"SMSG_SET_FLAT_SPELL_MODIFIER"
} else {
"SMSG_SET_PCT_SPELL_MODIFIER"
}
);
match &packet {
ServerPacket::SpellModifier {
flat,
mask_bit,
op,
value,
} => assert_eq!((*flat, *mask_bit, *op, *value), (is_flat, 35, 14, -30)),
other => panic!("spell modifier, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellModifier {
flat,
mask_bit,
op,
value,
} => assert_eq!((flat, mask_bit, op, value), (is_flat, 35, 14, -30)),
_ => panic!("spell modifier event"),
}
}
// Five bytes is not a body: the value is a dword, not the three bytes a "u8 u8 u8 i16" misread
// would take.
assert!(messages::parse_server(
messages::opcode::SMSG_SET_FLAT_SPELL_MODIFIER,
&hx("230ee2ffff")
)
.is_err());
}
+172 -2
View File
@@ -108,6 +108,176 @@ fn push_catalog(s: &mut UiScript) {
]);
}
/// The **orphan list** — `0x4a5d00`'s third array, and in 5875 its entire population: the three
/// battlegrounds, with the real row ids, map ids, area ids and art folders, in `WorldMapArea.dbc`
/// **file order** (the fill pass appends with no sort). Alterac Valley carries one of its three
/// real `WorldMapOverlay` rows over its real AreaBit 954 — the only battleground art the
/// explored-bit gate has anything to say about (WSG and AB key zero overlay rows).
fn push_direct_areas(s: &mut UiScript) {
let row = |name: &str, area_id: u32, folder: &str, overlays: Vec<WorldMapOverlayView>| {
WorldMapZoneView {
name: name.into(),
area_id,
map_file: folder.into(),
// A synthetic rect: nothing engine-side projects through it (the app owns every
// projection), it is carried because the row is the row.
loc_rect: (1500.0, 500.0, 1600.0, 600.0),
overlays,
}
};
s.set_world_map_direct_areas(vec![
(
401,
row(
"Alterac Valley",
2597,
"AlteracValley",
vec![WorldMapOverlayView {
texture: "Interface\\WorldMap\\AlteracValley\\DUNBALDAR".into(),
width: 128,
height: 128,
offset_x: 300,
offset_y: 200,
explore_bits: vec![954],
hit_rect: (200, 300, 328, 428),
area_name: Some("Dun Baldar".into()),
}],
),
),
(443, row("Warsong Gulch", 3277, "WarsongGulch", Vec::new())),
(461, row("Arathi Basin", 3358, "ArathiBasin", Vec::new())),
]);
}
/// **The direct-area selection** — the third state, and the whole of what a battleground map is
/// (wow-re `system/ui/scratch/worldmap-direct-area-selection.md`). Inside Warsong Gulch the
/// reference selects `(continent = -2, direct = WorldMapArea 443)`, and `GetMapInfo()` answers the
/// art folder that `Blizzard_BattlefieldMinimap.lua:83-86` needs before it will draw anything at
/// all; a client that models the selection as `(continent, zone)` answers nil there and draws an
/// empty battle map.
#[test]
fn a_direct_area_is_the_third_selection_state() {
let mut s = script();
push_catalog(&mut s);
push_direct_areas(&mut s);
// The player is in Warsong Gulch: `0x4a6650`'s continent loop misses (map 489 is no
// continent's) and only then does the orphan loop match, selecting the row's own id.
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
s.set_world_map_player_direct_area(Some(443));
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(
s.eval::<(String, i64, i64)>(
"return GetMapInfo(), GetCurrentMapContinent(), GetCurrentMapZone()"
)
.unwrap(),
("WarsongGulch".into(), -1, 0),
"the ART FOLDER (not the localized \"Warsong Gulch\"), continent -2 + 1, zone -1 + 1"
);
assert_eq!(
s.eval::<(i64, i64)>("local n, h, hpot = GetMapInfo() return h, hpot")
.unwrap(),
(0, 0),
"slots 2/3 are 0 on every instance map: WorldMapContinent.dbc ships only mapIDs 0 and 1"
);
assert!(
s.eval::<bool>("local t = { GetMapZones(GetCurrentMapContinent()) } return t[1] == nil")
.unwrap(),
"GetMapZones(-1) takes the unsigned-bound exit — the dropdown that could write a bogus \
row id has no entries to fire from"
);
// Hover and click are refused. Both matter: there is no `.zmp` bitmap for a battleground
// (`0x4a6ec0`/`0x4a7620`/`0x4a7540` each test `-2` explicitly), and the world sheet's
// continent walk must not run just because the direct state shares its `continent == 0` cell.
assert!(s
.eval::<Option<String>>("return UpdateMapHighlight(0.5, 0.5)")
.unwrap()
.is_none());
s.run("ProcessMapClick(0.72, 0.63)").unwrap(); // EK land, were this the world sheet
assert_eq!(
s.eval::<(String, i64)>("return GetMapInfo(), GetCurrentMapContinent()")
.unwrap(),
("WarsongGulch".into(), -1),
"a click cannot drill off an instance map"
);
// ── THE REFRESH TRAP. All five of the reference's in-place refresh sites re-pass
// `direct != -1 ? direct : zone` (`0x48f9f6`, `0x4a6460`, `0x6d93d8`, `0x6d9a17`, `0x6dac72`);
// one that re-passed the zone alone would drop the instance map back to the world view on the
// next world-state push or exploration update — silently, and only inside a battleground.
s.set_world_map_explored(vec![u32::MAX; 64]);
s.set_world_map_landmarks(Vec::new());
s.run("SetMapToCurrentZone()").unwrap();
s.sync_world_map_to_player_zone(0, 0, Some(443));
assert_eq!(
s.eval::<(String, i64)>("return GetMapInfo(), GetCurrentMapContinent()")
.unwrap(),
("WarsongGulch".into(), -1),
"a refresh re-selects the DIRECT cell, never the (0, 0) pair beside it"
);
// Selecting a continent or a zone CLEARS the direct cell: `0x4a67a0`'s other legs all fall
// into `0x4a67ea mov [0x845074],-1`, and only `ecx == -2` jumps past it.
s.run("SetMapZoom(2, 1)").unwrap();
assert_eq!(
s.eval::<(String, i64, i64)>(
"return GetMapInfo(), GetCurrentMapContinent(), GetCurrentMapZone()"
)
.unwrap(),
("Elwynn".into(), 2, 1),
"the instance map does not shadow later navigation"
);
// And the stock zoom-out button's own path off a battleground map: its `else` arm fires
// because `GetCurrentMapZone()` is 0 there, and `SetMapZoom(0)` is the world view.
s.run("SetMapToCurrentZone()").unwrap();
s.run("SetMapZoom(0)").unwrap();
assert_eq!(
s.eval::<(Option<String>, i64)>("return GetMapInfo(), GetCurrentMapContinent()")
.unwrap(),
(None, 0),
"zooming out of an instance map lands on the world sheet"
);
}
/// An instance map's overlays are **admitted**, keyed by the direct area, under exactly the zone's
/// explored-bit gate (`0x4a67a0`'s overlay half, `0x4a6b10 jl 0x4a6b3d`; wow-re
/// `worldmap-overlay-reveal-gate.md` §2) — no free reveal for a battleground. On 5875 data that
/// means Alterac Valley's three rows and nothing at all for the other two.
#[test]
fn an_instance_map_reveals_its_overlays_under_the_zone_gate() {
let mut s = script();
push_catalog(&mut s);
push_direct_areas(&mut s);
s.set_world_map_player_direct_area(Some(401));
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(
s.eval::<i64>("return GetNumMapOverlays()").unwrap(),
0,
"nothing explored — the gate applies exactly as on a zone map"
);
// AreaBit 954 = word 29, bit 26.
let mut explored = vec![0u32; 64];
explored[29] = 1 << 26;
s.set_world_map_explored(explored);
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 1);
assert_eq!(
s.eval::<String>("return GetMapOverlayInfo(1)").unwrap(),
"Interface\\WorldMap\\AlteracValley\\DUNBALDAR"
);
// Warsong Gulch keys zero `WorldMapOverlay` rows — a fully explored character still sees bare
// detail tiles there.
s.set_world_map_player_direct_area(Some(443));
s.run("SetMapToCurrentZone()").unwrap();
s.set_world_map_explored(vec![u32::MAX; 64]);
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
}
/// The navigation surface: lists come from the catalog, SetMapZoom's selection reads back
/// synchronously in the same Lua breath (the reference's dropdown-click contract), zone/continent
/// arguments clamp, and GetMapInfo names the displayed art folder (nil at the world level — the
@@ -240,7 +410,7 @@ fn the_engine_can_select_a_zone_with_no_lua_call() {
(0, 0)
);
s.sync_world_map_to_player_zone(1, 2);
s.sync_world_map_to_player_zone(1, 2, None);
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
@@ -259,7 +429,7 @@ fn the_engine_can_select_a_zone_with_no_lua_call() {
// It clamps through the same tail as the Lua verbs — an out-of-range pair cannot corrupt
// the selection (`0x4a67a0` range-checks the zone against the continent's child count).
s.sync_world_map_to_player_zone(99, 99);
s.sync_world_map_to_player_zone(99, 99, None);
let (c, _) = s
.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap();
+208 -31
View File
@@ -17,6 +17,15 @@
//! verified rules (WorldMapArea file order for continents, case-insensitive display-name sort
//! for zones; wow-re Q1(d)/Q3(b) verdicts, 2026-07-07).
//!
//! …and a **third** selection state beside that pair: the **direct area**, the map of an
//! instance — a battleground, a dungeon — which is no continent's child and so falls out of both
//! lists (wow-re `system/ui/scratch/worldmap-direct-area-selection.md`). The reference does not
//! encode it inside `(continent, zone)`: it carries THREE `.data` cells, and `continent == -2`
//! with a `WorldMapArea` row **ID** in the third one IS that state ([`WorldMapState::direct_area`]).
//! Inside Warsong Gulch a client without it answers `GetMapInfo() == nil`, and the stock
//! `Blizzard_BattlefieldMinimap.lua:83-86` returns on the fourth line of its update — an empty
//! battle map and an empty world map, which is the bug this models away.
//!
//! Continent-level hover/click resolve through the pushed **zone grid** — the 128×128 area
//! bitmap (`Interface\WorldMap\<Continent>.zmp`, remapped app-side to 1-based zone indices; the
//! source + cell law are wow-re-verified, `0x4a6ec0` / the Q1 §5 verdict 2026-07-07). The cell
@@ -178,6 +187,14 @@ fn area_grid_cell(loc: (f32, f32, f32, f32), u: f32, v: f32) -> Option<usize> {
/// bitmap, a zone/city is just a different rect window (wow-re 15b2a8ea, FUN_004a6ec0 §1c). This
/// is what lets a click on a city's footprint from its neighbouring zone map drill into the city.
fn grid_area(state: &WorldMapState, u: f32, v: f32) -> Option<u16> {
// The direct-area state has no bitmap and is refused before the cell law runs: `0x4a6ec0`
// opens `cmp esi,-2; je 0x4a70ed` (wow-re `worldmap-direct-area-selection.md` §6) — the chain
// ships exactly three `.zmp` files and none of them is a battleground's. Explicit rather than
// left to `c == 0` below, because "the world sheet" and "an instance map" are different
// states that happen to share that cell.
if state.direct_area.is_some() {
return None;
}
let (c, z) = state.selection;
if c == 0 {
return None;
@@ -215,12 +232,37 @@ pub struct WorldMapState {
/// the world level, where `GetMapInfo` returns nil and the reference Lua falls back to
/// `"World"`).
pub continents: Vec<WorldMapContinentView>,
/// The displayed map: `(continent, zone)`, `(0, 0)` = the world sheet.
/// The **orphan list** — the third array the reference's catalog builder fills (`0x4a5d00`'s
/// count/fill passes `0x4a6130`/`0x4a61c9`): every `WorldMapArea` row with `areaID != 0`
/// whose mapID matches **no** continent record, as `(row ID, the row)`, in DBC **file order,
/// unsorted** — the three battlegrounds in 5875. Keyed by id, never indexed: nothing outside
/// [`direct_row`]'s lookup reads a position here, because the selection stores the row's own
/// ID rather than a place in this list.
pub direct_areas: Vec<(u32, WorldMapZoneView)>,
/// The displayed map: `(continent, zone)`, `(0, 0)` = the world sheet. **Not the whole
/// selection on its own** — [`Self::direct_area`] overrides it.
pub selection: (u32, u32),
/// The **direct-area selection**: the `WorldMapArea` row **ID** of an instance map, or `None`
/// at every continent/zone/world selection.
///
/// The reference's selection is THREE `.data` cells, not two (wow-re
/// `worldmap-direct-area-selection.md` §1): `[0x84506c]` continent, `[0x845070]` zone,
/// `[0x845074]` direct. `continent == -2` IS this state, and the third cell then holds the
/// row's own id — **not** an index into [`Self::direct_areas`], which is only ever a lookup
/// table. The setter `0x4a67a0` keeps the two exclusive (`0x4a67c1` stores the id only on the
/// `ecx == -2` leg and jumps past `0x4a67ea`'s `= -1`; every other leg reaches it), so
/// `direct_area.is_some()` ⇒ `selection == (0, 0)` — enforced in [`store_selection`], the one
/// writer.
pub direct_area: Option<u32>,
/// Where `SetMapToCurrentZone` lands — the player's current `(continent, zone)` resolved by
/// the app (AreaTable parent walk × the catalog). `None` = unresolvable (instance, loading) →
/// the world sheet.
/// the app (AreaTable parent walk × the catalog). `None` = no continent matched → either the
/// orphan leg below, or the world sheet.
pub player_zone: Option<(u32, u32)>,
/// The orphan half of that same answer: the `WorldMapArea` row ID whose mapID is the player's
/// own map, or `None`. `0x4a6650` runs its continent loop FIRST and reaches the orphan loop
/// only on a miss (`0x4a66c3`), so at most one of the two is ever set — and inside a
/// battleground it is this one.
pub player_direct_area: Option<u32>,
/// `GetPlayerMapPosition("player")` for the CURRENT selection — projected app-side each frame
/// (`map_proj`), `None`/(0,0) = off this map (the reference hides the blip).
pub player_uv: Option<(f32, f32)>,
@@ -355,6 +397,11 @@ fn continent_hover(cont: &WorldMapContinentView, zone: &WorldMapZoneView) -> Opt
/// name-only tail is reachable from there (`0x4a812e`). World level: the continent highlight is
/// not built, so the tail.
fn hover(wm: &WorldMapState, x: f32, y: f32) -> Hover {
// An instance map resolves no area under the cursor: the lookup INSIDE `UpdateMapHighlight`
// (`0x4a7620`) opens `cmp esi,-2; je 0x4a76d7` → `xor eax,eax`, so no highlight and no name.
if wm.direct_area.is_some() {
return Hover::Miss;
}
let (c, z) = wm.selection;
let Some(cont) = c.checked_sub(1).and_then(|i| wm.continents.get(i as usize)) else {
return Hover::Miss;
@@ -377,8 +424,28 @@ fn hover(wm: &WorldMapState, x: f32, y: f32) -> Hover {
.unwrap_or(Hover::Miss)
}
/// The current selection's zone view, if a zone map is displayed.
/// The selected direct area's row, resolved by **id** through the orphan list — the reference's
/// `[0xc0d5bc]` id-index lookup (`0x4a6cf0`'s `0x4a6d24` leg), which bound-checks on every read
/// rather than trusting the stored cell. An id no row carries reads back as `None`, which is the
/// binary's own NULL → `lua_pushnil`.
fn direct_row(state: &WorldMapState) -> Option<&WorldMapZoneView> {
let id = state.direct_area?;
state
.direct_areas
.iter()
.find(|(row_id, _)| *row_id == id)
.map(|(_, row)| row)
}
/// The displayed map's `WorldMapArea` row, if one is displayed: the selected zone, or — in the
/// direct-area state — the instance map's own row. `0x4a6cf0` resolves both through the same id
/// lookup, and `0x4a67a0`'s overlay half admits the `-2` state with the displayed key
/// `= [0x845074]` (wow-re `worldmap-overlay-reveal-gate.md` §2), so a battleground's overlays
/// reveal under the explored-bit gate exactly as a zone's do — no free reveal.
fn current_zone(state: &WorldMapState) -> Option<&WorldMapZoneView> {
if state.direct_area.is_some() {
return direct_row(state);
}
let (c, z) = state.selection;
if c == 0 || z == 0 {
return None;
@@ -417,6 +484,25 @@ impl super::UiScript {
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
/// Push the **orphan list** — the instance maps, keyed by `WorldMapArea` row id (see
/// [`WorldMapState::direct_areas`]). Pushed beside the continent catalog at the same edge and
/// for the same reason; the reference fills both containers in one walk (`0x4a5d00`).
pub fn set_world_map_direct_areas(&mut self, direct_areas: Vec<(u32, WorldMapZoneView)>) {
let mut model = self.model_mut();
model.worldmap.direct_areas = direct_areas;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
/// Push the resolver's **orphan leg** for the player — the `WorldMapArea` row id whose map is
/// the player's own, or `None` (see [`WorldMapState::player_direct_area`]). The continent/zone
/// leg rides [`Self::set_world_map_feed`]'s `player_zone`; `0x4a6650` computes both in one
/// pass and reaches this one only when that one missed.
pub fn set_world_map_player_direct_area(&mut self, direct_area: Option<u32>) {
self.model_mut().worldmap.player_direct_area = direct_area;
}
/// Push the discovery bitset (the app calls this when `PLAYER_EXPLORED_ZONES` changes —
/// including the first stream-in). Queues `WORLD_MAP_UPDATE` so an open zone map fills in
/// newly explored art immediately.
@@ -463,10 +549,15 @@ impl super::UiScript {
model.worldmap.raid_uv = raid_uv;
}
/// The engine-owned selection `(continent, zone)` — the app reads it each frame to project
/// the feed for the displayed map.
pub fn world_map_selection(&self) -> (u32, u32) {
self.model_ref().worldmap.selection
/// The engine-owned selection — the app reads it each frame to project the feed for the
/// displayed map. **All three of the reference's cells**, in its own order: the continent and
/// zone pair (`0` = whole), then the direct-area row id
/// ([`WorldMapState::direct_area`]) which overrides both when it is `Some`. A consumer that
/// reads only the pair sees an instance map as the world sheet — which is precisely the bug
/// that left the battle map empty — so the third cell travels with them.
pub fn world_map_selection(&self) -> (u32, u32, Option<u32>) {
let wm = &self.model_ref().worldmap;
(wm.selection.0, wm.selection.1, wm.direct_area)
}
/// Move the selection **from the engine**, with no Lua in the loop — the reference's
@@ -479,9 +570,19 @@ impl super::UiScript {
/// level `GetPlayerMapPosition` answers a world-SHEET uv (`0x4a7360` step 2), which every
/// addon that assumes a zone uv silently mis-scales. See [`crate::script::worldmap`]'s caller
/// in `ui_world_map::feed_world_map` for the gate.
pub fn sync_world_map_to_player_zone(&mut self, continent: u32, zone: u32) {
///
/// It takes the resolver's whole answer — including the orphan leg — because `0x4947ac` calls
/// `0x4a6650`, the same resolver `SetMapToCurrentZone` does: a player logging straight into a
/// battleground is on the battleground's own map before any Lua runs, exactly as one logging
/// into Elwynn is on Elwynn's.
pub fn sync_world_map_to_player_zone(
&mut self,
continent: u32,
zone: u32,
direct_area: Option<u32>,
) {
let mut model = self.model_mut();
select(&mut model, i64::from(continent), i64::from(zone));
select_resolved(&mut model, continent, zone, direct_area);
}
/// The **normalized position within the map art** at UI-space `(x, y)`, or `None` when the
@@ -515,8 +616,22 @@ impl super::UiScript {
}
}
/// Clamp + store a selection and queue the repaint event. The shared tail of
/// `SetMapZoom`/`SetMapToCurrentZone`/`ProcessMapClick`.
/// **The one writer of the three selection cells** — `0x4a67a0`, whose whole job is that the
/// continent/zone pair and the direct-area id can never both be live: the setter's valid-continent
/// leg and its `-1` leg both fall into `0x4a67ea mov [0x845074],eax` (`= -1`), and only `ecx == -2`
/// jumps past it after storing the id (`0x4a67c1`), having already forced the zone cell to `-1`.
/// Every selection verb in this module goes through here, so neither half can be moved alone.
fn store_selection(model: &mut Model, selection: (u32, u32), direct_area: Option<u32>) {
model.worldmap.selection = selection;
model.worldmap.direct_area = direct_area;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
/// Clamp + store a continent/zone selection and queue the repaint event. The shared tail of
/// `SetMapZoom`/`SetMapToCurrentZone`/`ProcessMapClick` — and it **clears the direct area**, which
/// is the setter invariant above.
fn select(model: &mut Model, continent: i64, zone: i64) {
let c = continent.clamp(0, model.worldmap.continents.len() as i64) as u32;
let z = if c == 0 {
@@ -525,10 +640,30 @@ fn select(model: &mut Model, continent: i64, zone: i64) {
let n = model.worldmap.continents[c as usize - 1].zones.len() as i64;
zone.clamp(0, n) as u32
};
model.worldmap.selection = (c, z);
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
store_selection(model, (c, z), None);
}
/// Select an instance map directly — `0x4a67a0` reached with `ecx == -2`: the zone cell goes to
/// `-1` (our `0`) and the raw `WorldMapArea` row id lands in `[0x845074]` **verbatim and
/// unchecked**. We keep it unchecked for the same reason the reference can: every consumer
/// bound-checks on read ([`direct_row`]), so an id no row carries degrades to the nil map name
/// instead of corrupting the selection.
fn select_direct(model: &mut Model, wma_id: u32) {
store_selection(model, (0, 0), Some(wma_id));
}
/// Apply the resolver `0x4a6650`'s answer — the shared tail of `SetMapToCurrentZone` (`0x4a7e20`
/// is literally `call 0x4a6650`) and of the engine's own first-world-enter sync (`0x4947ac`, the
/// same function: there is no second resolver).
///
/// The orphan leg wins when it is set, and it is set only when no continent matched — the
/// reference reaches `0x4a66c3` exclusively through the continent loop's miss, so the two answers
/// are mutually exclusive by construction upstream and the order here is belt-and-braces.
fn select_resolved(model: &mut Model, continent: u32, zone: u32, direct_area: Option<u32>) {
match direct_area {
Some(id) => select_direct(model, id),
None => select(model, i64::from(continent), i64::from(zone)),
}
}
/// Register the world-map globals.
@@ -551,6 +686,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetMapZones(continent) → name1, name2, … (that continent's zone list; index = zone number).
// A NEGATIVE continent answers empty — `0x4a7d10`'s `dec edi; cmp edi,[0xb6e664]; jae` is an
// unsigned bound, so `GetCurrentMapContinent()`'s `-1` on an instance map takes the empty
// exit. That emptiness is load-bearing: it is what leaves `WorldMapZoneButton_OnClick` with
// no button to fire, and so what stops a `SetMapZoom(-1, id)` from writing a bogus row id
// into the direct cell (wow-re §5.4).
g.set(
"GetMapZones",
lua.create_function(|lua, c: i64| {
@@ -570,12 +710,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetCurrentMapContinent() / GetCurrentMapZone() — the selection halves (0 = world / whole
// continent). The reference reads them right after SetMapZoom in the same click.
// continent). The reference reads them right after SetMapZoom in the same click. Each is its
// cell `+ 1` and nothing else (`0x4a7ed0`, `0x4a7f00`: a straight-line fild/pushnumber), so
// the direct-area state answers **-1** (from the `-2` sentinel) and **0** (the zone cell it
// forces to `-1`). The `-1` is a value no stock path tests for, and benignly: it leaves
// `WorldMapFrame.lua:59`'s zoom-out button enabled, and the zone dropdown it would feed is
// empty because `GetMapZones(-1)` takes the unsigned-bound exit below.
g.set(
"GetCurrentMapContinent",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.worldmap.selection.0))
Ok(match model.worldmap.direct_area {
Some(_) => -1i64,
None => i64::from(model.worldmap.selection.0),
})
})?,
)?;
g.set(
@@ -596,28 +744,41 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetMapToCurrentZone() — the OnShow verb: jump to the player's zone (app-resolved feed).
// SetMapToCurrentZone() — the OnShow verb: jump to the player's own map (app-resolved feed).
// `0x4a7e20` is `call 0x4a6650; xor eax,eax; ret`, so this is the resolver's whole answer,
// orphan leg included — which is what puts a player standing in Warsong Gulch on the Warsong
// Gulch map. The stock battlefield minimap calls it itself on PLAYER_ENTERING_WORLD.
g.set(
"SetMapToCurrentZone",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (c, z) = model.worldmap.player_zone.unwrap_or((0, 0));
select(&mut model, i64::from(c), i64::from(z));
let direct = model.worldmap.player_direct_area;
select_resolved(&mut model, c, z, direct);
Ok(())
})?,
)?;
// GetMapInfo() → mapFileName (the Interface\WorldMap\<name>\ folder). World level → nil —
// the reference Lua's own `"World"` fallback exists because the client returned nil there
// (INFERRED, wow-re Q3 pending).
// (wow-re `worldmap-direct-area-selection.md` §5.1/§5.3).
//
// The name is `WorldMapArea` field[3], the **art-folder identifier** — "WarsongGulch", never
// the localized "Warsong Gulch": `0x4a6cf0` reads `[row+0xc]` with no locale multiplier, in
// deliberate contrast to `0x4a8740`'s `[rec + 4*[0xc0e080] + 0x2c]` AreaTable read. That is
// why FrameXML interpolates it straight into `Interface\WorldMap\<name>\<name>1..12`.
g.set(
"GetMapInfo",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let wm = &model.worldmap;
let file = match wm.selection {
(0, _) => None,
(c, z) => wm.continents.get(c as usize - 1).map(|cont| match z {
let file = match (wm.direct_area, wm.selection) {
// The direct-area state — an instance map, resolved by id through the orphan
// list. `0x4a6cf0`'s `jl 0x4a6d24` sends BOTH negative continents here and the
// `-1` world level answers nil only because its third cell is `-1` too.
(Some(_), _) => direct_row(wm).map(|row| row.map_file.clone()),
(None, (0, _)) => None,
(None, (c, z)) => wm.continents.get(c as usize - 1).map(|cont| match z {
0 => cont.map_file.clone(),
z => cont
.zones
@@ -626,11 +787,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.unwrap_or_else(|| cont.map_file.clone()),
}),
};
// THREE values, always. `0x4a7e30` has one `ret` and not a single conditional jump —
// there is no miss branch at all, the emptiness lives in two helpers — so the
// world-level answer is `nil, 0, 0` rather than one nil. (`arity_conf = exact`;
// decision 1845.) The two zeros are the map's texture dimensions, which no caller
// reads on the world level.
// THREE values, always: `0x4a7e30` pushes all three unconditionally and returns
// `mov eax,3` from its single `ret`, so the world-level answer is `nil, 0, 0` rather
// than one nil. (Its four conditional jumps all sit inside slot 3's power-of-two
// round and none gates a push — wow-re §10 corrects the "no conditional jump at all"
// absolute this comment used to carry, without touching the conclusion.
// `arity_conf = exact`; decision 1845.)
//
// The two zeros are the map art's texture dimensions, which no caller reads: both
// consumers bind slot 2 to a `textureHeight` local they never read again. They are
// **0 on every instance map** in the reference too — `0x4a6d50` scans
// `WorldMapContinent.dbc` for the row's mapID and that table ships only mapIDs
// {0, 1}, so the scan exhausts (§5.2).
let file = match file {
Some(f) => Value::String(lua.create_string(&f)?),
None => Value::Nil,
@@ -696,6 +864,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"ProcessMapClick",
lua.create_function(|lua, (x, y): (f32, f32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// An instance map swallows the click: the drill INSIDE `ProcessMapClick`
// (`0x4a7540`) opens `cmp esi,-2; je 0x4a760d`, a bare epilogue. Ahead of the world
// arm below, because the direct state shares its `continent == 0` cell and must not
// fall into the world sheet's continent walk.
if model.worldmap.direct_area.is_some() {
return Ok(());
}
if model.worldmap.selection.0 == 0 {
let hit = model.worldmap.continents.iter().position(|cont| {
let (u0, v0, u1, v1) = cont.world_rect;
@@ -793,9 +968,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetNumMapOverlays() — how many of the displayed zone's overlays are REVEALED by the
// GetNumMapOverlays() — how many of the displayed map's overlays are REVEALED by the
// explored bitset (the client's C side filters the same way; the reference Lua draws every
// returned overlay). 0 at world/continent level.
// returned overlay). 0 at world/continent level; an instance map is admitted under the same
// gate as a zone (see [`current_zone`]) — which on 5875 data means Alterac Valley's three
// rows and nothing for the other two battlegrounds.
g.set(
"GetNumMapOverlays",
lua.create_function(|lua, ()| {