From ea7e0d087aa11998b8f497976946b58082688241 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:53:16 +0700 Subject: [PATCH] Zone names stay uncoloured on PvE realms outside capitals, as in 1.12.1 (#825) On a PvE realm, zone names were still coloured friendly, hostile or contested. In 1.12.1, `GetZonePVPInfo` (`0x48d540`) answers a type only when its display gate is open (`0x48d5c2`/`0x48d5cc`). The gate opens on the realm's PvP flag or in a zone with Flags `0x10`, which is the six capitals. The realm flag is read from `Cfg_Configs.dbc` for the selected realm's type and stored once per world entry (`0x4015de`). A type with no row skips the store. Otherwise the type is nil, so the stock zone splash shows cream and the minimap zone text gold. benilla now reads `Cfg_Configs.dbc` instead of a transcription that stopped at type 8, so realm type 10 lists as PvP. It latches the realm flag at world entry and applies the gate. Tests cover the gate, the six capitals and every shipped Cfg_Configs row; each failed before the fix. Fixes #816 Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/area.rs | 202 ++++++++++++++++-- crates/benilla-app/src/char_select/refresh.rs | 6 +- crates/benilla-app/src/realm_select/load.rs | 90 +++++--- crates/benilla-app/src/realm_select/mod.rs | 22 +- crates/benilla-app/src/realm_select/screen.rs | 2 +- .../src/ui_script/zone_text_tests.rs | 21 ++ crates/benilla-formats/src/cfg_configs.rs | 127 +++++++++++ crates/benilla-formats/src/lib.rs | 2 + crates/benilla-ui/src/script/zone_text.rs | 2 +- docs/MAP.md | 2 +- 10 files changed, 418 insertions(+), 58 deletions(-) create mode 100644 crates/benilla-formats/src/cfg_configs.rs diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index f511943a..9250f3b7 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -26,13 +26,13 @@ //! 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. //! -//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is -//! untraced; ours is always open. +//! `GetZonePVPInfo`'s type and faction show only on a PvP realm or in one of the six capitals +//! (zone Flags `0x10`); elsewhere they are nil and the stock zone texts stay uncoloured. use bevy::ecs::system::NonSendMut; use bevy::prelude::*; -use benilla_formats::AreaTableCatalog; +use benilla_formats::{AreaTableCatalog, AreaTableRow}; use benilla_ui::script::UiScript; use crate::net::{ObjectStore, SelfPlayer}; @@ -107,6 +107,7 @@ fn feed_zone_events( areas: Option>, factions: Option>, self_store: Query<&ObjectStore, With>, + realm_pvp: Option>, mut cache: Local>, ) { let (Some(mut script), Some(areas)) = (script, areas) else { @@ -193,26 +194,23 @@ fn feed_zone_events( 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 + let zone_row = areas.0.get(zone); + let template = 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(""))) - }); + .and_then(|(f, store)| f.catalog().template(store.0.unit_faction_template()?)) + .map(|t| (t.friend_group_mask, t.enemy_group_mask)); + let realm_pvp = realm_pvp.is_some_and(|r| r.0); + let (ty, is_arena) = zone_pvp_info(realm_pvp, leaf_row, zone_row, template); + // factionName is FactionGroup.dbc's Name for the zone's mask bit. + let pvp = ty.map(|ty| { + let mask = zone_row.map_or(0, |r| r.faction_group_mask); + let name = factions + .as_ref() + .and_then(|f| f.catalog().faction_group_name(mask)) + .unwrap_or(""); + (ty, name) + }); let pvp_type = pvp.map_or("", |(ty, _)| ty); script.set_zone_texts(benilla_ui::script::ZoneTexts { @@ -247,12 +245,70 @@ fn feed_zone_events( cache.wmo_group = wmo_group; } +/// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read +/// before any bail. The type is nil with no zone row, no faction template, or the display gate +/// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six +/// capitals (`0x48d5c2`/`0x48d5cc`). Open, it is the zone's FactionGroupMask against the template's +/// `(friend, enemy)` masks, "contested" when neither, never "arena". +fn zone_pvp_info( + realm_pvp: bool, + leaf: &AreaTableRow, + zone: Option<&AreaTableRow>, + template: Option<(u32, u32)>, +) -> (Option<&'static str>, bool) { + let is_arena = leaf.flags & 0x80 != 0; + let ty = zone + .filter(|z| realm_pvp || z.flags & 0x10 != 0) + .zip(template) + .map(|(z, (friend, enemy))| { + let mask = z.faction_group_mask; + if mask & friend != 0 { + "friendly" + } else if mask & enemy != 0 { + "hostile" + } else { + "contested" + } + }); + (ty, is_arena) +} + +/// `[0x88272c]`, the realm's PvP flag the display gate reads: the selected realm's +/// `PlayerKillingAllowed` in `Cfg_Configs.dbc`, stored once per world entry from the character +/// list (`0x4015de` in `0x401570`), so a `/reload` or a far teleport keeps it. +#[derive(Resource, Default)] +pub(crate) struct RealmPvp(bool); + +/// Latch [`RealmPvp`] from the realm this session entered the world on. +fn latch_realm_pvp( + mut latch: ResMut, + roster: Option>, + realms: Option>, +) { + let realm_type = roster.and_then(|r| r.realm.as_ref().map(|realm| realm.realm_type)); + let row = realm_type + .zip(realms) + .and_then(|(t, realms)| realms.row_pvp(t)); + latch.0 = latched_realm_pvp(latch.0, row); +} + +/// A realm type with no `Cfg_Configs.dbc` row skips the store (`0x4015d2`) and keeps the last +/// world entry's value, which the process starts at 0 (`.data`). +fn latched_realm_pvp(previous: bool, row_pvp: Option) -> bool { + row_pvp.unwrap_or(previous) +} + /// 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)) + app.init_resource::() + .add_systems(Startup, load_area_table.after(AssetSet::Open)) + .add_systems( + OnEnter(crate::char_select::ClientState::InWorld), + latch_realm_pvp, + ) .add_systems( Update, // After the leaf authority: leaf, indoor bit and names must come from one frame, @@ -329,6 +385,108 @@ mod tests { ); } + fn row(flags: u32, faction_group_mask: u32) -> AreaTableRow { + AreaTableRow { + map_id: 0, + zone_id: 0, + explore_flag: 0, + flags, + faction_group_mask, + exploration_level: 0, + name: String::new(), + } + } + + /// An Alliance player's template: friend Alliance (2), enemy Horde (4). + const ALLIANCE: Option<(u32, u32)> = Some((2, 4)); + + /// The display gate (`0x48d5c2`/`0x48d5cc`): a PvE realm outside a capital gets no type; a + /// capital's Flags `0x10` opens it there, and a PvP realm opens it everywhere. + #[test] + fn the_pvp_type_shows_on_a_pvp_realm_or_in_a_capital() { + let elwynn = row(0x40, 2); + let stormwind = row(0x138, 2); + let orgrimmar = row(0x138, 4); + let stranglethorn = row(0x40, 0); + let pvp = + |realm_pvp, zone: &AreaTableRow| zone_pvp_info(realm_pvp, zone, Some(zone), ALLIANCE).0; + + assert_eq!(pvp(false, &elwynn), None, "PvE realm, outside a capital"); + assert_eq!( + pvp(false, &stranglethorn), + None, + "PvE realm, ownerless zone" + ); + assert_eq!( + pvp(false, &stormwind), + Some("friendly"), + "PvE realm, own capital" + ); + assert_eq!( + pvp(false, &orgrimmar), + Some("hostile"), + "PvE realm, enemy capital" + ); + + assert_eq!(pvp(true, &elwynn), Some("friendly")); + assert_eq!(pvp(true, &stranglethorn), Some("contested")); + assert_eq!(pvp(true, &orgrimmar), Some("hostile")); + } + + /// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it. + #[test] + fn is_arena_survives_a_closed_gate() { + let battle_ring = row(0xd0, 0); + let stranglethorn = row(0x40, 0); + assert_eq!( + zone_pvp_info(false, &battle_ring, Some(&stranglethorn), ALLIANCE), + (None, true) + ); + assert_eq!( + zone_pvp_info(true, &battle_ring, Some(&stranglethorn), ALLIANCE), + (Some("contested"), true) + ); + assert_eq!( + zone_pvp_info(true, &battle_ring, Some(&stranglethorn), None), + (None, true), + "no faction template" + ); + } + + /// A realm type with no row keeps what the last world entry stored. + #[test] + fn a_realm_type_with_no_row_keeps_the_last_latch() { + assert!(!latched_realm_pvp(false, None)); + assert!(latched_realm_pvp(true, None)); + assert!(!latched_realm_pvp(true, Some(false))); + assert!(latched_realm_pvp(false, Some(true))); + } + + /// No zone row is a bail, not an ownerless zone. + #[test] + fn a_missing_zone_row_has_no_pvp_type() { + assert_eq!( + zone_pvp_info(true, &row(0, 0), None, ALLIANCE), + (None, false) + ); + } + + /// On the install's AreaTable the gate opens in exactly the six capitals for a PvE realm. + #[test] + fn the_six_capitals_are_the_zones_a_pve_realm_colours() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("chain"); + let cat = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"); + let open: Vec = (0..=u32::from(u16::MAX)) + .filter(|&id| { + cat.get(id).is_some_and(|r| { + r.zone_id == 0 && zone_pvp_info(false, r, Some(r), ALLIANCE).0.is_some() + }) + }) + .collect(); + assert_eq!(open, [1497, 1519, 1537, 1637, 1638, 1657]); + } + /// 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() { diff --git a/crates/benilla-app/src/char_select/refresh.rs b/crates/benilla-app/src/char_select/refresh.rs index e2987cb0..68ab0b02 100644 --- a/crates/benilla-app/src/char_select/refresh.rs +++ b/crates/benilla-app/src/char_select/refresh.rs @@ -18,6 +18,7 @@ use super::{class_name, Roster}; pub(super) fn refresh_list( roster: Res, areas: Option>, + realms: Option>, strings: Option>, status: Res, mut rows: Query<(&SelectAction, &mut Visibility), (With