- Questie's arrow points at the turn-in, not 100 yards off: the map selection sits on your zone from the moment you enter the world, as the reference's does, so Astrolabe reads the right sheet (2192) - an area the map has no row for sends the selection to your continent, not the world view; the reference fails its zone lookup and its continent lookup separately - a tooltip shown with no lines hides itself instead of drawing an empty plate at the last hover's size, as Questie's tracker did on some quests; each fresh hover restores full alpha, so a plate an addon dimmed does not stay dim - Questie's minimap button turns the quest icons back on: a nil handed to a colour setter stores zero and carries on, as the reference does, where benilla raised and jammed Questie's event queue from login (1973) - MonkeyDev loads without three red dialogs at login: an unknown frame type in an addon's XML is logged and skipped, as the reference does; only a script creating a frame of a bad type still raises (2191) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1283 lines
58 KiB
Rust
1283 lines
58 KiB
Rust
//! The world-map data feed (decision 0203 phase 2) — the app half behind
|
||
//! stock `Interface\FrameXML\WorldMapFrame.xml` and benilla-ui's `script/worldmap.rs` bindings.
|
||
//!
|
||
//! Two systems, the quest-log seam shape:
|
||
//! - [`load_world_map_ui`] (once, when the chain + VM + Map.dbc catalog all exist): builds the
|
||
//! static **catalog** from `WorldMapArea` × `AreaTable` × `WorldMapContinent` × `Map` ×
|
||
//! the `.zmp` bitmaps and pushes it into the engine. Every ordering/naming rule is the
|
||
//! wow-re-verified one (Q1/Q3 verdicts, 2026-07-07): continents in WorldMapArea **file
|
||
//! order** (Kalimdor, then EK — the `0x4a5d00` builder's walk), displayed under their
|
||
//! `Map.dbc` localized names ("Eastern Kingdoms", not the art folder's "Azeroth"); zones
|
||
//! sorted case-insensitively by AreaTable localized name (the `0x4a6390` comparator's
|
||
//! `SStrCmpI`); the zone grids remapped from raw AreaTable ids by the client's one-hop parent
|
||
//! rollup + (mapId, areaId) match — here straight to 1-based zone indices.
|
||
//! - [`feed_world_map`] (every frame): reads the engine-owned selection back, projects the
|
||
//! player's world position onto the displayed map via [`benilla_world::map_proj`] (world sheet:
|
||
//! WorldMapContinent constants; continent/zone: the WorldMapArea rect lerp), resolves the
|
||
//! player's `(continent, zone)` from `CurrentArea` through the AreaTable parent chain, and
|
||
//! pushes the trio + facing. (The client matches its zone-level area global directly —
|
||
//! `0x4a6650`; our MCNK `CurrentArea` is the leaf sub-area, so the parent walk lands on the
|
||
//! same zone.) It also runs the **landmark pass** — the reference's `0x4a67a0` builder
|
||
//! ([`landmark_gates_pass`], decision 1586): the `AreaPOI.dbc` rows the displayed level admits,
|
||
//! then the guard-directions marker. That pass is keyed rather than per-frame ([`LandmarkKey`]),
|
||
//! because the reference rebuilds it on events, not on a clock.
|
||
//!
|
||
//! …and one dev affordance beside them, [`dev_map_jump`]: **Alt+click the map to go there.**
|
||
|
||
use bevy::ecs::system::NonSendMut;
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_assets::coords::bevy_to_wow;
|
||
use benilla_formats::{
|
||
load_world_map_area_catalog, load_world_map_continent_catalog, load_world_map_overlay_catalog,
|
||
load_zone_map, WorldMapArea,
|
||
};
|
||
use benilla_ui::script::{
|
||
UiScript, WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView,
|
||
};
|
||
|
||
use crate::net::{ObjectStore, SelfPlayer};
|
||
use crate::player::Player;
|
||
use crate::ui_script::UiInput;
|
||
use benilla_assets::MapCatalogRes;
|
||
use benilla_assets::{LockRecover, WorldAssets};
|
||
use benilla_world::map_proj::{self, WorldProj, ZoneRect};
|
||
use benilla_world::world_map::CurrentMap;
|
||
|
||
/// The app-side mirror of the pushed catalog — the projection data (rects + world-sheet
|
||
/// constants) per continent/zone, in the SAME order as the engine's copy (indices must agree).
|
||
/// The AreaTable itself is the shared [`crate::area::AreaTableRes`] (decision 0287).
|
||
#[derive(Resource)]
|
||
pub(crate) struct WorldMapUiData {
|
||
continents: Vec<ContinentEntry>,
|
||
}
|
||
|
||
pub(crate) struct ContinentEntry {
|
||
map_id: u32,
|
||
proj: Option<WorldProj>,
|
||
rect: ZoneRect,
|
||
zones: Vec<ZoneEntry>,
|
||
}
|
||
|
||
struct ZoneEntry {
|
||
area_id: u32,
|
||
rect: ZoneRect,
|
||
}
|
||
|
||
fn zone_rect(a: &WorldMapArea) -> ZoneRect {
|
||
ZoneRect {
|
||
left: a.loc_left,
|
||
right: a.loc_right,
|
||
top: a.loc_top,
|
||
bottom: a.loc_bottom,
|
||
}
|
||
}
|
||
|
||
/// Build the catalog off the patch chain — the pure half of [`load_world_map_ui`], split out
|
||
/// so a test can drive the REAL `WorldMapArea` × `AreaTable` × `WorldMapContinent` × `Map` ×
|
||
/// `.zmp` build rather than a hand-written stand-in. A fixture catalog can only ever prove the
|
||
/// engine's arithmetic; what the player hovers is this, and the two were never joined before
|
||
/// ([`super::ui_script::world_map_tests`]'s real-data hover).
|
||
///
|
||
/// `None` when a DBC fails to load — the caller disables the map window and says so.
|
||
pub(crate) fn build_catalog(
|
||
chain: &mut benilla_formats::Chain,
|
||
areas: &benilla_formats::AreaTableCatalog,
|
||
maps: &MapCatalogRes,
|
||
) -> Option<(Vec<WorldMapContinentView>, Vec<ContinentEntry>)> {
|
||
let loaded = load_world_map_area_catalog(&mut *chain).and_then(|wma| {
|
||
let wmc = load_world_map_continent_catalog(&mut *chain)?;
|
||
let wmo = load_world_map_overlay_catalog(&mut *chain)?;
|
||
Ok((wma, wmc, wmo))
|
||
});
|
||
let (wma, wmc, wmo) = match loaded {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
error!("world map: DBC load failed, map window disabled: {e:#}");
|
||
return None;
|
||
}
|
||
};
|
||
|
||
// Continents: the areaId==0 rows in WorldMapArea FILE order — the 0x4a5d00 builder's walk,
|
||
// which defines the Lua continent index (Kalimdor, then EK in 5875; wow-re Q1(d) verdict).
|
||
let cont_rows: Vec<(u32, &WorldMapArea)> = wma.iter().filter(|(_, a)| a.area_id == 0).collect();
|
||
|
||
let mut entries = Vec::with_capacity(cont_rows.len());
|
||
let mut views = Vec::with_capacity(cont_rows.len());
|
||
for (_, cont) in cont_rows {
|
||
// Zones: this continent's areaId!=0 rows, display-named from AreaTable, sorted
|
||
// case-insensitively by that name (the 0x4a6390 comparator's SStrCmpI — this order IS
|
||
// the Lua zone index). Instance rows (BGs) live on other map ids and drop out naturally.
|
||
let mut zones: Vec<(u32, &WorldMapArea, String)> = wma
|
||
.iter()
|
||
.filter(|(_, a)| a.map_id == cont.map_id && a.area_id != 0)
|
||
.map(|(id, a)| {
|
||
let name = areas.name(a.area_id).unwrap_or(a.name.as_str()).to_string();
|
||
(id, a, name)
|
||
})
|
||
.collect();
|
||
zones.sort_by_key(|(_, _, name)| name.to_lowercase());
|
||
|
||
let proj = wmc.get(cont.map_id).map(|c| WorldProj {
|
||
offset_u: c.offset_x,
|
||
offset_v: c.offset_y,
|
||
scale: c.scale,
|
||
});
|
||
// The continent's rect on the world sheet: the 0x4a5d00 kernel over the WorldMapContinent
|
||
// tile bounds (the world-level click's AABB — disjoint per continent, unlike the art
|
||
// rects).
|
||
let world_rect = wmc
|
||
.get(cont.map_id)
|
||
.zip(proj)
|
||
.map(|(c, p)| {
|
||
map_proj::continent_sheet_rect(
|
||
(
|
||
c.left_boundary,
|
||
c.right_boundary,
|
||
c.top_boundary,
|
||
c.bottom_boundary,
|
||
),
|
||
p,
|
||
)
|
||
})
|
||
.unwrap_or((0.0, 0.0, 0.0, 0.0));
|
||
let rect = zone_rect(cont);
|
||
|
||
// The continent's area bitmap, remapped from raw AreaTable ids to 1-based zone indices —
|
||
// the client's load-time remap (one-hop parent rollup, then the (mapId, areaId) match;
|
||
// wow-re Q1(b)), fused with its zone-index resolution since our engine consumes indices.
|
||
let zone_grid: Vec<u16> = match load_zone_map(&mut *chain, &cont.name) {
|
||
Ok(grid) => grid
|
||
.iter()
|
||
.map(|&raw| {
|
||
let mut area_id = raw;
|
||
if let Some(row) = areas.get(area_id) {
|
||
if row.zone_id != 0 {
|
||
area_id = row.zone_id; // one-hop rollup, verbatim (not a full walk)
|
||
}
|
||
}
|
||
zones
|
||
.iter()
|
||
.position(|(_, a, _)| a.area_id == area_id)
|
||
.map(|i| i as u16 + 1)
|
||
.unwrap_or(0)
|
||
})
|
||
.collect(),
|
||
Err(e) => {
|
||
// The client tolerates a missing bitmap (grid stays zero → hover/click inert).
|
||
warn!("world map: no zone bitmap for {}: {e:#}", cont.name);
|
||
Vec::new()
|
||
}
|
||
};
|
||
|
||
views.push(WorldMapContinentView {
|
||
// Display name = Map.dbc's localized MapName ("Eastern Kingdoms"), never the art
|
||
// folder (wow-re Q3(a)); the folder stays the map_file.
|
||
name: maps
|
||
.0
|
||
.name(cont.map_id)
|
||
.unwrap_or(cont.name.as_str())
|
||
.to_string(),
|
||
map_file: cont.name.clone(),
|
||
world_rect,
|
||
loc_rect: (cont.loc_left, cont.loc_right, cont.loc_top, cont.loc_bottom),
|
||
zone_grid,
|
||
zones: zones
|
||
.iter()
|
||
.map(|(wma_id, a, name)| WorldMapZoneView {
|
||
name: name.clone(),
|
||
area_id: a.area_id,
|
||
map_file: a.name.clone(),
|
||
loc_rect: (a.loc_left, a.loc_right, a.loc_top, a.loc_bottom),
|
||
// The zone's discovery overlays: WorldMapOverlay rows joined by the zone's
|
||
// WMA id, their reveal bits = each covered area's AreaTable exploreFlag.
|
||
overlays: wmo
|
||
.for_area(*wma_id)
|
||
.iter()
|
||
.map(|o| WorldMapOverlayView {
|
||
texture: format!("Interface\\WorldMap\\{}\\{}", a.name, o.texture_name),
|
||
width: o.texture_width,
|
||
height: o.texture_height,
|
||
offset_x: o.offset_x,
|
||
offset_y: o.offset_y,
|
||
explore_bits: o
|
||
.area_id
|
||
.iter()
|
||
.filter(|&&aid| aid != 0)
|
||
.filter_map(|&aid| areas.get(aid).map(|r| r.explore_flag))
|
||
.collect(),
|
||
hit_rect: (
|
||
o.hit_rect_top,
|
||
o.hit_rect_left,
|
||
o.hit_rect_bottom,
|
||
o.hit_rect_right,
|
||
),
|
||
// The zone-level hover's label inside that rect: the FIRST area
|
||
// slot's AreaTable name — `0x4a7fa0` reads `+0x8` only (wow-re
|
||
// 15b2a8ea §1d); a slot that resolves to no row makes the overlay
|
||
// invisible to the hover, never to the draw.
|
||
area_name: areas.name(o.area_id[0]).map(str::to_string),
|
||
})
|
||
.collect(),
|
||
})
|
||
.collect(),
|
||
});
|
||
entries.push(ContinentEntry {
|
||
map_id: cont.map_id,
|
||
proj,
|
||
rect,
|
||
zones: zones
|
||
.iter()
|
||
.map(|(_, a, _)| ZoneEntry {
|
||
area_id: a.area_id,
|
||
rect: zone_rect(a),
|
||
})
|
||
.collect(),
|
||
});
|
||
}
|
||
Some((views, entries))
|
||
}
|
||
|
||
/// Build + push the static catalog once the patch chain, the VM, and the Map.dbc catalog all
|
||
/// exist (Update-gated rather than Startup-ordered, like every feed that needs the script).
|
||
fn load_world_map_ui(
|
||
mut done: Local<crate::ui_script::VmMemo<bool>>,
|
||
script: Option<NonSendMut<UiScript>>,
|
||
world_assets: Option<ResMut<WorldAssets>>,
|
||
maps: Option<Res<MapCatalogRes>>,
|
||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||
mut commands: Commands,
|
||
) {
|
||
let (Some(mut script), Some(assets), Some(maps), Some(areas)) =
|
||
(script, world_assets, maps, areas)
|
||
else {
|
||
return;
|
||
};
|
||
// Once per **VM** (1290), not once per process: the catalog is static, the VM it is pushed
|
||
// into is not — a login builds a fresh one, and without this the map window has no continents.
|
||
if !done.claim(&script) {
|
||
return;
|
||
}
|
||
let areas = &areas.0;
|
||
|
||
let mut chain = assets.chain.lock_recover();
|
||
let built = build_catalog(&mut chain, areas, &maps);
|
||
drop(chain);
|
||
let Some((views, entries)) = built else {
|
||
return;
|
||
};
|
||
|
||
info!(
|
||
"world map: catalog — {} continents, {} zones",
|
||
views.len(),
|
||
views.iter().map(|c| c.zones.len()).sum::<usize>()
|
||
);
|
||
script.set_world_map_catalog(views);
|
||
commands.insert_resource(WorldMapUiData {
|
||
continents: entries,
|
||
});
|
||
}
|
||
|
||
/// Which of the map's three levels is displayed — the reference's `(continent, zone)` globals
|
||
/// `[0x84506c]`/`[0x845070]` reduced to the thing every landmark gate actually branches on.
|
||
///
|
||
/// The reference has a fourth, `ORPHAN` (`continent == -2`, a map selected directly rather than
|
||
/// through the continent/zone pair). We have no way to reach it: [`UiScript::world_map_selection`]
|
||
/// is a `(u32, u32)` pair with `0` for "whole", so a direct-area selection is unrepresentable.
|
||
/// Where the reference's gates test "zone **or** orphan", ours test zone — the same answer for
|
||
/// every state our map can be in.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
enum MapLevel {
|
||
/// Both continents on one sheet (the reference's `continent == -1`).
|
||
World,
|
||
/// One continent, no zone selected (`continent >= 0, zone == -1`).
|
||
Continent,
|
||
/// One zone (`continent >= 0, zone >= 0`).
|
||
Zone,
|
||
}
|
||
|
||
impl MapLevel {
|
||
/// Our 1-based `(continent, zone)` selection, `0` = "whole", mapped onto the reference's
|
||
/// `-1`-for-whole pair.
|
||
fn of(continent: u32, zone: u32) -> Self {
|
||
match (continent, zone) {
|
||
(0, _) => Self::World,
|
||
(_, 0) => Self::Continent,
|
||
_ => Self::Zone,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The inputs the landmark list is a pure function of — the rebuild edge.
|
||
///
|
||
/// The reference does not rebuild per frame: `0x4a67a0` runs from thirteen callers, and they are
|
||
/// exactly this set — a map selection change, a world-state push (`0x48fa0d`), a
|
||
/// `PLAYER_EXPLORED_ZONES` descriptor change (`0x4a6477`, registered as an UpdateFields callback
|
||
/// on byte offset `0xe6c`), and the three gossip-marker set/clear sites. Keying on them keeps our
|
||
/// pass event-driven the same way, which is what stops a 339-row walk and ~40 string clones from
|
||
/// running 60 times a second with nothing changed.
|
||
///
|
||
/// `explored` is held whole rather than hashed: it is 64 dwords, it is compared far more often
|
||
/// than it is stored, and a hash would trade an exact answer for a collision risk on the one
|
||
/// input that silently hides map icons when it is wrong.
|
||
#[derive(PartialEq, Eq)]
|
||
struct LandmarkKey {
|
||
selection: (u32, u32),
|
||
map: u32,
|
||
states: u64,
|
||
/// Owned rather than borrowed, and compared before it is ever cloned — see
|
||
/// [`LandmarkKey::matches`]. Cloning it every frame to build a throwaway key would have cost
|
||
/// the allocation this whole mechanism exists to avoid.
|
||
explored: Vec<u32>,
|
||
/// The guard-directions marker's identity — `(continent, pos, icon)`. Its position is held as
|
||
/// raw `f32` bits: "the same marker" here means the same wire packet, and the coordinates are
|
||
/// copied from it rather than computed, so a bit compare is the exact question.
|
||
marker: Option<(u32, [u32; 3], u32)>,
|
||
}
|
||
|
||
impl LandmarkKey {
|
||
/// Is this key still the current one? Takes `explored` by slice so the common case — nothing
|
||
/// changed — compares without allocating.
|
||
fn matches(
|
||
&self,
|
||
selection: (u32, u32),
|
||
map: u32,
|
||
states: u64,
|
||
explored: &[u32],
|
||
marker: Option<(u32, [u32; 3], u32)>,
|
||
) -> bool {
|
||
self.selection == selection
|
||
&& self.map == map
|
||
&& self.states == states
|
||
&& self.marker == marker
|
||
&& self.explored == explored
|
||
}
|
||
}
|
||
|
||
/// The builder's near-zero skip (`0x4a6868`/`0x4a687a`): a landmark whose projected UV is `0` on
|
||
/// **both** axes is dropped.
|
||
///
|
||
/// That is the level/continent filter, not a paranoia check. [`map_proj::zone_uv`] answers
|
||
/// `(0, 0)` for a position outside the displayed rect or on the wrong continent, and `world_uv`'s
|
||
/// caller answers `None` for a map with no `WorldMapContinent` row — so "did it project to zero"
|
||
/// IS "does this POI belong on the map that is showing". Both axes, never either: a POI genuinely
|
||
/// on the top-left edge of a rect (`u = 0`, `v = 0.5`) survives, as it does in the reference.
|
||
///
|
||
/// The epsilon is the binary's own `2.384e-7`.
|
||
fn is_degenerate(uv: (f32, f32)) -> bool {
|
||
const EPS: f32 = 2.384e-7;
|
||
uv.0.abs() < EPS && uv.1.abs() < EPS
|
||
}
|
||
|
||
/// The three gates `0x4a67a0`'s AreaPOI walk applies, in the reference's order (wow-re
|
||
/// `system/ui/scratch/gossip-poi-marker.md` §7 + §8.2).
|
||
///
|
||
/// 1. **The level flag** (`0x4a79b0`'s fall-through chain, `0x4a79cb`–`0x4a7a05`): a row must
|
||
/// carry `0x04` to show at zone level, `0x08` at continent level, and **both `0x10` and
|
||
/// `0x08`** at world level — the chain is not a switch, so the world leg falls through the
|
||
/// continent leg. The shipped data agrees and could have disagreed: of the eleven `Flags`
|
||
/// values in `AreaPOI.dbc`, the only two carrying `0x10` (`0x1d`, `0x98`) also carry `0x08`.
|
||
/// 2. **Exploration** (`0x4a6890`–`0x4a68f3`): a row whose `AreaID` is `> 0` (signed — `-1`
|
||
/// arrives here as `u32::MAX` and means continent-wide) must resolve in `AreaTable`, and if
|
||
/// that row's `ExplorationLevel` is `>= 0` the player must have discovered it. This is why
|
||
/// Stormwind's icon is on the map from the first login (its row is continent-wide) while a
|
||
/// tower in a zone you have never walked is not.
|
||
/// 3. **World state** (`0x4a6903`): a row carrying a `WorldStateID` shows only while
|
||
/// [`crate::world_state::WorldStates`] reads that key non-zero. This is the whole Eastern
|
||
/// Plaguelands tower mechanism — rows 1749–1777 are gated on states 2352–2379, one row per
|
||
/// (tower, owner, contested/progressing) combination, so the server flipping a state swaps
|
||
/// which icon exists rather than editing one.
|
||
///
|
||
/// **Not tested here, and deliberately:** `ContinentID`, `Importance`, `FactionID`, `Icon`. The
|
||
/// reference's loop reads none of them — the continent filter is the projection (see
|
||
/// [`is_degenerate`]), and `Importance` gates nothing on the world map at all.
|
||
fn landmark_gates_pass(
|
||
poi: &benilla_formats::AreaPoi,
|
||
level: MapLevel,
|
||
areas: &benilla_formats::AreaTableCatalog,
|
||
explored: &[u32],
|
||
world_states: &crate::world_state::WorldStates,
|
||
) -> bool {
|
||
let level_ok = match level {
|
||
MapLevel::Zone => poi.flags & 0x04 != 0,
|
||
MapLevel::Continent => poi.flags & 0x08 != 0,
|
||
MapLevel::World => poi.flags & 0x10 != 0 && poi.flags & 0x08 != 0,
|
||
};
|
||
if !level_ok {
|
||
return false;
|
||
}
|
||
// `AreaID > 0`, signed. A row the table does not carry cannot be tested for exploration, so
|
||
// it passes — the reference's own bounds check on `[0xc0e048]`/`[0xc0e04c]` guards the read,
|
||
// not the landmark. (Moot on 5875 data: all 171 gated rows resolve.)
|
||
if poi.area_id as i32 > 0 {
|
||
if let Some(area) = areas.get(poi.area_id) {
|
||
if area.exploration_level >= 0 && !explored_bit(explored, area.explore_flag) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
poi.world_state_id == 0 || world_states.get(poi.world_state_id) != 0
|
||
}
|
||
|
||
/// `PLAYER_EXPLORED_ZONES` bit `bit` — the reference's `0x4a9a40`, which indexes the descriptor
|
||
/// bitfield bytewise (`[base + bit/8] & (1 << (bit & 7))`). Our slots are the same bits as
|
||
/// little-endian dwords, so the dword form below is the same test.
|
||
fn explored_bit(explored: &[u32], bit: u32) -> bool {
|
||
let (slot, within) = (bit as usize / 32, bit % 32);
|
||
explored.get(slot).is_some_and(|w| w & (1 << within) != 0)
|
||
}
|
||
|
||
/// `GetMapLandmarkInfo`'s `textureIndex` leg (`0x4a8848`–`0x4a8877`): the row's own `Icon`, except
|
||
/// that at **zone** level a row without `Flags & 0x80` is substituted with the constant **15**.
|
||
///
|
||
/// The substitution and the world-state gate are one designed mechanism, and the shipped data
|
||
/// proves it: across all 339 rows, `Flags & 0x80` is set exactly when `WorldStateID != 0`. A
|
||
/// battleground node or an Eastern Plaguelands tower whose icon *means* something — Alliance vs
|
||
/// Horde vs contested — has to escape a substitution that would flatten all of them to one cell.
|
||
fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u32 {
|
||
/// The generic zone-level POI cell of `Interface\Minimap\POIIcons`.
|
||
const ZONE_SUBSTITUTE: u32 = 15;
|
||
match poi.flags & 0x80 != 0 || level != MapLevel::Zone {
|
||
true => poi.icon,
|
||
false => ZONE_SUBSTITUTE,
|
||
}
|
||
}
|
||
|
||
/// `SetMapToCurrentZone`'s resolver, whose two halves fail SEPARATELY — see the call site in
|
||
/// [`feed_world_map`] for the byte citation. `map_id` is the player's `Map.dbc` id; `top_zone` is
|
||
/// their current area walked up to its top-level zone, or `None` while the area feed has not
|
||
/// answered. The answer is the 1-based `(continent, zone)` the Lua selection verbs speak, with
|
||
/// **zone 0 meaning the continent map** — the reference's `SetMap(continent, −1)` as
|
||
/// `GetCurrentMapZone` reads it back.
|
||
fn resolve_player_zone(
|
||
data: &WorldMapUiData,
|
||
map_id: u32,
|
||
top_zone: Option<u32>,
|
||
) -> Option<(u32, u32)> {
|
||
let ci = data.continents.iter().position(|c| c.map_id == map_id)?;
|
||
let zone = top_zone
|
||
.and_then(|top| {
|
||
data.continents[ci]
|
||
.zones
|
||
.iter()
|
||
.position(|z| z.area_id == top)
|
||
})
|
||
.map_or(0, |zi| zi as u32 + 1);
|
||
Some((ci as u32 + 1, zone))
|
||
}
|
||
|
||
/// The engine-side world-enter sync's gate and answer — `0x494780`'s `old == 0` leg into the
|
||
/// resolver `0x4a6650`. `None` = do not sync this frame; `Some(sel)` = apply `sel` and never
|
||
/// sync again this session.
|
||
///
|
||
/// Two things it has to get right, both from the bytes:
|
||
///
|
||
/// - **The trigger is the zone id becoming known, not the frame count.** `0x67e510` bails before
|
||
/// `0x494780` whenever the resolved zone id is 0, so the reference's sync cannot fire before
|
||
/// the player's area is real. `top_zone.is_some()` is that same precondition; without it we
|
||
/// would sync on frame 1 to the bare continent and — since the reference never re-syncs — stay
|
||
/// there.
|
||
/// - **Every exit of `0x4a6650` writes the selection**, including its two failure legs
|
||
/// (`0x4a667e`/`0x4a670b` ⇒ `SetMap(−1, −1)`). So an unresolvable player — an instance map, no
|
||
/// continent row — still counts as synced, at the world view, rather than re-arming the gate
|
||
/// every frame. That is `player_zone.unwrap_or((0, 0))`: our `None` IS the reference's
|
||
/// `(−1, −1)`, which `GetCurrentMapContinent` reads back to Lua as `0`.
|
||
fn world_enter_selection(
|
||
synced: bool,
|
||
top_zone: Option<u32>,
|
||
player_zone: Option<(u32, u32)>,
|
||
) -> Option<(u32, u32)> {
|
||
(!synced && top_zone.is_some()).then(|| player_zone.unwrap_or((0, 0)))
|
||
}
|
||
|
||
/// Per frame: selection read-back → projection → feed push (see the module doc).
|
||
/// One projection law for every blip on the DISPLAYED map: world-sheet mode (selection
|
||
/// `(0, _)`) projects through the POSITION's own map's continent constants; continent and zone
|
||
/// mode through the selected rect, gated to that continent's map. Off-map — the wrong continent,
|
||
/// an instance map, outside the rect — is `None`, the reference's `(0, 0)` hide sentinel once it
|
||
/// reaches Lua.
|
||
pub(crate) fn project_on_displayed(
|
||
data: &WorldMapUiData,
|
||
selection: (u32, u32),
|
||
pos_map: u32,
|
||
px: f32,
|
||
py: f32,
|
||
) -> Option<(f32, f32)> {
|
||
match selection {
|
||
(0, _) => data
|
||
.continents
|
||
.iter()
|
||
.find(|cont| cont.map_id == pos_map)
|
||
.and_then(|cont| cont.proj)
|
||
.map(|p| map_proj::world_uv(p, px, py)),
|
||
(c, z) => data
|
||
.continents
|
||
.get(c as usize - 1)
|
||
.filter(|cont| cont.map_id == pos_map)
|
||
.and_then(|cont| match z {
|
||
0 => Some(cont.rect),
|
||
z => cont.zones.get(z as usize - 1).map(|zone| zone.rect),
|
||
})
|
||
.map(|rect| map_proj::zone_uv(rect, px, py)),
|
||
}
|
||
}
|
||
|
||
/// [`feed_world_map`]'s memos, bundled behind ONE [`crate::ui_script::VmMemo`] because the feed
|
||
/// sits at Bevy's system-parameter ceiling. Keeping the memo on the outside rather than on each
|
||
/// field is the point: a login is a new VM, and the whole bundle resets with it in one place —
|
||
/// there is no field that can be added later and quietly outlive the session it is memory about
|
||
/// (decision 1290).
|
||
#[derive(Default)]
|
||
struct FeedMemos {
|
||
/// The last pushed `PLAYER_EXPLORED_ZONES` bitset.
|
||
explored: Option<Vec<u32>>,
|
||
/// The inputs the last landmark rebuild was keyed on.
|
||
landmarks: Option<LandmarkKey>,
|
||
/// The reference's `old == 0` gate on the engine-side map sync — see the call site.
|
||
map_synced: bool,
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn feed_world_map(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
data: Option<Res<WorldMapUiData>>,
|
||
player: Res<Player>,
|
||
map: Option<Res<CurrentMap>>,
|
||
world: benilla_world::world_point::WorldPoint,
|
||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||
death_net: Res<crate::death::DeathNet>,
|
||
poi_marker: Res<crate::poi_marker::PoiMarker>,
|
||
// The party blips' two position sources (B320): the roster + the wire's per-member stats, and
|
||
// the streamed-entity index that beats them when a member is actually in the world with us.
|
||
group: Res<crate::ui_party::GroupState>,
|
||
guids: Res<crate::net::GuidIndex>,
|
||
unit_pos: Query<&GlobalTransform, With<crate::net::NetEntity>>,
|
||
pois: Option<Res<crate::area_poi::AreaPoiRes>>,
|
||
world_states: Res<crate::world_state::WorldStates>,
|
||
mut memo: Local<crate::ui_script::VmMemo<FeedMemos>>,
|
||
) {
|
||
let (Some(mut script), Some(data), Some(map), Some(areas)) = (script, data, map, areas) else {
|
||
return;
|
||
};
|
||
// The discovery bitset (PLAYER_EXPLORED_ZONES, PRIVATE — only our own avatar carries it):
|
||
// push on change (including the first stream-in); the engine setter queues the
|
||
// WORLD_MAP_UPDATE that fills newly explored art into an open zone map. It is also the
|
||
// landmark pass's exploration gate below, so it is resolved before either use.
|
||
let explored: Vec<u32> = self_q
|
||
.iter()
|
||
.next()
|
||
.map(|store| {
|
||
(0..benilla_protocol::messages::PLAYER_EXPLORED_ZONES_SLOTS)
|
||
.map(|i| store.0.player_explored_zone_slot(i))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
{
|
||
let memo = memo.get(&script);
|
||
if !explored.is_empty() && memo.explored.as_ref() != Some(&explored) {
|
||
memo.explored = Some(explored.clone());
|
||
script.set_world_map_explored(explored.clone());
|
||
}
|
||
}
|
||
let wow = bevy_to_wow(player.pos);
|
||
let (wx, wy) = (wow[0], wow[1]);
|
||
|
||
// The player's (continent, zone) — CurrentArea's MCNK areaId walked up the AreaTable parent
|
||
// chain to its top-level zone, matched against the displayed catalog (1-based indices).
|
||
//
|
||
// **The two halves fail SEPARATELY, and that is the reference's law, not a convenience.**
|
||
// `SetMapToCurrentZone 0x4a7e20`'s resolver `0x4a6650` matches the player's map id against
|
||
// each continent record's `+0x00`, then walks that record's sorted child-zone array for the
|
||
// zone whose WMA areaID equals the player's current-area global — and **"no zone match ⇒
|
||
// `SetMap(continent, −1)`"**, i.e. the CONTINENT map, with only "no continent match" falling
|
||
// through to the orphan list and then `SetMap(−1, −1)` = the world view (wow-re
|
||
// `system/ui/scratch/geometry.md` §"Worldmap data model"; `GetCurrentMapZone 0x4a7f00` is
|
||
// `DAT_00845070 + 1`, so that −1 reads back to Lua as **0**).
|
||
//
|
||
// We used to resolve both or neither, so a zone the catalog has no row for — and, before the
|
||
// area feed answers, a zone we simply do not know yet — slammed the selection to the WORLD
|
||
// view instead of the player's continent. That matters beyond the picture: at the world level
|
||
// `GetPlayerMapPosition` answers a world-SHEET uv (`0x4a7360`'s step-2 continent formula), and
|
||
// every consumer that assumes a zone uv — Astrolabe, and so every addon built on it — silently
|
||
// mis-scales it.
|
||
//
|
||
// NOT modelled, and deliberately: the `−2` orphan/direct-area leg for a map that is no
|
||
// continent's child (the instance maps). We have no orphan list, so those still land on the
|
||
// world view rather than the reference's direct-area selection.
|
||
let top_zone = world.area().and_then(|aid| areas.0.top_zone(aid));
|
||
let player_zone = resolve_player_zone(&data, map.0, top_zone);
|
||
|
||
// ── First world-enter: the ENGINE selects the player's zone, with no Lua in the loop.
|
||
//
|
||
// `0x494780` — the zone updater we already transcribe verbatim in `crate::area` for its
|
||
// ZONE_CHANGED event election — carries one more effect that election note called "an
|
||
// unrelated side call": when the cached zone id was **0** (the zeroed BSS, i.e. the first
|
||
// world-enter of the session) it also calls `0x4a6650`, the same player→(continent, zone)
|
||
// resolver `SetMapToCurrentZone` uses, and hands it to the `SetMap` setter `0x4a67a0`. So a
|
||
// freshly-logged-in reference client is ALREADY on the player's own zone map before a single
|
||
// line of FrameXML or addon Lua has asked for it.
|
||
//
|
||
// We modelled the election and dropped the side call, so our selection stayed at the world
|
||
// level until something opened the map. That is not cosmetic: at the world level
|
||
// `GetPlayerMapPosition` answers a world-SHEET uv (`0x4a7360` step 2) instead of a zone uv,
|
||
// and every addon built on Astrolabe scales it with the *zone's* yard dimensions — Questie's
|
||
// quest arrow pointed ~100 yards off the turn-in, on a client where the reference is exact.
|
||
// Astrolabe's own rescue cannot save it: that is gated on `(x <= 0 and y <= 0)`, and a
|
||
// world-sheet uv is a perfectly non-zero pair.
|
||
//
|
||
// **The gate is the zone id becoming known, not the frame count.** The reference reaches
|
||
// `0x494780` only with a nonzero resolved zone (`0x67e510` bails on 0), so its sync cannot
|
||
// fire before the player's area is real; ours must not either, or `resolve_player_zone`
|
||
// answers the bare continent and nothing re-asks. `top_zone.is_some()` is that same
|
||
// precondition. Per **VM**, not per process: a new login is a new session's first enter.
|
||
{
|
||
let memo = memo.get(&script);
|
||
if let Some((c, z)) = world_enter_selection(memo.map_synced, top_zone, player_zone) {
|
||
memo.map_synced = true;
|
||
script.sync_world_map_to_player_zone(c, z);
|
||
}
|
||
}
|
||
|
||
// The player's UV on the DISPLAYED map. Off-map (wrong continent, outside the rect, or an
|
||
// instance map) resolves to None/(0,0) — the reference's hide-the-blip sentinel.
|
||
let (c, z) = script.world_map_selection();
|
||
// One projection law for every blip on the displayed map (the player now, the corpse below,
|
||
// the battleground teammates in `ui_battlefield_positions`): [`project_on_displayed`].
|
||
let project =
|
||
|pos_map: u32, px: f32, py: f32| project_on_displayed(&data, (c, z), pos_map, px, py);
|
||
let uv = project(map.0, wx, wy);
|
||
// The corpse marker (decision 0308 §5): the query answer's DISPLAY position/map (a dungeon
|
||
// corpse projects at its entrance — the server rewrote it). `zone_uv`'s outside-the-rect
|
||
// (0,0) and the None here both land on the reference's hide sentinel.
|
||
let corpse_uv = death_net.corpse.and_then(|cp| {
|
||
project(
|
||
u32::try_from(cp.display_map).unwrap_or(u32::MAX),
|
||
cp.position[0],
|
||
cp.position[1],
|
||
)
|
||
});
|
||
|
||
// ── The map's POI icons (`GetNumMapLandmarks`) — the reference's landmark-list builder
|
||
// `0x4a67a0`, rebuilt only when one of its inputs moves (see [`LandmarkKey`]).
|
||
let marker = poi_marker
|
||
.on_map(map.0)
|
||
.map(|m| (m.continent_id, m.pos.map(f32::to_bits), m.icon));
|
||
let states_gen = world_states.generation();
|
||
let unchanged = memo
|
||
.get(&script)
|
||
.landmarks
|
||
.as_ref()
|
||
.is_some_and(|k| k.matches((c, z), map.0, states_gen, &explored, marker));
|
||
if !unchanged {
|
||
let level = MapLevel::of(c, z);
|
||
let mut landmarks = Vec::new();
|
||
// The DBC rows first, in file order, each through the builder's gate chain — that order
|
||
// IS the Lua landmark index (`0x4a6819`'s walk over `[0xc0e054]`).
|
||
if let Some(pois) = pois.as_ref() {
|
||
for (_, poi) in pois.0.rows() {
|
||
if !landmark_gates_pass(poi, level, &areas.0, &explored, &world_states) {
|
||
continue;
|
||
}
|
||
let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) else {
|
||
continue;
|
||
};
|
||
if is_degenerate(uv) {
|
||
continue;
|
||
}
|
||
landmarks.push(WorldMapLandmarkView {
|
||
name: poi.name.clone(),
|
||
description: poi.description.clone(),
|
||
texture_index: landmark_texture_index(poi, level),
|
||
uv,
|
||
});
|
||
}
|
||
}
|
||
// Then the guard-directions marker (`crate::poi_marker`), appended last exactly as
|
||
// `0x4a69c7` does and — like it — exempt from every gate above: no level flag, no
|
||
// exploration bit, no world state, its `Icon` verbatim with no level-15 substitution
|
||
// (wow-re `gossip-poi-marker.md` §8, the `+0x10 == 1` element). Only the same
|
||
// non-degenerate projection test applies.
|
||
if let Some(poi) = poi_marker.on_map(map.0) {
|
||
if let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) {
|
||
if !is_degenerate(uv) {
|
||
landmarks.push(WorldMapLandmarkView {
|
||
name: poi.name.clone(),
|
||
description: poi.description.clone(),
|
||
texture_index: poi.icon,
|
||
uv,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
memo.get(&script).landmarks = Some(LandmarkKey {
|
||
selection: (c, z),
|
||
map: map.0,
|
||
states: states_gen,
|
||
explored: explored.clone(),
|
||
marker,
|
||
});
|
||
script.set_world_map_landmarks(landmarks);
|
||
}
|
||
|
||
// The party slots' blips (report B320) — `party1..4` in the same order the frames and the
|
||
// unit tokens use (`GroupState::party_slots`).
|
||
//
|
||
// Two position sources, the minimap's own law (`minimap::blips::party_member_pos`): a member
|
||
// who is STREAMED has a real transform, and anyone else has the `(x, y)` their
|
||
// `SMSG_PARTY_MEMBER_STATS` carried — an `i16` pair, so a far member's blip is yard-accurate
|
||
// and no better, which is all the reference has for them either.
|
||
//
|
||
// Which MAP that position belongs to is the part the stats packet does not say: it carries a
|
||
// zone, not a map id. So the member's zone is looked up in the catalog and the continent that
|
||
// owns it supplies the map; a zone we cannot place (an instance, a zone missing from the
|
||
// catalog) falls back to ours, which is right for the overwhelmingly common case of a party
|
||
// spread across one continent and merely projects off-rect — the (0,0) hide — when it is not.
|
||
let member_uv = |m: &benilla_protocol::messages::GroupMemberEntry| {
|
||
let (px, py) = crate::minimap::party_member_pos(m, &group, &guids, &unit_pos)?;
|
||
let member_map = group
|
||
.stats
|
||
.get(&m.guid)
|
||
.and_then(|st| st.zone)
|
||
.and_then(|zone| {
|
||
data.continents
|
||
.iter()
|
||
.find(|cont| cont.zones.iter().any(|z| z.area_id == u32::from(zone)))
|
||
.map(|cont| cont.map_id)
|
||
})
|
||
.unwrap_or(map.0);
|
||
project(member_map, px, py).filter(|uv| *uv != (0.0, 0.0))
|
||
};
|
||
let party_uv: Vec<Option<(f32, f32)>> = group.party_slots().map(member_uv).collect();
|
||
|
||
// The raid roster's blips (1980, the stock map's `raid1..raid40` arm over `WorldMapRaid1..40`):
|
||
// the order the RaidFrame's own roster uses — ourselves first, then the members as listed
|
||
// (`ui_party::feed::raid_roster`) — so `raidN` here is `raidN` there. The reference skips
|
||
// `UnitIsUnit(unit, "player")` in Lua, so our own slot carries the player's UV rather than a
|
||
// hole that would shift every index after it.
|
||
let raid_uv: Vec<Option<(f32, f32)>> = if group.group_type == crate::ui_party::GROUPTYPE_RAID {
|
||
std::iter::once(uv)
|
||
.chain(group.members.iter().map(member_uv))
|
||
.collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
script.set_world_map_feed(
|
||
player_zone,
|
||
uv,
|
||
player.facing(),
|
||
corpse_uv,
|
||
party_uv,
|
||
raid_uv,
|
||
);
|
||
}
|
||
|
||
/// **Alt+click the world map to go there** — the dev jump.
|
||
///
|
||
/// The inverse of the blip projection above: the click's UV inside the map art
|
||
/// ([`UiScript::world_map_uv_at`], the reference's own normalization) run back through the
|
||
/// displayed rect to a world `(x, y)`, sent as vmangos's `.go xy x y <mapid>` — the **no-Z** form,
|
||
/// so the server resolves the ground (`GetWaterOrGroundLevel`) instead of us guessing a height off
|
||
/// a 2-D sheet. A jump to another continent's sheet is a cross-map worldport, handled like any
|
||
/// other ([`crate::player::wire_in`]).
|
||
///
|
||
/// **Zone and continent sheets only.** At the *world* level (both continents on one sheet) the
|
||
/// client's own UV→world law `0x4a7100` is not the inverse of its world→UV law — a confirmed,
|
||
/// reproduced anomaly (wow-re Q2 verdict; see [`map_proj::world_click_world`]) — so a click there
|
||
/// would land somewhere real but wrong by ~1.33× the sheet offset. Rather than invent a corrected
|
||
/// inverse the reference doesn't have, the jump declines and says to zoom in first.
|
||
///
|
||
/// The click is NOT consumed: the faithful path (`WorldMapButton_OnClick` → `ProcessMapClick`)
|
||
/// also runs and drills into the clicked zone, which is what you want anyway — you arrive, and the
|
||
/// map is showing where you arrived. Adding a modifier fork to the reference's click law to
|
||
/// suppress that would be a dev affordance rewriting a faithful one.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn dev_map_jump(
|
||
buttons: Res<ButtonInput<MouseButton>>,
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||
script: Option<NonSendMut<UiScript>>,
|
||
data: Option<Res<WorldMapUiData>>,
|
||
net: Res<crate::net::NetCommands>,
|
||
) {
|
||
// A dev affordance living in a gameplay module — it names no dev root, so nothing about it
|
||
// fails to compile in a player build, and it shipped in 1174's (decision 1179). Alt-click is
|
||
// free-fly's closest sibling: it moves the player's body across the continent.
|
||
if !crate::run_mode::dev_affordances() {
|
||
return;
|
||
}
|
||
let alt = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);
|
||
if !alt || !buttons.just_pressed(MouseButton::Left) {
|
||
return;
|
||
}
|
||
let (Some(script), Some(data), Ok(window)) = (script, data, windows.single()) else {
|
||
return;
|
||
};
|
||
let Some(cursor) = window.cursor_position() else {
|
||
return;
|
||
};
|
||
// Window px (y-down) → the VM's y-up 768-virtual units, exactly as the pointer feed converts
|
||
// them (`ui_script::input`) — `world_map_uv_at` hit-tests in that space.
|
||
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
|
||
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
|
||
else {
|
||
return;
|
||
};
|
||
let (c, z) = script.world_map_selection();
|
||
let Some(cont) = c
|
||
.checked_sub(1)
|
||
.and_then(|i| data.continents.get(i as usize))
|
||
else {
|
||
info!(
|
||
"map-jump: no exact click→world law at the world level — zoom into a continent first"
|
||
);
|
||
return;
|
||
};
|
||
let rect = match z.checked_sub(1) {
|
||
None => cont.rect,
|
||
Some(i) => match cont.zones.get(i as usize) {
|
||
Some(zone) => zone.rect,
|
||
None => return,
|
||
},
|
||
};
|
||
// `zone_world` lerps BOTH axes by its single `t` (the binary's own shape) — so it is called
|
||
// once per axis, which is how the reference's own callers consume it.
|
||
let (_, wy) = map_proj::zone_world(rect, u);
|
||
let (wx, _) = map_proj::zone_world(rect, v);
|
||
let text = format!(".go xy {wx:.2} {wy:.2} {}", cont.map_id);
|
||
info!("map-jump: {text}");
|
||
let _ = net.0.send(crate::net::ClientCommand::Chat {
|
||
kind: crate::net::ChatKind::Say,
|
||
target: None,
|
||
text,
|
||
});
|
||
}
|
||
|
||
/// The world-map data feed (decision 0203 phase 2) — see the module doc.
|
||
pub(crate) struct WorldMapUiPlugin;
|
||
|
||
impl Plugin for WorldMapUiPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.add_systems(
|
||
Update,
|
||
(
|
||
load_world_map_ui,
|
||
// After the script tick (UiInput), like the minimap's zone feed: the projection
|
||
// for a selection changed THIS tick lands next tick — invisible at frame rate.
|
||
feed_world_map.after(UiInput),
|
||
// Same slot, and for the same reason from the other side: the jump hit-tests
|
||
// against the frame rects THIS tick's resolve produced.
|
||
dev_map_jump.after(UiInput),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::world_state::WorldStates;
|
||
use benilla_formats::AreaPoi;
|
||
|
||
/// A bare row; each test sets only the columns its gate reads.
|
||
fn poi(flags: u32, area_id: u32, world_state_id: u32) -> AreaPoi {
|
||
AreaPoi {
|
||
importance: 0,
|
||
icon: 4,
|
||
faction_id: 0,
|
||
pos: [0.0, 0.0, 0.0],
|
||
continent_id: 0,
|
||
flags,
|
||
area_id,
|
||
name: String::new(),
|
||
description: String::new(),
|
||
world_state_id,
|
||
}
|
||
}
|
||
|
||
fn empty_areas() -> benilla_formats::AreaTableCatalog {
|
||
benilla_formats::AreaTableCatalog::from_rows(Vec::new())
|
||
}
|
||
|
||
/// The level-flag chain, in the shape §7 says it has: `0x04` for zone, `0x08` for continent,
|
||
/// and world demanding **both** `0x10` and `0x08` because the tests fall through rather than
|
||
/// switch. A row with `0x10` alone is invisible everywhere — the case the shipped data can't
|
||
/// exhibit (no 5875 row sets `0x10` without `0x08`) and the one a switch would get wrong.
|
||
#[test]
|
||
fn the_level_flag_chain_falls_through_at_world_level() {
|
||
let (areas, states, explored) = (empty_areas(), WorldStates::default(), Vec::new());
|
||
let pass = |flags, level| {
|
||
landmark_gates_pass(&poi(flags, 0, 0), level, &areas, &explored, &states)
|
||
};
|
||
|
||
// A town (0x0d = 0x01|0x04|0x08): zone and continent, not the world sheet.
|
||
assert!(pass(0x0d, MapLevel::Zone));
|
||
assert!(pass(0x0d, MapLevel::Continent));
|
||
assert!(!pass(0x0d, MapLevel::World));
|
||
|
||
// A capital (0x1d = 0x0d|0x10): all three.
|
||
assert!(pass(0x1d, MapLevel::Zone));
|
||
assert!(pass(0x1d, MapLevel::Continent));
|
||
assert!(pass(0x1d, MapLevel::World));
|
||
|
||
// An Eastern Plaguelands tower (0x87 = 0x01|0x02|0x04|0x80): zone only.
|
||
assert!(pass(0x87, MapLevel::Zone));
|
||
assert!(!pass(0x87, MapLevel::Continent));
|
||
assert!(!pass(0x87, MapLevel::World));
|
||
|
||
// `0x10` alone: the fall-through means world level still wants `0x08`, so nowhere.
|
||
assert!(!pass(0x10, MapLevel::World));
|
||
assert!(!pass(0x10, MapLevel::Continent));
|
||
assert!(!pass(0x10, MapLevel::Zone));
|
||
}
|
||
|
||
/// The world-state gate — the Eastern Plaguelands tower mechanism. A row with no
|
||
/// `WorldStateID` is ungated; a row with one appears exactly while that key reads non-zero,
|
||
/// so flipping the state swaps which of the sibling rows exists.
|
||
#[test]
|
||
fn a_world_state_row_appears_only_while_its_key_is_set() {
|
||
let (areas, explored) = (empty_areas(), Vec::new());
|
||
let mut states = WorldStates::default();
|
||
// 2372 / 2373 are the real pair for Northpass Tower: Alliance-held / Horde-held.
|
||
let alliance = poi(0x87, 0, 2372);
|
||
let horde = poi(0x87, 0, 2373);
|
||
let plain = poi(0x87, 0, 0);
|
||
let pass = |p: &AreaPoi, st: &WorldStates| {
|
||
landmark_gates_pass(p, MapLevel::Zone, &areas, &explored, st)
|
||
};
|
||
|
||
assert!(
|
||
pass(&plain, &states),
|
||
"an ungated row is always a candidate"
|
||
);
|
||
assert!(!pass(&alliance, &states), "nothing received yet");
|
||
assert!(!pass(&horde, &states));
|
||
|
||
states.write(&[(2372, 1)]);
|
||
assert!(pass(&alliance, &states));
|
||
assert!(!pass(&horde, &states));
|
||
|
||
// The server flips the tower: the Alliance row's state goes to 0, the Horde row's to 1.
|
||
states.write(&[(2372, 0), (2373, 1)]);
|
||
assert!(!pass(&alliance, &states));
|
||
assert!(pass(&horde, &states));
|
||
}
|
||
|
||
/// The exploration gate and its two exemptions: a continent-wide row (`AreaID` `-1`, which
|
||
/// arrives as `u32::MAX`) and a row whose area has `ExplorationLevel < 0` are never gated;
|
||
/// everything else waits for the bit.
|
||
#[test]
|
||
fn the_exploration_gate_reads_the_right_bit_and_exempts_the_right_rows() {
|
||
use benilla_formats::AreaTableRow;
|
||
let row = |explore_flag, exploration_level| AreaTableRow {
|
||
map_id: 0,
|
||
zone_id: 0,
|
||
explore_flag,
|
||
flags: 0,
|
||
faction_group_mask: 0,
|
||
exploration_level,
|
||
name: String::new(),
|
||
};
|
||
let areas = benilla_formats::AreaTableCatalog::from_rows(vec![
|
||
(139, row(40, 0)), // gated
|
||
(200, row(41, -1)), // exempt by ExplorationLevel
|
||
]);
|
||
let states = WorldStates::default();
|
||
// Bit 40 → slot 1, bit 8 within it.
|
||
let unexplored = vec![0u32; 4];
|
||
let explored = {
|
||
let mut v = vec![0u32; 4];
|
||
v[1] = 1 << 8;
|
||
v
|
||
};
|
||
let pass =
|
||
|p: &AreaPoi, ex: &[u32]| landmark_gates_pass(p, MapLevel::Zone, &areas, ex, &states);
|
||
|
||
assert!(!pass(&poi(0x04, 139, 0), &unexplored), "not discovered yet");
|
||
assert!(pass(&poi(0x04, 139, 0), &explored));
|
||
assert!(
|
||
pass(&poi(0x04, 200, 0), &unexplored),
|
||
"ExplorationLevel -1 exempts the row"
|
||
);
|
||
assert!(
|
||
pass(&poi(0x04, u32::MAX, 0), &unexplored),
|
||
"AreaID -1 (continent-wide) is not > 0, so no gate"
|
||
);
|
||
assert!(pass(&poi(0x04, 0, 0), &unexplored), "AreaID 0 likewise");
|
||
assert!(
|
||
pass(&poi(0x04, 999, 0), &unexplored),
|
||
"an AreaID the table does not carry cannot be tested"
|
||
);
|
||
}
|
||
|
||
/// `GetMapLandmarkInfo`'s texture leg: the level-15 substitution bites only at zone level and
|
||
/// only on a row without `Flags & 0x80` — which is exactly the rows with no live world state.
|
||
#[test]
|
||
fn the_zone_level_icon_substitution_spares_the_world_state_rows() {
|
||
let town = poi(0x0d, 0, 0);
|
||
let tower = poi(0x87, 0, 2372);
|
||
assert_eq!(landmark_texture_index(&town, MapLevel::Zone), 15);
|
||
assert_eq!(
|
||
landmark_texture_index(&town, MapLevel::Continent),
|
||
town.icon
|
||
);
|
||
assert_eq!(landmark_texture_index(&town, MapLevel::World), town.icon);
|
||
for level in [MapLevel::Zone, MapLevel::Continent, MapLevel::World] {
|
||
assert_eq!(
|
||
landmark_texture_index(&tower, level),
|
||
tower.icon,
|
||
"Flags & 0x80 escapes the substitution at every level"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The near-zero skip is on **both** axes, not either — a POI on the top edge of a rect
|
||
/// (`v == 0`) stays on the map.
|
||
#[test]
|
||
fn only_a_both_axes_zero_projection_is_dropped() {
|
||
assert!(is_degenerate((0.0, 0.0)));
|
||
assert!(is_degenerate((1e-8, -1e-8)));
|
||
assert!(!is_degenerate((0.0, 0.5)));
|
||
assert!(!is_degenerate((0.5, 0.0)));
|
||
assert!(!is_degenerate((1e-6, 0.0)));
|
||
}
|
||
|
||
/// Our `(continent, zone)` pair, `0` = whole, onto the reference's levels.
|
||
#[test]
|
||
fn the_selection_pair_maps_onto_the_reference_levels() {
|
||
assert_eq!(MapLevel::of(0, 0), MapLevel::World);
|
||
assert_eq!(MapLevel::of(0, 3), MapLevel::World, "continent 0 wins");
|
||
assert_eq!(MapLevel::of(2, 0), MapLevel::Continent);
|
||
assert_eq!(MapLevel::of(2, 7), MapLevel::Zone);
|
||
}
|
||
|
||
/// The REAL `AreaPOI.dbc`, run through the whole gate chain the way the feed does — the
|
||
/// report B190 asks about, answered off the shipped table rather than a fixture.
|
||
///
|
||
/// Two claims: the Eastern Plaguelands towers exist as world-state-gated zone-level rows and
|
||
/// change with the state; and the capitals are continent-level rows that need no exploration
|
||
/// bit (which is why a fresh character sees Stormwind on the Eastern Kingdoms map).
|
||
/// Skips without client data.
|
||
#[test]
|
||
fn the_real_table_gates_the_epl_towers_and_the_city_icons() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||
let pois = benilla_formats::load_area_poi_catalog(&mut chain).expect("AreaPOI");
|
||
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
|
||
let nothing_explored = vec![0u32; 64];
|
||
|
||
// ── The towers. Northpass Tower's Alliance-held row (1768, state 2372).
|
||
let (_, tower) = pois
|
||
.rows()
|
||
.find(|(id, _)| *id == 1768)
|
||
.expect("AreaPOI 1768");
|
||
assert_eq!(tower.name, "Northpass Tower");
|
||
assert_eq!(tower.world_state_id, 2372);
|
||
assert_eq!(
|
||
tower.flags & 0x80,
|
||
0x80,
|
||
"escapes the level-15 substitution"
|
||
);
|
||
assert_eq!(tower.area_id, 139, "Eastern Plaguelands");
|
||
|
||
let mut states = WorldStates::default();
|
||
let explored_epl = {
|
||
let bit = areas.get(139).expect("EPL area row").explore_flag;
|
||
let mut v = vec![0u32; 64];
|
||
v[bit as usize / 32] |= 1 << (bit % 32);
|
||
v
|
||
};
|
||
let tower_shows = |st: &WorldStates, ex: &[u32]| {
|
||
landmark_gates_pass(tower, MapLevel::Zone, &areas, ex, st)
|
||
};
|
||
assert!(
|
||
!tower_shows(&states, &explored_epl),
|
||
"no world states received — no tower icon (this IS report B190)"
|
||
);
|
||
states.write(&[(2372, 1)]);
|
||
assert!(tower_shows(&states, &explored_epl), "Alliance holds it");
|
||
assert!(
|
||
!tower_shows(&states, ¬hing_explored),
|
||
"and it still needs the zone discovered"
|
||
);
|
||
assert!(
|
||
!landmark_gates_pass(tower, MapLevel::Continent, &areas, &explored_epl, &states),
|
||
"a tower is a zone-level row (Flags 0x87 carries no 0x08)"
|
||
);
|
||
|
||
// ── The capitals: continent-level, and exploration-exempt.
|
||
let states = WorldStates::default();
|
||
for (id, name) in [
|
||
(16u32, "Stormwind"),
|
||
(8, "Ironforge"),
|
||
(18, "The Undercity"),
|
||
] {
|
||
let (_, city) = pois.rows().find(|(r, _)| *r == id).expect("city row");
|
||
assert_eq!(city.name, name);
|
||
assert_eq!(
|
||
city.area_id,
|
||
u32::MAX,
|
||
"continent-wide, so no exploration gate"
|
||
);
|
||
for level in [MapLevel::World, MapLevel::Continent, MapLevel::Zone] {
|
||
assert!(
|
||
landmark_gates_pass(city, level, &areas, ¬hing_explored, &states),
|
||
"{name} shows at every level on a fresh character"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
landmark_texture_index(city, MapLevel::Continent),
|
||
city.icon,
|
||
"the city icon is its own at continent level"
|
||
);
|
||
assert_eq!(
|
||
landmark_texture_index(city, MapLevel::Zone),
|
||
15,
|
||
"and the generic cell inside a zone map"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// File order is the landmark index: the catalog must hand rows back in the DBC's own record
|
||
/// order, not a hash permutation (which would also make the feed's change-diff fire forever).
|
||
/// Skips without client data.
|
||
#[test]
|
||
fn the_catalog_preserves_dbc_file_order() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||
let a: Vec<u32> = benilla_formats::load_area_poi_catalog(&mut chain)
|
||
.expect("AreaPOI")
|
||
.rows()
|
||
.map(|(id, _)| id)
|
||
.collect();
|
||
let b: Vec<u32> = benilla_formats::load_area_poi_catalog(&mut chain)
|
||
.expect("AreaPOI")
|
||
.rows()
|
||
.map(|(id, _)| id)
|
||
.collect();
|
||
assert_eq!(
|
||
a, b,
|
||
"two loads agree — the order is the file's, not a hash's"
|
||
);
|
||
assert!(a.len() > 300);
|
||
assert_ne!(
|
||
a,
|
||
{
|
||
let mut sorted = a.clone();
|
||
sorted.sort_unstable();
|
||
sorted
|
||
},
|
||
"and it is genuinely file order, not id order"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod player_zone_tests {
|
||
use super::*;
|
||
|
||
fn rect() -> ZoneRect {
|
||
ZoneRect {
|
||
left: 1.0,
|
||
right: -1.0,
|
||
top: 1.0,
|
||
bottom: -1.0,
|
||
}
|
||
}
|
||
|
||
/// Azeroth (map 0) with Elwynn at catalog index 9 (→ Lua zone 10), plus Kalimdor (map 1).
|
||
fn data() -> WorldMapUiData {
|
||
WorldMapUiData {
|
||
continents: vec![
|
||
ContinentEntry {
|
||
map_id: 1,
|
||
proj: None,
|
||
rect: rect(),
|
||
zones: Vec::new(),
|
||
},
|
||
ContinentEntry {
|
||
map_id: 0,
|
||
proj: None,
|
||
rect: rect(),
|
||
zones: (0..10)
|
||
.map(|i| ZoneEntry {
|
||
// index 9 is Elwynn's areaId 12; the rest are filler ids.
|
||
area_id: if i == 9 { 12 } else { 900 + i },
|
||
rect: rect(),
|
||
})
|
||
.collect(),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/// **The two halves fail separately** — `SetMapToCurrentZone`'s resolver `0x4a6650`:
|
||
/// "no zone match ⇒ `SetMap(continent, −1)`", and only "no continent match" reaches the world
|
||
/// view (wow-re `system/ui/scratch/geometry.md`). Resolving both-or-neither sent a player
|
||
/// whose zone the catalog cannot name — and, for the frames before the area feed answers,
|
||
/// EVERY player — to the world level, where `GetPlayerMapPosition` answers a world-SHEET uv
|
||
/// that every zone-uv consumer silently mis-scales.
|
||
#[test]
|
||
fn an_unresolved_zone_falls_back_to_the_continent_not_the_world() {
|
||
let d = data();
|
||
|
||
// Both halves resolve: Elwynn is continent 2, zone 10.
|
||
assert_eq!(resolve_player_zone(&d, 0, Some(12)), Some((2, 10)));
|
||
|
||
// The continent resolves, the zone does not — an area the catalog has no row for.
|
||
assert_eq!(
|
||
resolve_player_zone(&d, 0, Some(4242)),
|
||
Some((2, 0)),
|
||
"SetMap(continent, -1); GetCurrentMapZone reads that back as 0"
|
||
);
|
||
|
||
// The area feed has not answered yet — same leg, not the world view.
|
||
assert_eq!(
|
||
resolve_player_zone(&d, 0, None),
|
||
Some((2, 0)),
|
||
"a zone we do not know YET is still this continent"
|
||
);
|
||
|
||
// No continent match at all (an instance map) — the one leg that is genuinely None, and
|
||
// whose reference counterpart is the -2 orphan list we do not model.
|
||
assert_eq!(resolve_player_zone(&d, 389, Some(12)), None);
|
||
}
|
||
|
||
/// **The engine selects the player's zone itself, on the first world-enter** — the side call
|
||
/// `0x494780` makes to `0x4a6650` when the cached zone id was 0. We transcribed that
|
||
/// function's event election (`crate::area`) and dropped this half; wow-re carved it as
|
||
/// `system/ui/scratch/worldmap-selection-autosync.md` (`0x4947ac`, guarded by `sete al` on
|
||
/// `[0xb4e314] == 0`, with the new zone committed *before* the call).
|
||
///
|
||
/// Dropping it is why Questie's quest arrow pointed ~100 yards off the turn-in while the
|
||
/// reference was exact: with no zone selected, `GetPlayerMapPosition` answers a world-SHEET
|
||
/// uv, and Astrolabe scales it with the *zone's* yard dimensions. Astrolabe's own rescue
|
||
/// cannot catch it — that is gated on `(x <= 0 and y <= 0)`, and a world-sheet uv is a
|
||
/// perfectly non-zero pair.
|
||
#[test]
|
||
fn the_engine_selects_the_players_zone_on_first_world_enter() {
|
||
// Frame 1, no area yet: the reference cannot be here at all (`0x67e510` bails before
|
||
// `0x494780` on a zero zone id), so neither are we — syncing now would take the
|
||
// continent leg and, since the engine never re-syncs, strand us there.
|
||
assert_eq!(world_enter_selection(false, None, Some((2, 0))), None);
|
||
|
||
// The area answers: sync, once, to the player's own zone.
|
||
assert_eq!(
|
||
world_enter_selection(false, Some(12), Some((2, 10))),
|
||
Some((2, 10))
|
||
);
|
||
|
||
// And never again. A later zone change — even cross-continent — leaves the selection
|
||
// alone: the guard is `old == 0`, and only the world-session teardown `0x491180`
|
||
// re-zeroes `[0xb4e314]`.
|
||
assert_eq!(world_enter_selection(true, Some(40), Some((2, 22))), None);
|
||
|
||
// An unresolvable player still counts as synced rather than re-arming the gate every
|
||
// frame: EVERY exit of `0x4a6650` writes the selection, and its two failure legs write
|
||
// `SetMap(-1, -1)` — which `GetCurrentMapContinent` reads back to Lua as `0`.
|
||
assert_eq!(world_enter_selection(false, Some(12), None), Some((0, 0)));
|
||
}
|
||
}
|