`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
520 lines
23 KiB
Rust
520 lines
23 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|