Files
benilla-octo/crates/benilla-app/src/area.rs
T
gromandClaude Opus 5 d3d2103919 app: publish the world-map feed from the frame's own area
`SetMapToCurrentZone()` answers out of the `(continent, zone)` pair
`feed_world_map` pushes into the VM, because the VM cannot query the
world — the reference's resolver `0x4a6650` reads the player's area
live. That makes the schedule part of the verb's contract, and only
half of it was declared: the feed ordered `.after(UiInput)` and
nothing else, so it ran before `terrain_stream::update_current_area`
wrote the area under the player and published the previous frame's
zone.

At a crossing the feed therefore named the zone just left, `area`'s
`feed_zone_events` fired `ZONE_CHANGED_NEW_AREA` on the new one, and
both addons that answer it while the map is closed reselected where
the map already was. From grom's 03:57 log at the Ironforge gate:
`ZONE_CHANGED_NEW_AREA 10/10 handler(s) · MINIMAP_ZONE_CHANGED 8/8
handler(s) · the UI asked to follow the player 3× · WorldMapFrame
shown=Some(false)` — every handler reached, the verb called three
times, the selection unmoved. Every map-derived overlay then sits a
zone behind, `GetPlayerMapPosition` and so pfQuest's minimap pins
included, until the player opens the world map by hand (pfQuest
reselects on close), which is the workaround he reported.

Ordered after `AreaAuthoritySet`, whose own doc already required it of
every consumer of the area, and pinned by a schedule test over the
declared graph: remove the edge and it fails naming both systems.

The watchdog's zero-ask arm said "nothing has called
`SetMapToCurrentZone()` since the player crossed" through all of this.
The count was right and the window was not: it starts where this feed
first publishes the new zone, which the stale feed made *later* than
the addons' answer, so three real calls read as none. That one wrong
fork cost two sessions; the wording now names the window it measures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-23 04:22:49 -06:00

520 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The player's area identity for the UI: the `ZONE_CHANGED` event family and the host globals
//! behind `GetZoneText`, `GetSubZoneText`, `GetRealZoneText`, `GetMinimapZoneText` and
//! `GetZonePVPInfo`, all fired from the one updater `0x494780`.
//!
//! - A zone id change fires `ZONE_CHANGED_NEW_AREA` alone; else a text change fires
//! `ZONE_CHANGED_INDOORS` or `ZONE_CHANGED` by the indoor bit. The first world-enter is a zone
//! id change from the zeroed cache.
//! - 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 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.
//! - `GetRealZoneText` reads the zone name before the indoor override.
//! - `GetMinimapZoneText` is subzone-else-zone (`0xb4da28`), with its own `MINIMAP_ZONE_CHANGED`
//! (`0x494970`).
//!
//! On the first world-enter `0x494780` also sets the world map to the player's zone
//! (`0x4a6650`); that half lives in `crate::ui_world_map::world_enter_selection`.
//!
//! Host globals are written before the event fires, so a handler's `GetZoneText()` sees the new
//! state.
//!
//! 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.
//!
//! It is **not** what grom's 2026-10-22 stale-minimap report was, and nothing on this side was:
//! that run reproduced the report with the zone-text interior claim reading `outdoors` at the
//! gate, so the skip was never consulted. The handshake the fire site logs
//! (`fire_event_reach`, `frame_shown`) is what finally named the cause, in one run: the election
//! fired, every registered handler ran — 10/10 and 8/8 — and the UI asked to follow the player
//! 3× on the spot. The verb was answering out of a feed a frame behind, because
//! `ui_world_map::feed_world_map` was not ordered after the area authority. Fixed there, and
//! pinned by `game_plugins::schedule_tests`; the instrument stays, since it is the only thing
//! that can tell a dropped event from an answered one.
//!
//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is
//! untraced; ours is always open.
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
use benilla_formats::AreaTableCatalog;
use benilla_ui::script::UiScript;
use crate::net::{ObjectStore, SelfPlayer};
use crate::target::Factions;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
/// The shared `AreaTable.dbc` catalog, read by the world map and this module; absent if the DBC
/// failed to read.
#[derive(Resource)]
pub(crate) struct AreaTableRes(pub(crate) AreaTableCatalog);
fn load_area_table(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_area_table_catalog(&mut chain)
};
match loaded {
Ok(cat) => {
info!("area: {} rows in the shared AreaTable catalog", cat.len());
commands.insert_resource(AreaTableRes(cat));
}
Err(e) => warn!("area: AreaTable catalog failed to load: {e:#}"),
}
}
/// The client's zone-text cache: the compare targets of `0x494780`, the minimap line's cache
/// (`0x494970`) and the indoor dedup pair (`[0x868608]`, `[0x86860c]`). It lives per VM, so every
/// login starts zeroed and its first resolve is a NEW_AREA.
#[derive(Default)]
struct ZoneCache {
/// `None` until the first resolve.
zone_id: Option<u32>,
zone_text: String,
subzone_text: String,
minimap_text: String,
/// `[0x868608]`.
indoor: bool,
/// 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.
struct ZoneSignal {
zone_id: u32,
zone_text: String,
subzone_text: String,
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) {
return Some("ZONE_CHANGED_NEW_AREA");
}
if cache.zone_text != next.zone_text || cache.subzone_text != next.subzone_text {
return Some(if next.indoor {
"ZONE_CHANGED_INDOORS"
} else {
"ZONE_CHANGED"
});
}
None
}
/// Resolves the texts and PvP info, writes the host globals, then fires the elected zone event and,
/// independently, `MINIMAP_ZONE_CHANGED`.
fn feed_zone_events(
script: Option<NonSendMut<UiScript>>,
world: benilla_world::world_point::WorldPoint,
areas: Option<Res<AreaTableRes>>,
factions: Option<Res<Factions>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
mut cache: Local<crate::ui_script::VmMemo<ZoneCache>>,
) {
let (Some(mut script), Some(areas)) = (script, areas) else {
return;
};
let cache = cache.get(&script);
let Some(leaf) = world.area() else { return };
let Some(leaf_row) = areas.0.get(leaf) else {
// An unknown id keeps the last state, as the resolver does for areaId 0.
return;
};
let zone = if leaf_row.zone_id == 0 {
leaf
} else {
leaf_row.zone_id
};
let real_zone_text = areas.0.name(zone).unwrap_or_default().to_string();
let indoor = world.area_interior().is_some();
// The outdoor texts `0x67e670` starts from.
let mut zone_text = real_zone_text.clone();
let mut subzone_text = if leaf == zone {
String::new()
} else {
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 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();
}
}
// 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,
zone_text,
subzone_text,
indoor,
};
let minimap_text = if signal.subzone_text.is_empty() {
signal.zone_text.clone()
} else {
signal.subzone_text.clone()
};
let event = elect_event(cache, &signal);
let minimap_changed = cache.minimap_text != minimap_text;
if event.is_none() && !minimap_changed {
return;
}
// GetZonePVPInfo (`0x48d540`): isArena is the leaf's Flags `0x80`; the type is the zone's
// FactionGroupMask against the player template's friend then enemy masks, "contested" when
// neither; never "arena"; nil only on structural failure. factionName is FactionGroup.dbc's
// Name for the zone's mask bit; the realm type never enters.
let is_arena = leaf_row.flags & 0x80 != 0;
let zone_mask = areas.0.get(zone).map_or(0, |r| r.faction_group_mask);
let pvp = factions
.as_ref()
.zip(self_store.single().ok())
.and_then(|(f, store)| {
let tpl = f.catalog().template(store.0.unit_faction_template()?)?;
let ty = if zone_mask & tpl.friend_group_mask != 0 {
"friendly"
} else if zone_mask & tpl.enemy_group_mask != 0 {
"hostile"
} else {
"contested"
};
Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or("")))
});
let (pvp_type, pvp_faction) = pvp.unwrap_or(("", ""));
let globals = script.lua().globals();
let pushed = globals
.set("__benilla_zone_name", signal.zone_text.clone())
.and_then(|()| globals.set("__benilla_real_zone_name", real_zone_text))
.and_then(|()| globals.set("__benilla_subzone_name", signal.subzone_text.clone()))
.and_then(|()| globals.set("__benilla_zone_text", minimap_text.clone()))
.and_then(|()| globals.set("__benilla_pvp_type", pvp_type))
.and_then(|()| globals.set("__benilla_pvp_faction", pvp_faction))
.and_then(|()| globals.set("__benilla_pvp_arena", is_arena));
if let Err(e) = pushed {
warn!("area: zone host globals: {e}");
return;
}
// The whole handshake on one line, because a crossing is rare and this is the only place both
// halves of it are visible: what the engine elected, how many registered handlers each fire
// actually reached, and whether any of them answered by asking to follow the player
// (`SetMapToCurrentZone`, which seats every map-derived overlay — see `ui_world_map`'s
// watchdog, whose warning this line is the other half of). A crossing where the verb is never
// called leaves pfQuest's minimap pins a zone behind, and the three reasons it can go uncalled
// read apart here and nowhere else: `0/0` is nothing registered, `reached` below `registered`
// is a walk cut short, and every handler run with `0×` asked is the guard both answerers
// share — `shown=Some(true)` on a map the player cannot see is that guard shut
// (`pfQuest/map.lua:1466`, `pfUI/modules/minimap.lua:183`).
let asks_before = script.world_map_resolver_calls();
let zone_reach = event.map(|e| script.fire_event_reach(e, vec![]));
let minimap_reach =
minimap_changed.then(|| script.fire_event_reach("MINIMAP_ZONE_CHANGED", vec![]));
if let (true, Some(event)) = (reused_names, event) {
// 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 \u{2014} the terrain zone changed under it, now {:?}",
signal.zone_text
);
}
{
let reach = |r: Option<benilla_ui::script::EventReach>| match r {
Some(r) => format!(
"{}/{} handler(s){}",
r.reached,
r.registered,
match r.cut_at {
// The walk stopped here: every frame after this one in the registration order
// never heard the event, and this names the addon that took it out.
Some(name) => format!(" \u{2014} cut short at {name}"),
None => String::new(),
}
),
None => "not fired".to_string(),
};
info!(
"area: {} \u{b7} MINIMAP_ZONE_CHANGED {} \u{b7} zone {:?} / sub {:?} ({pvp_type}) \u{b7} \
the UI asked to follow the player {}\u{d7} \u{b7} WorldMapFrame shown={:?}",
match event {
Some(e) => format!("{e} {}", reach(zone_reach)),
None => "no zone event elected".to_string(),
},
reach(minimap_reach),
signal.zone_text,
signal.subzone_text,
script.world_map_resolver_calls().saturating_sub(asks_before),
script.frame_shown("WorldMapFrame"),
);
}
cache.zone_id = Some(signal.zone_id);
cache.zone_text = signal.zone_text;
cache.subzone_text = signal.subzone_text;
cache.minimap_text = minimap_text;
cache.indoor = signal.indoor;
cache.wmo_group = wmo_group;
}
/// The shared area catalog and the zone-event feed.
pub(crate) struct AreaPlugin;
impl Plugin for AreaPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_area_table.after(AssetSet::Open))
.add_systems(
Update,
// After the leaf authority: leaf, indoor bit and names must come from one frame,
// as in the client's single-pass resolve (`0x67e510`), or a stale leaf fires a
// spurious splash.
feed_zone_events
.after(crate::ui_script::UiInput)
.after(benilla_world::terrain_stream::AreaAuthoritySet)
// `ZONE_CHANGED*` is the signal pfQuest answers with `SetMapToCurrentZone()`
// (`pfQuest/map.lua:1466`), and that verb reads the zone
// `ui_world_map::feed_world_map` publishes. Unordered, the two could run
// either way round, and the wrong way reselects the zone the player just
// left — leaving every map-derived overlay a zone behind until something
// else reselects. See `WorldMapFeedSet`.
.after(crate::ui_world_map::WorldMapFeedSet),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(zone_id: u32, zone: &str, sub: &str, indoor: bool) -> ZoneSignal {
ZoneSignal {
zone_id,
zone_text: zone.into(),
subzone_text: sub.into(),
indoor,
}
}
fn cache(zone_id: Option<u32>, zone: &str, sub: &str) -> ZoneCache {
ZoneCache {
zone_id,
zone_text: zone.into(),
subzone_text: sub.into(),
..ZoneCache::default()
}
}
/// 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() {
let c = ZoneCache::default();
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Northshire Valley", false)),
Some("ZONE_CHANGED_NEW_AREA")
);
// Zone hop: NEW_AREA alone, though the texts changed too.
let c = cache(Some(12), "Elwynn Forest", "");
assert_eq!(
elect_event(&c, &sig(40, "Westfall", "The Jansen Stead", false)),
Some("ZONE_CHANGED_NEW_AREA")
);
let c = cache(Some(12), "Elwynn Forest", "");
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)),
Some("ZONE_CHANGED")
);
// Inn entry: the indoor override changed the zone text.
let c = cache(Some(12), "Elwynn Forest", "Goldshire");
assert_eq!(
elect_event(&c, &sig(12, "Lion's Pride Inn", "", true)),
Some("ZONE_CHANGED_INDOORS")
);
let c = cache(Some(12), "Lion's Pride Inn", "");
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)),
Some("ZONE_CHANGED")
);
let c = cache(Some(12), "Elwynn Forest", "Goldshire");
assert_eq!(
elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)),
None
);
}
/// The `0x67e670` override skip (abbey) and fire (inn) branches on the install's data.
#[test]
fn indoor_naming_matches_the_byte_law_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("chain");
let cat = benilla_formats::load_wmo_area_catalog(&mut chain).expect("WMOAreaTable");
// Abbey (WMO 59, name-set 1): the default name equals the yard subzone, so no override.
let default = cat.default_row(59, 1).expect("abbey default row");
assert_eq!(default.name, "Northshire Abbey");
let group = cat.group_row(59, 1, 1934).expect("abbey main hall row");
assert_eq!(group.name, "Main Hall");
assert_eq!(
group.area_table_id, 24,
"the leaf stays Northshire Abbey (24)"
);
// Rooms have distinct rows, so a room hop re-fires the dedup.
let library = cat.group_row(59, 1, 1943).expect("library row");
assert_ne!(group.id, library.id, "distinct rows exist per room");
let unnamed = cat.group_row(59, 1, 1935).expect("unnamed room row");
assert!(unnamed.name.is_empty());
// Goldshire inn (WMO 53, name-set 2): only a whole-WMO row, named unlike the street, so
// the override fires and the subzone stays nulled.
let inn = cat.default_row(53, 2).expect("the inn's whole-WMO row");
assert_eq!(inn.name, "Lion's Pride Inn");
assert_eq!(inn.area_table_id, 0);
assert!(
cat.group_row(53, 2, 0).is_none_or(|g| g.name.is_empty()),
"no named group rows — query B leaves the nulled subzone alone"
);
}
}