app: the same-interior skip must not swallow a zone change

grom's minimap report, found. Walking out through Ironforge's gate tunnel fires
no `ZONE_CHANGED_NEW_AREA`, so nothing tells pfQuest to call
`SetMapToCurrentZone()`, so `GetPlayerMapPosition` keeps projecting onto
Ironforge and every minimap pin sits wherever that lands — until the world map's
`OnShow` calls the verb itself, which is exactly "it fixes itself when I open and
close the map".

The zone updater's same-interior skip (`[0x868608]`, `[0x86860c]`) returned out
of the whole function while the player stayed inside one group of one building.
But the zone it elects on comes from the **terrain leaf**, and that can change
underneath an unbroken group: the gate tunnel spans the boundary, so the leaf
goes 1537 → 809 "Gates of Ironforge", whose zone is Dun Morogh, with the group
never changing. The election never ran, and the cache kept saying Ironforge for
as long as he stood there.

So the skip now covers the naming only: it carries the previous frame's names
forward explicitly and falls through. The texts then compare equal, so the only
thing that can elect is the zone id — which is the case that was being lost, and
nothing else changes. Three tests pin it, including the gate crossing itself and
that a different group of the same building still renames.

Read as a skip of the whole updater the reference would lose the "Dun Morogh"
splash on the way out, which it plainly does not, so this is recorded in the
module doc as a deviation chosen on that evidence rather than one traced out of
the binary. The dedup key is a separate question left open there: the doc claims
`(WMOID, MOGP uniqueID)` and the code has only the group's `WMOAreaTable` key.

Two log lines, because this was invisible from a log twice over: the zone-text
interior claim now prints on change (`sound::interior` prints the *render* claim,
a different ray, and the two disagree exactly at the aprons where it matters),
and an election that happens inside one unbroken group says so.

Measured first, against grom's own addons: `--fire ZONE_CHANGED_NEW_AREA` in the
probe harness has the loaded corpus call `SetMapToCurrentZone` 1× and move the
selection, which is what ruled the addon side out and left the client. `--fire`
is new to the harness for that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-22 18:21:11 -06:00
co-authored by Claude Opus 5
parent 9e13856cfd
commit 38fd2543b4
3 changed files with 165 additions and 36 deletions
+2 -1
View File
@@ -44,12 +44,13 @@ fn report_lines(label: &str, lines: &[String]) {
}
}
/// `--eval`, `--tick` and `--mouse` are one ordered list, run in the order typed.
/// `--eval`, `--tick`, `--mouse` and `--fire` are one ordered list, run in the order typed.
fn probe_steps(rest: &[String]) -> Vec<addon_harness::probe::Step> {
rest.iter()
.enumerate()
.filter_map(|(i, a)| match a.as_str() {
"--eval" => Some(addon_harness::probe::Step::Eval(rest.get(i + 1)?.clone())),
"--fire" => Some(addon_harness::probe::Step::Fire(rest.get(i + 1)?.clone())),
"--tick" => Some(addon_harness::probe::Step::Tick(
rest.get(i + 1)?.parse().ok()?,
)),
@@ -44,6 +44,11 @@ pub enum Step {
Mouse(f32, f32),
/// Advance one `OnUpdate` frame of `secs`; the answer is what the tick raised, or `no errors`.
Tick(f32),
/// Fire a global event into the VM, as the app's own feeds do. The answer is what the
/// handlers raised, plus how many of them answered by reselecting the world map — which
/// turns "did this event reach the addon that acts on it" into something a probe can see
/// without the app, the server or a player walking across a border.
Fire(String),
}
/// Load `name` out of `root` the way the survey does, drive the session start, then run each
@@ -165,6 +170,22 @@ fn run_steps(script: &mut UiScript, steps: &[Step]) -> Vec<(String, String)> {
},
)
}
Step::Fire(event) => {
let before = script.errors().len();
let asked = script.world_map_resolver_calls();
script.fire_event(event, vec![]);
let raised: Vec<String> = script.errors().into_iter().skip(before).collect();
let asks = script.world_map_resolver_calls() - asked;
let what = if raised.is_empty() {
"no errors".to_string()
} else {
format!("RAISED: {}", raised.join(" | "))
};
(
format!("--fire {event}"),
format!("= {what} \u{b7} SetMapToCurrentZone called {asks}\u{d7}"),
)
}
Step::Mouse(x, y) => {
// Resolve first: the hit-test reads resolved rects, which a new or moved frame
// lacks until then.
+142 -35
View File
@@ -8,8 +8,9 @@
//! - The zone is the leaf's single-hop parent (AreaTable field 2, itself when 0).
//! - The indoor bit is the faces-only down-ray (`0x6a87f0`): the surface below is a WMO face whose
//! group lacks MOGP `0x8` EXTERIOR.
//! - Indoor naming (`0x67e670`): while indoors in the same group the whole updater is skipped
//! (`[0x868608]`, `[0x86860c]`). On a change, the whole-WMO row (`0x69d830`) overrides the zone
//! - Indoor naming (`0x67e670`): while indoors in the same group the naming is skipped
//! (`[0x868608]`, `[0x86860c]`) and the previous frame's names stand. On a change, the
//! whole-WMO row (`0x69d830`) overrides the zone
//! slot and nulls the subzone when its name is non-empty and differs from the subzone; an
//! unnamed row resolves through its `AreaTableID`, a missing row never overrides. Then the
//! group's own row (`0x69d8f0`) refills the subzone. Both are exact-key lookups.
@@ -26,6 +27,16 @@
//! Deviation: the indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group
//! index, because the raw index can alias across adjacent buildings.
//!
//! Deviation: that same-interior skip covers the **naming** only, and the zone election still runs
//! against the terrain leaf. Read as a skip of the whole updater it swallows a zone change that
//! happens while the player stays inside one group — walking out through Ironforge's gate
//! tunnel, where the leaf goes 1537 → 809 "Gates of Ironforge" (zone Dun Morogh) under one
//! unbroken group. The reference cannot be doing that: it splashes "Dun Morogh" on the way out,
//! and `GetZoneText` is how it does so. Chosen on that evidence rather than traced, and it is the
//! whole of grom's 2026-10-22 report — `ZONE_CHANGED_NEW_AREA` never fired, so nothing told
//! pfQuest to reselect the map and every minimap pin kept projecting onto the zone behind
//! (`crate::ui_world_map`'s selection watchdog is what named it).
//!
//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is
//! untraced; ours is always open.
@@ -74,6 +85,11 @@ struct ZoneCache {
/// The previous hit group (`[0x86860c]`, keyed as the module doc's deviation says).
wmo_id: u32,
wmo_group: u32,
/// The zone-text interior claim last logged. The whole updater turns on this input and it is
/// the one thing about it nothing printed: `sound::interior` logs the *render* claim, a
/// different ray (under the character, no EXTERIOR exclusion), so the two disagree at exactly
/// the doorways and aprons where this matters. `None` until the first resolve.
claim_logged: Option<Option<(u32, u32)>>,
}
/// One resolve's compare inputs.
@@ -84,6 +100,17 @@ struct ZoneSignal {
indoor: bool,
}
/// The reference's same-interior skip (`[0x868608]`, `[0x86860c]`): the player is still inside the
/// group of the building the cache was filled from, so the indoor naming would come out the same
/// and is not redone. It says nothing about the terrain leaf, which can change zone underneath one
/// unbroken group; see the module doc's second deviation.
///
/// Takes the claim's two ids rather than the key struct, so this module's one named import of a
/// renderer type stays the one it already had (`tests/world_api_wall.rs`).
fn interior_naming_is_unchanged(cache: &ZoneCache, wmo_id: u32, group_area_id: u32) -> bool {
cache.indoor && group_area_id != 0 && (cache.wmo_id, cache.wmo_group) == (wmo_id, group_area_id)
}
/// The event election of `0x494780`.
fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> {
if cache.zone_id != Some(next.zone_id) {
@@ -134,46 +161,65 @@ fn feed_zone_events(
leaf_row.name.clone()
};
// One line per change of the claim, at the cadence `wmo_portal` already logs the camera's.
{
let claim = world.area_interior().map(|k| (k.wmo_id, k.group_area_id));
if cache.claim_logged != Some(claim) {
cache.claim_logged = Some(claim);
match claim {
Some((wmo, group)) => info!(
"area: zone-text interior \u{2192} wmo {wmo} group {group} (naming and the same-group skip read this)"
),
None => info!("area: zone-text interior \u{2192} outdoors"),
}
}
}
let mut wmo_group = 0u32;
let mut reused_names = false;
if let Some((k, group, default)) = world.area_interior_rows() {
// The client keys `[groupRec+0x7c]`, nonzero-gated; see the module doc's deviation.
wmo_group = k.group_area_id;
if cache.indoor
&& k.group_area_id != 0
&& (cache.wmo_id, cache.wmo_group) == (k.wmo_id, wmo_group)
{
return;
}
cache.wmo_id = k.wmo_id;
// The whole-WMO -1 row. An unnamed one with `AreaTableID` 0 takes the leaf's name, which
// is the terrain areaId the client's area-0 arm reads.
let a_name = default.as_ref().map(|d| {
if !d.name.is_empty() {
d.name.clone()
} else if d.area_table_id != 0 {
areas
.0
.name(d.area_table_id)
.unwrap_or_default()
.to_string()
} else {
leaf_row.name.clone()
if interior_naming_is_unchanged(cache, k.wmo_id, k.group_area_id) {
// The names the skip would have left standing, carried forward explicitly. Falling
// through with them — rather than returning — is what keeps a zone change
// under one group from being swallowed: the texts then compare equal, so only the
// zone id can elect.
zone_text = cache.zone_text.clone();
subzone_text = cache.subzone_text.clone();
reused_names = true;
} else {
cache.wmo_id = k.wmo_id;
// The whole-WMO -1 row. An unnamed one with `AreaTableID` 0 takes the leaf's name, which
// is the terrain areaId the client's area-0 arm reads.
let a_name = default.as_ref().map(|d| {
if !d.name.is_empty() {
d.name.clone()
} else if d.area_table_id != 0 {
areas
.0
.name(d.area_table_id)
.unwrap_or_default()
.to_string()
} else {
leaf_row.name.clone()
}
});
if let Some(a) = a_name.filter(|n| !n.is_empty()) {
if a != subzone_text {
zone_text = a;
subzone_text = String::new();
}
}
});
if let Some(a) = a_name.filter(|n| !n.is_empty()) {
if a != subzone_text {
zone_text = a;
subzone_text = String::new();
// The hit group's own row refills the subzone.
if let Some(b) = group
.as_ref()
.map(|r| r.name.as_str())
.filter(|n| !n.is_empty())
{
subzone_text = b.to_string();
}
}
// The hit group's own row refills the subzone.
if let Some(b) = group
.as_ref()
.map(|r| r.name.as_str())
.filter(|n| !n.is_empty())
{
subzone_text = b.to_string();
}
}
let signal = ZoneSignal {
zone_id: zone,
@@ -231,6 +277,14 @@ fn feed_zone_events(
if let Some(event) = event {
script.fire_event(event, vec![]);
if reused_names {
// Rare, and worth a line at INFO: the terrain leaf changed zone while the player
// stayed inside one interior group, which is the case the skip used to swallow.
info!(
"area: {event} inside one interior group — the terrain zone changed under it, now {:?}",
signal.zone_text
);
}
debug!(
"area: {event} → zone {:?} / sub {:?} ({pvp_type})",
signal.zone_text, signal.subzone_text
@@ -295,6 +349,59 @@ mod tests {
}
}
/// Inside one group of one building, the naming is the cache's: redoing it is the work the
/// reference's skip exists to avoid, and standing still must stay silent.
#[test]
fn standing_still_inside_one_interior_group_elects_nothing() {
let mut c = cache(Some(1537), "Ironforge", "The Great Forge");
c.indoor = true;
c.wmo_id = 1121;
c.wmo_group = 1537;
assert!(interior_naming_is_unchanged(&c, 1121, 1537));
// The texts come from the cache under the skip, so the signal differs in nothing.
let held = sig(1537, &c.zone_text, &c.subzone_text, true);
assert_eq!(elect_event(&c, &held), None);
}
/// Walking out through Ironforge's gate tunnel: one unbroken interior group, but the terrain
/// leaf under it goes 1537 to 809 "Gates of Ironforge", whose zone is Dun Morogh. The skip
/// must not swallow that — read as a skip of the whole updater it fires no
/// `ZONE_CHANGED_NEW_AREA`, so nothing tells an addon to reselect the world map and every
/// map-derived overlay stays on Ironforge. That was grom's 2026-10-22 minimap report.
#[test]
fn a_terrain_zone_change_under_one_interior_group_still_elects_new_area() {
let mut c = cache(Some(1537), "Ironforge", "The Great Forge");
c.indoor = true;
c.wmo_id = 1121;
c.wmo_group = 1537;
assert!(
interior_naming_is_unchanged(&c, 1121, 1537),
"same building, same group: the naming is reused"
);
// Reused names, new zone: exactly what the system builds on that path.
let crossed = sig(1, &c.zone_text, &c.subzone_text, true);
assert_eq!(
elect_event(&c, &crossed),
Some("ZONE_CHANGED_NEW_AREA"),
"the zone id changed under the group, and that is a new area"
);
}
/// The skip is per building *and* group: a different group of the same building redoes the
/// naming, so a doorway between two named rooms still relabels.
#[test]
fn a_different_group_of_the_same_building_is_not_the_same_interior() {
let mut c = cache(Some(1537), "Ironforge", "The Great Forge");
c.indoor = true;
c.wmo_id = 1121;
c.wmo_group = 1537;
assert!(!interior_naming_is_unchanged(&c, 1121, 1595));
// And an unkeyed group (`WMOGroupID` 0) is never a match, as the client's gate says.
assert!(!interior_naming_is_unchanged(&c, 1121, 0));
// Nor is the same group id in a different building.
assert!(!interior_naming_is_unchanged(&c, 1122, 1537));
}
/// The `0x494780` election.
#[test]
fn election_matches_the_byte_law() {