diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index be113e05..22f7c3d1 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -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 { 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()?, )), diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index 0d90da21..33d9cf01 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -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 = 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. diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index 1d9dfab5..d0bb2904 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -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>, } /// 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() {