compat: the uiMapID graph over the realm's own WorldMapArea, and C_Map's other half
1.12 addresses a map by a (continent, zone) pair of dropdown indices — a position in two lists, which moves when a zone is inserted — and publishes no map's rectangle on any other map at all, because its window draws one zone at a time into a fixed 1002x668 frame and never needs to know where that zone sits. A seamless, pannable map is exactly that missing relation. `script/uimap.rs` builds the tree as a pure function of the catalog `ui_world_map.rs` already pushes, rebuilt inside both setters so the graph and the catalog cannot disagree. No new DBC read, so nothing here can contradict what the stock map window itself draws. It is built from OctoWoW's own rows, which is the point: 175 against stock 1.12's 51, with 124 maps added and 49 shipped rects re-tuned. Era's uiMapIDs are reused where the map is the same map, joined on the WorldMapArea art folder rather than the zone name, because the folder is the column a realm has no reason to touch — this realm spells Azshara's `Aszhara` and Hillsbrad's `Hilsbrad` exactly as 1.12 does. A row Era has no counterpart for is minted from its own AreaTable id. One realm quirk is handled with its measurement: a third `areaID == 0` row whose art folder is `World` is the sheet's own row, which the reference's catalog walk files beside the two continents — `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`. Left in, it is a phantom continent carrying a second copy of all 39 Eastern Kingdoms zones. Art is answered as paths, so the chain serves the upscaled pack; the layer geometry is the logical 1002x668 / 256x256, because normalized fractions are invariant under a uniform upscale. The harness now seats the player a world position and the per-frame map feed, because every verb that answers *where* the player is reads the position, not the zone name. Corpus: 85 ok / 1 fail, roster and digest unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1135,9 +1135,29 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
faction_group: Some("Alliance".into()),
|
||||
// `0x5efe00`'s team digit for Human (race 1), the rank-title key's second `%d`.
|
||||
pvp_team: crate::ui_unit::race_pvp_team(1),
|
||||
// Standing outside Goldshire, the same place `__benilla_real_zone_name` says the
|
||||
// harness player is. A position matters because every map verb that answers *where*
|
||||
// the player is reads it rather than the zone name — `C_Map.GetPlayerMapPosition`,
|
||||
// `C_Map.GetBestMapForUnit`, SuperWoW's `UnitPosition` — and with none of them
|
||||
// answering, a map addon draws its art and no player arrow, which the survey's "drew
|
||||
// something" column cannot tell apart from a map addon that drew nothing at all.
|
||||
position: Some((-9450.0, 60.0, 56.0)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// …and the per-frame map feed that goes with it, which the live client pushes every frame
|
||||
// (`ui_world_map::feed_world_map`) and nothing else here would. The `(2, 1)` selection is
|
||||
// Eastern Kingdoms, first zone: the catalog above decides which zone that is, and nothing in
|
||||
// the survey depends on it being a particular one — what it buys is a *resolved* player zone,
|
||||
// which is the state `SetMapToCurrentZone` and `C_Map.GetBestMapForUnit` both read.
|
||||
script.set_world_map_feed(
|
||||
Some((2, 1)),
|
||||
Some((0.5, 0.5)),
|
||||
0.0,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
// `C_CreatureInfo`'s race and class tables. The live client pushes these once in `PostStartup`
|
||||
// off the player's own `ChrRaces.dbc`/`ChrClasses.dbc` (`ui_chr_races.rs`,
|
||||
|
||||
@@ -90,6 +90,7 @@ fn catalog() -> Vec<WorldMapContinentView> {
|
||||
vec![WorldMapContinentView {
|
||||
name: "Kalimdor".into(),
|
||||
map_file: "Kalimdor".into(),
|
||||
map_id: 1,
|
||||
world_rect: (0.0, 0.0, 1.0, 1.0),
|
||||
loc_rect: (10000.0, -10000.0, 10000.0, -10000.0),
|
||||
zone_grid: Vec::new(),
|
||||
|
||||
@@ -218,6 +218,7 @@ pub(crate) fn build_catalog(
|
||||
.unwrap_or(cont.name.as_str())
|
||||
.to_string(),
|
||||
map_file: cont.name.clone(),
|
||||
map_id: cont.map_id,
|
||||
world_rect,
|
||||
loc_rect: (cont.loc_left, cont.loc_right, cont.loc_top, cont.loc_bottom),
|
||||
zone_grid,
|
||||
|
||||
@@ -103,8 +103,29 @@ fn group(lua: &Lua, rows: &[(&str, i64)]) -> mlua::Result<Table> {
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// `Enum.UIMapType`, the kind of map a `uiMapID` names. The values are the modern client's own
|
||||
/// and the names it uses; the three this client has no map for (`Cosmic`, `Micro`, `Orphan`) are
|
||||
/// present because a caller's filter compares against the *name*, and a missing name compares as
|
||||
/// nil against every map type there is — a filter that silently matches nothing.
|
||||
///
|
||||
/// The one consumer worth naming walks down the tree and stops when it leaves the land:
|
||||
/// `if info.mapType ~= Enum.UIMapType.Continent and info.mapType ~= Enum.UIMapType.Zone then
|
||||
/// break end` (OpenMapClassic, `Map/Stitch.lua:255`). Both of those are real here, which is why
|
||||
/// [`crate::script::uimap`] files a continent as `Continent` and a zone as `Zone` rather than
|
||||
/// flattening the two.
|
||||
const UI_MAP_TYPE: &[(&str, i64)] = &[
|
||||
("Cosmic", 0),
|
||||
("World", 1),
|
||||
("Continent", 2),
|
||||
("Zone", 3),
|
||||
("Dungeon", 4),
|
||||
("Micro", 5),
|
||||
("Orphan", 6),
|
||||
];
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
t.set("UIMapType", group(lua, UI_MAP_TYPE)?)?;
|
||||
t.set("PowerType", group(lua, POWER_TYPE)?)?;
|
||||
t.set("ItemQuality", group(lua, ITEM_QUALITY)?)?;
|
||||
t.set("ItemClass", group(lua, ITEM_CLASS)?)?;
|
||||
|
||||
@@ -68,6 +68,7 @@ mod map;
|
||||
mod math_ext;
|
||||
mod merchant_frame;
|
||||
mod misc;
|
||||
mod uimap;
|
||||
// `pub(in …compat)` for its `plate_frame`, which SuperWoW's `UnitNameplate` shares.
|
||||
pub(in crate::script::compat) mod nameplate;
|
||||
mod new_items;
|
||||
@@ -153,7 +154,15 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set("C_FriendList", friend_list::install(lua)?)?;
|
||||
g.set("C_GossipInfo", gossip::install(lua)?)?;
|
||||
g.set("C_MerchantFrame", merchant_frame::install(lua)?)?;
|
||||
g.set("C_Map", map::install(lua)?)?;
|
||||
// One `C_Map` table, filled by two files: `map.rs` answers about the 1.12 *selection* (the
|
||||
// zone being viewed), `uimap.rs` about the uiMapID *graph*. Both on one table because that is
|
||||
// one namespace to a caller, and in that order because the graph's members are the ones a
|
||||
// modern map framework reaches for first and so are the ones worth reading last.
|
||||
let c_map = map::install(lua)?;
|
||||
uimap::extend_c_map(lua, &c_map)?;
|
||||
g.set("C_Map", c_map)?;
|
||||
g.set("C_MapExplorationInfo", uimap::install_exploration(lua)?)?;
|
||||
uimap::install_globals(lua)?;
|
||||
taxi_map::install(lua)?;
|
||||
g.set("C_PlayerCache", player_cache::install(lua)?)?;
|
||||
g.set("C_PlayerInfo", player_info::install(lua)?)?;
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
//! The uiMapID half of `C_Map`, plus `C_MapExplorationInfo`, `CreateVector2D` and `UiMapPoint`.
|
||||
//!
|
||||
//! [`super::map`] holds the five members that answer about the **1.12 selection** — the zone being
|
||||
//! viewed, addressed by a `(continent, zone)` dropdown pair. These are the other fourteen, which
|
||||
//! address a map by a single stable `uiMapID` and publish the *tree*: which maps a map contains,
|
||||
//! and where each child's rectangle falls on its parent and on the world sheet
|
||||
//! ([`crate::script::uimap`] builds that graph from the realm's own `WorldMapArea.dbc`).
|
||||
//!
|
||||
//! **Why the tree is the whole point.** 1.12's map window draws one zone at a time into a fixed
|
||||
//! 1002×668 frame; there is no API for "where is Westfall relative to Elwynn", because the window
|
||||
//! never needs to know. A seamless map does: it places every zone's art in one coordinate space
|
||||
//! and pans across the borders. That is one rectangle per map, composed up to a common root, and
|
||||
//! it is exactly what `GetMapRectOnMap` publishes. Everything else here — the art layer list, the
|
||||
//! exploration overlays, the position conversions — hangs off those rectangles.
|
||||
//!
|
||||
//! **The art is the realm's, at whatever resolution the realm ships.** `GetMapArtLayerTextures`
|
||||
//! answers `Interface\WorldMap\<folder>\<folder>N` **paths**, not the fileIDs Era answers, and the
|
||||
//! folder is the one on the realm's own `WorldMapArea` row. The asset chain then serves whichever
|
||||
//! copy wins, which on this install is the upscaled pack (`patch-M.mpq`, three copies deep). A
|
||||
//! path is the right currency here for two reasons: our texture setters take one, and `file_ids.rs`
|
||||
//! translates a numeric id to a path anyway, so handing back ids would be a round trip through a
|
||||
//! table that cannot name the realm's own art. The 1002×668 / 256×256 layer geometry is reported
|
||||
//! as the **logical** size, not the shipped pixel size, because the drawn fractions are normalized
|
||||
//! and an upscale that scales a tile and its padding together leaves them unchanged — the same
|
||||
//! invariance [`super::map`]'s `art_scale` has to correct for only because `GetMapOverlays`
|
||||
//! publishes absolute pixels.
|
||||
//!
|
||||
//! **What is not built**, and would be a guess rather than a port: `GetMapPosFromWorldPos` (the
|
||||
//! inverse search over every map's rect, which nothing in the corpus calls), the `C_Map` members
|
||||
//! about maps this client has no concept of (`GetMapGroupID`, `GetMapLinksForMap`,
|
||||
//! `GetBountySetIDForMap`), and `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`. Listed in
|
||||
//! `docs/ADDON_COMPAT.md` rather than stubbed: a member answering a plausible-looking nothing is
|
||||
//! worse than an absent one, which a caller's `if C_Map.X then` can see.
|
||||
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::script::uimap::{self, UiMapNode, WORLD_UI_MAP_ID};
|
||||
use crate::script::{Model, WorldMapState};
|
||||
|
||||
/// A map's art layer geometry. 1.12's detail frame is 1002×668 filled by 12 tiles of 256×256, four
|
||||
/// across and three down, row-major (`WorldMapFrame.xml:444`, `WorldMapFrame.lua:66`). Era keeps
|
||||
/// those numbers for the vanilla maps, so a layer here is that one layer.
|
||||
const LAYER_WIDTH: u32 = 1002;
|
||||
const LAYER_HEIGHT: u32 = 668;
|
||||
const TILE: u32 = 256;
|
||||
|
||||
/// How far the canvas may scale a layer. Nothing in the catalog says — 1.12 has no zoom at all —
|
||||
/// so these are the canvas framework's own bounds, and they live here because `CreateZoomLevels`
|
||||
/// reads them off the layer rather than off the frame.
|
||||
const MIN_SCALE: f64 = 0.3;
|
||||
const MAX_SCALE: f64 = 2.0;
|
||||
|
||||
/// The tile count on each axis for a layer, the `ceil` the reference's own loop takes.
|
||||
fn tile_grid() -> (u32, u32) {
|
||||
(LAYER_WIDTH.div_ceil(TILE), LAYER_HEIGHT.div_ceil(TILE))
|
||||
}
|
||||
|
||||
/// `Interface\WorldMap\<folder>\<folder><n>`, the path law `WorldMapFrame.lua:66` writes.
|
||||
fn tile_path(folder: &str, n: u32) -> String {
|
||||
format!("Interface\\WorldMap\\{folder}\\{folder}{n}")
|
||||
}
|
||||
|
||||
/// A `Vector2D`: the two-field table the modern client's `CreateVector2D` makes, as plain data.
|
||||
/// Its methods (`GetXY`, `ScaleBy`…) are not built — every caller in the corpus reads `.x`/`.y`,
|
||||
/// and a half-built class is worse than a table, because the half that is missing is found at
|
||||
/// whatever call site reaches for it.
|
||||
fn vector2d(lua: &Lua, x: f64, y: f64) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
t.set("x", x)?;
|
||||
t.set("y", y)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// One map's info table, `C_Map.GetMapInfo`'s return and the row every walk of the tree yields.
|
||||
fn map_info(lua: &Lua, node: &UiMapNode) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
t.set("mapID", node.ui_map_id)?;
|
||||
t.set("name", node.name.as_str())?;
|
||||
t.set("mapType", node.map_type)?;
|
||||
t.set("parentMapID", node.parent_ui_map_id)?;
|
||||
// `flags` is a modern bitfield about map behaviour (no-zoom, hidden-by-default…); none of its
|
||||
// bits has a 1.12 meaning, so it is 0 and a caller testing one gets the "no" that is true.
|
||||
t.set("flags", 0)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The rect of `child` expressed on `top`, composed up the tree. `None` when they are not related
|
||||
/// or when some map on the path has no placement.
|
||||
fn rect_on_map(state: &WorldMapState, child: u32, top: u32) -> Option<(f32, f32, f32, f32)> {
|
||||
// A map on itself is the whole unit square, which is what a caller normalizing by it expects
|
||||
// and what Era answers.
|
||||
if child == top {
|
||||
return Some((0.0, 1.0, 0.0, 1.0));
|
||||
}
|
||||
// Walk up from the child, composing each hop into what is already accumulated. The
|
||||
// accumulator always holds "the child's rect on the map climbed to so far".
|
||||
let mut node = uimap::node(state, child)?;
|
||||
let (mut l, mut r, mut t, mut b) = node.parent_rect;
|
||||
if r <= l || b <= t {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
let parent = node.parent_ui_map_id;
|
||||
if parent == top {
|
||||
return Some((l, r, t, b));
|
||||
}
|
||||
if parent == 0 {
|
||||
return None;
|
||||
}
|
||||
let up = uimap::node(state, parent)?;
|
||||
let (pl, pr, pt, pb) = up.parent_rect;
|
||||
if pr <= pl || pb <= pt {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (pr - pl, pb - pt);
|
||||
(l, r, t, b) = (pl + l * w, pl + r * w, pt + t * h, pt + b * h);
|
||||
node = up;
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's world position and the continent node it is measured against, which together are
|
||||
/// the only way to place the player on a map: world coordinates are per-continent, so a position
|
||||
/// without its continent names a different point on each one.
|
||||
fn player_on_continent<'a>(
|
||||
model: &'a Model,
|
||||
state: &'a WorldMapState,
|
||||
) -> Option<((f32, f32), &'a UiMapNode)> {
|
||||
let position = model.unit("player").and_then(|u| u.position)?;
|
||||
let (ci, _) = state.player_zone?;
|
||||
let cont = uimap::node_for_selection(state, (ci, 0))?;
|
||||
Some(((position.0, position.1), cont))
|
||||
}
|
||||
|
||||
/// The player's normalized position on one map, or `None` when the player is not on it.
|
||||
///
|
||||
/// Three cases, and they are the same arithmetic read at different depths: a map with its own yard
|
||||
/// rect (every continent and zone) un-lerps the player's world coordinates directly; the world
|
||||
/// sheet has no yard rect, so the player's place on its own continent is lerped into that
|
||||
/// continent's rect on the sheet; and a map on a *different* continent is not the player's map at
|
||||
/// all, which is the `None` an arrow should read as "do not draw me".
|
||||
fn player_position_on(
|
||||
model: &Model,
|
||||
state: &WorldMapState,
|
||||
want: &UiMapNode,
|
||||
) -> Option<(f32, f32)> {
|
||||
let ((x, y), cont) = player_on_continent(model, state)?;
|
||||
if want.ui_map_id == WORLD_UI_MAP_ID {
|
||||
let (u, v) = uimap::position_on(cont, x, y)?;
|
||||
let (cl, cr, ct, cb) = cont.world_rect;
|
||||
return Some((cl + u * (cr - cl), ct + v * (cb - ct)));
|
||||
}
|
||||
// An orphan (an instance map) has a yard rect in the instance's own space, which the player's
|
||||
// continent coordinates are not in; it resolves only while the player is inside it, which the
|
||||
// caller settles with `GetBestMapForUnit`.
|
||||
if want.continent_map_id != cont.continent_map_id {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = uimap::position_on(want, x, y)?;
|
||||
((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)).then_some((u, v))
|
||||
}
|
||||
|
||||
/// The two catalog views behind a node, for the members that reuse the 1.12 hover law.
|
||||
fn views<'a>(
|
||||
state: &'a WorldMapState,
|
||||
node: &UiMapNode,
|
||||
) -> Option<(
|
||||
&'a crate::script::WorldMapContinentView,
|
||||
&'a crate::script::WorldMapZoneView,
|
||||
)> {
|
||||
let (ci, zi) = node.selection?;
|
||||
let cont = state.continents.get(ci.checked_sub(1)? as usize)?;
|
||||
let zone = cont.zones.get(zi.checked_sub(1)? as usize)?;
|
||||
Some((cont, zone))
|
||||
}
|
||||
|
||||
/// Add the uiMapID members to the `C_Map` table [`super::map`] built.
|
||||
pub(super) fn extend_c_map(lua: &Lua, t: &Table) -> mlua::Result<()> {
|
||||
// GetFallbackWorldMapID() → the root. A caller asks this when it has no map to show and wants
|
||||
// the widest one there is; here that is the world sheet, always.
|
||||
t.set(
|
||||
"GetFallbackWorldMapID",
|
||||
lua.create_function(|_, ()| Ok(WORLD_UI_MAP_ID))?,
|
||||
)?;
|
||||
|
||||
// GetMapInfo(uiMapID) → { mapID, name, mapType, parentMapID, flags }, or nil.
|
||||
//
|
||||
// nil for an unknown id, which is also the answer on a run with no catalog: a caller's
|
||||
// `local info = C_Map.GetMapInfo(id); if info then` is the guard every consumer in the corpus
|
||||
// writes, and an empty table would defeat it.
|
||||
t.set(
|
||||
"GetMapInfo",
|
||||
lua.create_function(|lua, ui_map_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match ui_map_id.and_then(|id| uimap::node(&model.worldmap, id)) {
|
||||
Some(node) => Ok(Value::Table(map_info(lua, node)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapChildrenInfo(uiMapID [, mapType [, allDescendants]]) → an array of info tables.
|
||||
//
|
||||
// An array always, empty when there are no children: the caller's shape is `ipairs`, and nil
|
||||
// there raises. That differs from `GetMapInfo` above on purpose — "this map has no children"
|
||||
// is a fact about a map that exists, while "there is no such map" is not.
|
||||
t.set(
|
||||
"GetMapChildrenInfo",
|
||||
lua.create_function(
|
||||
|lua, (ui_map_id, want_type, all): (Option<u32>, Option<u8>, Option<bool>)| {
|
||||
let out = lua.create_table()?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(id) = ui_map_id else {
|
||||
return Ok(out);
|
||||
};
|
||||
for node in uimap::children(&model.worldmap, id, want_type, all.unwrap_or(false)) {
|
||||
out.set(out.raw_len() + 1, map_info(lua, node)?)?;
|
||||
}
|
||||
Ok(out)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// GetMapRectOnMap(uiMapID, topUiMapID) → left, right, top, bottom, normalized on `top`.
|
||||
//
|
||||
// **No values** rather than a zero rect when the two are unrelated or either has no
|
||||
// placement, because every caller divides by the span: a truthful `0, 0, 0, 0` becomes a
|
||||
// division by zero and places the whole map on one pixel. OpenMapClassic's own cache tests
|
||||
// exactly that (`Map/Stitch.lua:232`, `rr > l and b > t`), which is the shape to answer.
|
||||
t.set(
|
||||
"GetMapRectOnMap",
|
||||
lua.create_function(|lua, (ui_map_id, top): (Option<u32>, Option<u32>)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let rect = ui_map_id
|
||||
.zip(top)
|
||||
.and_then(|(id, top)| rect_on_map(&model.worldmap, id, top));
|
||||
Ok(match rect {
|
||||
Some((l, r, t, b)) => MultiValue::from_vec(vec![
|
||||
Value::Number(f64::from(l)),
|
||||
Value::Number(f64::from(r)),
|
||||
Value::Number(f64::from(t)),
|
||||
Value::Number(f64::from(b)),
|
||||
]),
|
||||
None => MultiValue::new(),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapArtLayers(uiMapID) → one layer, the 1.12 detail frame's geometry (see LAYER_WIDTH).
|
||||
//
|
||||
// An array of one, not a bare table: Era's maps have several layers at different zooms and
|
||||
// callers index `layers[1]`.
|
||||
t.set(
|
||||
"GetMapArtLayers",
|
||||
lua.create_function(|lua, ui_map_id: Option<u32>| {
|
||||
let out = lua.create_table()?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(node) = ui_map_id.and_then(|id| uimap::node(&model.worldmap, id)) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if node.map_file.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let layer = lua.create_table()?;
|
||||
layer.set("layerWidth", LAYER_WIDTH)?;
|
||||
layer.set("layerHeight", LAYER_HEIGHT)?;
|
||||
layer.set("tileWidth", TILE)?;
|
||||
layer.set("tileHeight", TILE)?;
|
||||
layer.set("minScale", MIN_SCALE)?;
|
||||
layer.set("maxScale", MAX_SCALE)?;
|
||||
layer.set("additionalZoomSteps", 0)?;
|
||||
out.set(1, layer)?;
|
||||
Ok(out)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapArtLayerTextures(uiMapID, layerIndex) → the layer's tiles as paths, row-major.
|
||||
//
|
||||
// Paths rather than fileIDs — see the module doc. Row-major and 1-based is the reference's own
|
||||
// numbering (`WorldMapFrame.lua:66` walks 1..12 against the twelve tiles the XML anchors four
|
||||
// across and three down), and it is the order the caller's `files[(j-1)*cols + k]` reads.
|
||||
t.set(
|
||||
"GetMapArtLayerTextures",
|
||||
lua.create_function(|lua, (ui_map_id, layer): (Option<u32>, Option<u32>)| {
|
||||
let out = lua.create_table()?;
|
||||
if layer.unwrap_or(1) != 1 {
|
||||
return Ok(out);
|
||||
}
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(node) = ui_map_id.and_then(|id| uimap::node(&model.worldmap, id)) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if node.map_file.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let (cols, rows) = tile_grid();
|
||||
for n in 1..=(cols * rows) {
|
||||
out.set(n, tile_path(&node.map_file, n))?;
|
||||
}
|
||||
Ok(out)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapArtID(uiMapID) → the id of the art set this map draws.
|
||||
//
|
||||
// Era's own art ids are a table this client does not have, and the one thing a caller can do
|
||||
// with the number is tell two maps' art apart, so the map's own `AreaTable` id is it — unique
|
||||
// per row, and 0 for the two continents and the sheet, whose art no other map shares either.
|
||||
t.set(
|
||||
"GetMapArtID",
|
||||
lua.create_function(|lua, ui_map_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(ui_map_id
|
||||
.and_then(|id| uimap::node(&model.worldmap, id))
|
||||
.map(|n| n.area_id))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMapInfoAtPosition(uiMapID, x, y) → the child map under a normalized point, one level
|
||||
// down.
|
||||
//
|
||||
// **One level, not the deepest.** The caller walks down with this and re-normalizes through
|
||||
// `GetMapRectOnMap` at each step, building the chain from the world down
|
||||
// (`Map/Stitch.lua:249`, whose own comment says `{ 947, continent, zone }`); a deepest-first
|
||||
// answer would skip the continent and hand back a two-link chain. Both terminate that loop —
|
||||
// it stops when the answer is the map it asked about — so this is the shape that keeps the
|
||||
// chain it is documented to build.
|
||||
t.set(
|
||||
"GetMapInfoAtPosition",
|
||||
lua.create_function(
|
||||
|lua, (ui_map_id, x, y): (Option<u32>, Option<f32>, Option<f32>)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let hit = ui_map_id
|
||||
.zip(x)
|
||||
.zip(y)
|
||||
.and_then(|((id, x), y)| uimap::child_at(&model.worldmap, id, x, y));
|
||||
match hit {
|
||||
Some(node) => Ok(Value::Table(map_info(lua, node)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// GetMapHighlightInfoAtPosition(uiMapID, x, y) → texture, atlasID, texPctX, texPctY,
|
||||
// textureX, textureY, scrollChildX, scrollChildY.
|
||||
//
|
||||
// The same numbers 1.12's own `UpdateMapHighlight` answers at continent level, out of the same
|
||||
// `continent_hover` (`worldmap.rs`) — so the zone outline this lights up is the one the stock
|
||||
// window lights up, and the two cannot drift. A **path** stands where Era returns a fileDataID,
|
||||
// for the reason the module doc gives; `atlasID` is nil, since 1.12 has no atlases and a
|
||||
// caller picks whichever of the two is non-nil.
|
||||
t.set(
|
||||
"GetMapHighlightInfoAtPosition",
|
||||
lua.create_function(
|
||||
|lua, (ui_map_id, x, y): (Option<u32>, Option<f32>, Option<f32>)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let wm = &model.worldmap;
|
||||
let found = ui_map_id
|
||||
.zip(x)
|
||||
.zip(y)
|
||||
.and_then(|((id, x), y)| uimap::child_at(wm, id, x, y))
|
||||
.and_then(|node| views(wm, node))
|
||||
.and_then(|(cont, zone)| {
|
||||
crate::script::worldmap::continent_highlight(cont, zone)
|
||||
});
|
||||
let Some(h) = found else {
|
||||
return Ok(MultiValue::new());
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(format!(
|
||||
"Interface\\WorldMap\\{f}\\{f}Highlight",
|
||||
f = h.file
|
||||
))?),
|
||||
Value::Nil,
|
||||
// texPctX is 1.0 at continent level in the reference too (`0x4a822a`): the
|
||||
// highlight art's x axis is used whole, only its y is padded.
|
||||
Value::Number(1.0),
|
||||
Value::Number(h.tex_pct_y),
|
||||
Value::Number(h.texture.0),
|
||||
Value::Number(h.texture.1),
|
||||
Value::Number(h.scroll.0),
|
||||
Value::Number(h.scroll.1),
|
||||
]))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// GetBestMapForUnit(unit) → the uiMapID of the map that unit is on.
|
||||
//
|
||||
// Only the player: the per-frame feed resolves the player's zone (`ui_world_map.rs`) and
|
||||
// nothing resolves anyone else's, so another token is an honest nil rather than the player's
|
||||
// answer wearing someone else's name. An instance map answers first, because the resolver
|
||||
// reaches its orphan leg only when the continent walk misses (`0x4a66c3`), so at most one is
|
||||
// set and that one is where the player is.
|
||||
t.set(
|
||||
"GetBestMapForUnit",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
if token.as_deref() != Some("player") {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let wm = &model.worldmap;
|
||||
if let Some(row) = wm.player_direct_area {
|
||||
return Ok(uimap::node_for_direct_area(wm, row).map(|n| n.ui_map_id));
|
||||
}
|
||||
Ok(wm
|
||||
.player_zone
|
||||
.and_then(|sel| uimap::node_for_selection(wm, sel))
|
||||
.map(|n| n.ui_map_id))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPlayerMapPosition(uiMapID, unit) → a Vector2D, or nil off that map.
|
||||
t.set(
|
||||
"GetPlayerMapPosition",
|
||||
lua.create_function(|lua, (ui_map_id, token): (Option<u32>, Option<String>)| {
|
||||
if token.as_deref() != Some("player") {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let found = ui_map_id
|
||||
.and_then(|id| uimap::node(&model.worldmap, id))
|
||||
.and_then(|node| player_position_on(&model, &model.worldmap, node));
|
||||
match found {
|
||||
Some((u, v)) => Ok(Value::Table(vector2d(lua, f64::from(u), f64::from(v))?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetWorldPosFromMapPos(uiMapID, mapPos) → continentID, worldPos.
|
||||
//
|
||||
// `continentID` is the `Map.dbc` id, which is what the modern client's own continent ids are
|
||||
// and what the caller's per-continent tables are keyed by (OpenMapClassic's
|
||||
// `MapMinimapFolder = { [0] = "Azeroth", [1] = "Kalimdor" }`). `worldPos` is a Vector2D of
|
||||
// `(x, y)` in world yards — north and west — which is the pairing `WorldMapArea`'s own rect
|
||||
// uses: its left/right bound the y axis and its top/bottom the x axis.
|
||||
t.set(
|
||||
"GetWorldPosFromMapPos",
|
||||
lua.create_function(|lua, (ui_map_id, pos): (Option<u32>, Option<Table>)| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let wm = &model.worldmap;
|
||||
let Some(node) = ui_map_id.and_then(|id| uimap::node(wm, id)) else {
|
||||
return Ok(MultiValue::new());
|
||||
};
|
||||
let (u, v) = match &pos {
|
||||
Some(p) => (
|
||||
p.get::<Option<f32>>("x")?.unwrap_or(0.0),
|
||||
p.get::<Option<f32>>("y")?.unwrap_or(0.0),
|
||||
),
|
||||
None => return Ok(MultiValue::new()),
|
||||
};
|
||||
// The sheet has no yard rect of its own: the point falls on whichever continent's rect
|
||||
// holds it, and that continent's coordinates are the answer.
|
||||
let (node, u, v) = if node.ui_map_id == WORLD_UI_MAP_ID {
|
||||
match uimap::child_at(wm, WORLD_UI_MAP_ID, u, v) {
|
||||
Some(cont) => {
|
||||
let (cl, cr, ct, cb) = cont.parent_rect;
|
||||
(cont, (u - cl) / (cr - cl), (v - ct) / (cb - ct))
|
||||
}
|
||||
None => return Ok(MultiValue::new()),
|
||||
}
|
||||
} else {
|
||||
(node, u, v)
|
||||
};
|
||||
let Some((x, y)) = uimap::world_position_of(node, u, v) else {
|
||||
return Ok(MultiValue::new());
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
node.continent_map_id
|
||||
.map_or(Value::Nil, |id| Value::Integer(i64::from(id))),
|
||||
Value::Table(vector2d(lua, f64::from(x), f64::from(y))?),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetAreaInfo(areaID) → the area's name.
|
||||
//
|
||||
// The whole `AreaTable.dbc` name column, the same table `GetAreas` answers from — so a subzone
|
||||
// this client never puts on a map still has a name, which is what a caller labelling a pin
|
||||
// wants.
|
||||
t.set(
|
||||
"GetAreaInfo",
|
||||
lua.create_function(|lua, area_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(area_id
|
||||
.and_then(|id| model.worldmap.areas.get(&id))
|
||||
.cloned())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
install_user_waypoint(lua, t)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user waypoint: the modern client's single map pin, which a caller sets, reads back and
|
||||
/// clears.
|
||||
///
|
||||
/// State, not a query — so it lives on [`WorldMapState`] beside the selection rather than being
|
||||
/// invented per call. It is one pin because the modern client's is one pin; an addon wanting more
|
||||
/// keeps its own list, which is what OpenMapClassic does when this refuses
|
||||
/// (`Map/Nav.lua:221`: `mod.char.pin = not blizzard and { … }`).
|
||||
fn install_user_waypoint(lua: &Lua, t: &Table) -> mlua::Result<()> {
|
||||
// CanSetUserWaypointOnMap(uiMapID) → whether a pin may be dropped there.
|
||||
//
|
||||
// Any map in the graph with a placement. A map with no rectangle cannot carry a pin anything
|
||||
// could draw, and saying yes there would make the caller believe a pin it never sees.
|
||||
t.set(
|
||||
"CanSetUserWaypointOnMap",
|
||||
lua.create_function(|lua, ui_map_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(ui_map_id
|
||||
.and_then(|id| uimap::node(&model.worldmap, id))
|
||||
.is_some_and(|n| n.ui_map_id == WORLD_UI_MAP_ID || n.placed_on_world()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetUserWaypoint(uiMapPoint) — the table `UiMapPoint.CreateFromCoordinates` makes.
|
||||
t.set(
|
||||
"SetUserWaypoint",
|
||||
lua.create_function(|lua, point: Option<Table>| {
|
||||
let Some(p) = point else {
|
||||
return Ok(false);
|
||||
};
|
||||
let id: Option<u32> = p.get("uiMapID")?;
|
||||
let pos: Option<Table> = p.get("position")?;
|
||||
let (Some(id), Some(pos)) = (id, pos) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let (x, y) = (
|
||||
pos.get::<Option<f32>>("x")?.unwrap_or(0.0),
|
||||
pos.get::<Option<f32>>("y")?.unwrap_or(0.0),
|
||||
);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if uimap::node(&model.worldmap, id).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
model.worldmap.user_waypoint = Some((id, x, y));
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
t.set(
|
||||
"HasUserWaypoint",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.worldmap.user_waypoint.is_some())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
t.set(
|
||||
"GetUserWaypoint",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some((id, x, y)) = model.worldmap.user_waypoint else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let out = lua.create_table()?;
|
||||
out.set("uiMapID", id)?;
|
||||
out.set("position", vector2d(lua, f64::from(x), f64::from(y))?)?;
|
||||
Ok(Value::Table(out))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
t.set(
|
||||
"ClearUserWaypoint",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.worldmap.user_waypoint = None;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetUserWaypointHyperlink() → the `|Hworldmap:…|h` link the modern client pastes into chat.
|
||||
//
|
||||
// The modern format is `worldmap:<uiMapID>:<x*10000>:<y*10000>`, and the integer scaling is
|
||||
// the part that matters: a reader parses three integers. This client's chat has no handler for
|
||||
// the link type, so the string is text — which is exactly what it is for a player pasting
|
||||
// coordinates to a friend, and nothing here claims the click works.
|
||||
t.set(
|
||||
"GetUserWaypointHyperlink",
|
||||
lua.create_function(|lua: &Lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.worldmap.user_waypoint.map(|(id, x, y)| {
|
||||
let (xi, yi) = ((x * 10_000.0) as i64, (y * 10_000.0) as i64);
|
||||
format!("|cffffff00|Hworldmap:{id}:{xi}:{yi}|h[Map Pin Location]|h|r")
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `C_MapExplorationInfo`: the exploration overlays for one map, as the canvas draws them.
|
||||
///
|
||||
/// One member. It is the same `WorldMapOverlay.dbc` rows the stock window draws
|
||||
/// (`WorldMapFrame.lua:107-160`) and the same reveal gate — an overlay appears once any of its
|
||||
/// area's explore bits is set — published in the shape the canvas framework reads: one entry per
|
||||
/// overlay with its pixel offset and size and its tiles as a list. The tiles are **paths**, for
|
||||
/// the reason [`extend_c_map`]'s module doc gives, under the `fileDataIDs` key Era uses, because
|
||||
/// the key is what callers index and renaming it would help no one.
|
||||
pub(super) fn install_exploration(lua: &Lua) -> mlua::Result<Table> {
|
||||
let t = lua.create_table()?;
|
||||
|
||||
// GetExploredMapTextures(uiMapID) → an array of overlay entries, or nil.
|
||||
//
|
||||
// nil rather than an empty array when the map is unknown, matching every other member's split
|
||||
// between "no such map" and "that map has none": the caller's own idiom is
|
||||
// `GetExploredMapTextures(mapID) or {}` (`Map/Stitch.lua:509`), which reads both the same way,
|
||||
// and the distinction is for the callers that do look.
|
||||
t.set(
|
||||
"GetExploredMapTextures",
|
||||
lua.create_function(|lua, ui_map_id: Option<u32>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let wm = &model.worldmap;
|
||||
let Some(node) = ui_map_id.and_then(|id| uimap::node(wm, id)) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let out = lua.create_table()?;
|
||||
let Some((_, zone)) = views(wm, node) else {
|
||||
// A continent, the sheet or an orphan: no overlay rows, which is an empty list.
|
||||
return Ok(Value::Table(out));
|
||||
};
|
||||
for o in &zone.overlays {
|
||||
if !o
|
||||
.explore_bits
|
||||
.iter()
|
||||
.any(|&b| explored_bit(&wm.explored, b))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let row = lua.create_table()?;
|
||||
row.set("textureWidth", o.width)?;
|
||||
row.set("textureHeight", o.height)?;
|
||||
row.set("offsetX", o.offset_x)?;
|
||||
row.set("offsetY", o.offset_y)?;
|
||||
// The reference's own tile count for a row: `ceil(w/256) * ceil(h/256)`, numbered
|
||||
// row-major from 1 (`WorldMapFrame.lua:115-158`).
|
||||
let (cols, rows) = (o.width.div_ceil(TILE), o.height.div_ceil(TILE));
|
||||
let files = lua.create_table()?;
|
||||
for n in 1..=(cols * rows) {
|
||||
files.set(n, format!("{}{n}", o.texture))?;
|
||||
}
|
||||
row.set("fileDataIDs", files)?;
|
||||
// The modern field for an overlay that appears only under the cursor (a highlight,
|
||||
// not a reveal). 1.12 has no such row — every `WorldMapOverlay` row is a reveal —
|
||||
// so it is false, and the caller's `if not info.isShownByMouseOver` takes them all.
|
||||
row.set("isShownByMouseOver", false)?;
|
||||
out.set(out.raw_len() + 1, row)?;
|
||||
}
|
||||
Ok(Value::Table(out))
|
||||
})?,
|
||||
)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Bit `n` of the `PLAYER_EXPLORED_ZONES_1` bitset, the same read the window's own overlay gate
|
||||
/// makes.
|
||||
fn explored_bit(explored: &[u32], n: u32) -> bool {
|
||||
explored
|
||||
.get((n / 32) as usize)
|
||||
.is_some_and(|w| w & (1 << (n % 32)) != 0)
|
||||
}
|
||||
|
||||
/// `CreateVector2D` and `UiMapPoint`, the two globals a `C_Map` caller needs to *build* an
|
||||
/// argument rather than read a return.
|
||||
pub(super) fn install_globals(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
g.set(
|
||||
"CreateVector2D",
|
||||
lua.create_function(|lua, (x, y): (Option<f64>, Option<f64>)| {
|
||||
vector2d(lua, x.unwrap_or(0.0), y.unwrap_or(0.0))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `UiMapPoint`, the argument shape `C_Map.SetUserWaypoint` takes. Two constructors, both of
|
||||
// which every caller in the corpus feature-tests before using
|
||||
// (`Map/Nav.lua:216`: `UiMapPoint and UiMapPoint.CreateFromCoordinates`).
|
||||
let point = lua.create_table()?;
|
||||
point.set(
|
||||
"CreateFromCoordinates",
|
||||
lua.create_function(
|
||||
|lua, (id, x, y, _z): (Option<u32>, Option<f64>, Option<f64>, Option<f64>)| {
|
||||
let t = lua.create_table()?;
|
||||
t.set("uiMapID", id)?;
|
||||
t.set(
|
||||
"position",
|
||||
vector2d(lua, x.unwrap_or(0.0), y.unwrap_or(0.0))?,
|
||||
)?;
|
||||
Ok(t)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
point.set(
|
||||
"CreateFromVector2D",
|
||||
lua.create_function(
|
||||
|lua, (id, vec, _z): (Option<u32>, Option<Table>, Option<f64>)| {
|
||||
let t = lua.create_table()?;
|
||||
t.set("uiMapID", id)?;
|
||||
let (x, y) = match &vec {
|
||||
Some(v) => (
|
||||
v.get::<Option<f64>>("x")?.unwrap_or(0.0),
|
||||
v.get::<Option<f64>>("y")?.unwrap_or(0.0),
|
||||
),
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
t.set("position", vector2d(lua, x, y)?)?;
|
||||
Ok(t)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
g.set("UiMapPoint", point)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -994,6 +994,7 @@ mod tests {
|
||||
s.set_world_map_catalog(vec![WorldMapContinentView {
|
||||
name: "Kalimdor".into(),
|
||||
map_file: "Kalimdor".into(),
|
||||
map_id: 1,
|
||||
world_rect: (0.0, 0.0, 1.0, 1.0),
|
||||
loc_rect: (0.0, -10000.0, 5000.0, -5000.0),
|
||||
zone_grid: Vec::new(),
|
||||
|
||||
@@ -162,6 +162,7 @@ mod trade;
|
||||
mod tradeskill;
|
||||
mod trainer;
|
||||
mod types;
|
||||
mod uimap;
|
||||
mod unit;
|
||||
mod weapon_enchant;
|
||||
mod who_sort;
|
||||
@@ -297,6 +298,7 @@ pub use types::{
|
||||
QuadContent, ScriptValue, TexCoords, UnitPaneSubject,
|
||||
};
|
||||
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
|
||||
pub use uimap::UiMapNode;
|
||||
pub use unit::{
|
||||
grey_band, guid_token, level_reads_unknown, power_token, unit_is_grey, PlayerRecord,
|
||||
SelectionRequest, UnitCastState, UnitState,
|
||||
|
||||
@@ -718,13 +718,28 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// missing global was 12,570 errors in one session — the largest single source in the log.
|
||||
"ItemLocation",
|
||||
"ItemLocationMixin",
|
||||
// ClassicAPI's C_Map (script/compat/classic_api/map.rs), the three members backed by the
|
||||
// world-map catalog `ui_world_map.rs` already pushes. 1.12 answers only about the map being
|
||||
// *viewed* and only about overlays already explored, so the shipped table — zone yard
|
||||
// sizes, map-folder area ids, every sub-area rect fogged or not — has no 1.12 spelling at
|
||||
// all. This install's pfQuest has deleted its own zone-size and subzone tables in favour
|
||||
// of these.
|
||||
// ClassicAPI's C_Map (script/compat/classic_api/map.rs and uimap.rs). 1.12 answers only
|
||||
// about the map being *viewed* and only about overlays already explored, so the shipped
|
||||
// table — zone yard sizes, map-folder area ids, every sub-area rect fogged or not — has no
|
||||
// 1.12 spelling at all. This install's pfQuest has deleted its own zone-size and subzone
|
||||
// tables in favour of these. `uimap.rs` adds the other half, the uiMapID graph: 1.12
|
||||
// addresses a map by a *pair of dropdown indices*, which is a position in two lists and
|
||||
// moves when a zone is inserted, and it publishes no map's rectangle on any other map at
|
||||
// all — so a seamless, pannable map cannot be written against it. Both halves read the one
|
||||
// catalog `ui_world_map.rs` pushes out of the realm's own DBCs.
|
||||
"C_Map",
|
||||
// The exploration overlays for an arbitrary map, which is the half of
|
||||
// `GetNumMapOverlays`/`GetMapOverlayInfo` 1.12 withholds: those answer only about the map
|
||||
// being viewed, so a map stitched from every zone at once has no way to ask. Same rows,
|
||||
// same reveal gate, published in the shape the canvas framework reads
|
||||
// (script/compat/classic_api/uimap.rs).
|
||||
"C_MapExplorationInfo",
|
||||
// The two argument *shapes* `C_Map`'s uiMapID members take. 1.12 passes coordinates as
|
||||
// loose numbers and has no vector or map-point object; a caller cannot call
|
||||
// `C_Map.SetUserWaypoint` or `C_Map.GetWorldPosFromMapPos` without them, and every call
|
||||
// site in the corpus feature-tests the pair first (`Map/Nav.lua:216`).
|
||||
"CreateVector2D",
|
||||
"UiMapPoint",
|
||||
// ClassicAPI's C_TaxiMap (script/compat/classic_api/taxi_map.rs), its one installed
|
||||
// member: `GetTaxiNodesForMap`, the whole `TaxiNodes.dbc` table resolved to zones and map
|
||||
// percentages. 1.12 has no spelling for it even in principle — its taxi verbs
|
||||
|
||||
@@ -19,6 +19,7 @@ fn push_catalog(s: &mut UiScript) {
|
||||
WorldMapContinentView {
|
||||
name: "Kalimdor".into(),
|
||||
map_file: "Kalimdor".into(),
|
||||
map_id: 1,
|
||||
world_rect: (0.08882, 0.07910, 0.40020, 0.86952),
|
||||
loc_rect: (17066.6, -19733.2, 12799.9, -11733.3),
|
||||
zone_grid: Vec::new(),
|
||||
@@ -42,6 +43,7 @@ fn push_catalog(s: &mut UiScript) {
|
||||
WorldMapContinentView {
|
||||
name: "Eastern Kingdoms".into(),
|
||||
map_file: "Azeroth".into(),
|
||||
map_id: 0,
|
||||
world_rect: (0.62375, 0.02695, 0.92315, 0.87126),
|
||||
loc_rect: (16000.0, -19199.9, 7466.6, -16000.0),
|
||||
zone_grid: ek_grid,
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
//! The uiMapID graph: one id per map in the realm's own `WorldMapArea.dbc`, with each map's
|
||||
//! rectangle on its parent and on the world sheet.
|
||||
//!
|
||||
//! 1.12 has no such thing. Its map is addressed by a `(continent, zone)` pair of **dropdown
|
||||
//! indices** ([`super::worldmap`]), which is a position in two lists and not a name: insert a zone
|
||||
//! and every index after it moves. Every modern map API — `C_Map`, `C_MapExplorationInfo`, the
|
||||
//! canvas framework addons written after 4.0 expect — is addressed by a single stable `uiMapID`
|
||||
//! instead, and carries a *tree*: the world holds continents, a continent holds zones, and each
|
||||
//! child knows its rectangle on its parent. That tree is what lets a map be drawn seamlessly, at
|
||||
//! any zoom, across zone borders — which is the whole point of an addon like OpenMapClassic.
|
||||
//!
|
||||
//! **This is built from the realm's catalog, not from Era's tables**, and that is the deliberate
|
||||
//! part. OctoWoW's `WorldMapArea.dbc` carries 175 rows against stock 1.12's 51: it has re-tuned 49
|
||||
//! of the shipped rows' world rectangles and added 124 maps of its own (`Alah'Thalas`, `Balor`,
|
||||
//! `Blackstone Isle`, `Gilneas`, `Grim Reaches`, `Icepoint`, `Lapidis`, `Moonwhisper`, `Northwind`,
|
||||
//! `Tel'Abim`, `Thalassian Highlands`, the Timbermaw tunnels, Hyjal…). A graph transcribed from
|
||||
//! Classic Era would describe a different world: the right shape and the wrong continent.
|
||||
//!
|
||||
//! **Where Era's own ids are reused, and why.** An addon written for Era ships data keyed by
|
||||
//! uiMapID — level ranges, colour grading, flight paths, place names. Those tables are about
|
||||
//! *vanilla zones*, which this realm still has, so a zone that is the same zone should carry the
|
||||
//! same id or that data cannot find it. [`ERA_UI_MAP_IDS`] is that join, keyed on the
|
||||
//! `WorldMapArea` **art folder** rather than the zone's name, because the folder is the one column
|
||||
//! a realm has no reason to touch — this realm spells Azshara's folder `Aszhara` and Hillsbrad's
|
||||
//! `Hilsbrad`, exactly as 1.12 did, while its localized names are the ordinary ones. A row with no
|
||||
//! Era counterpart is **minted** ([`minted_zone_id`]), deterministically from its `AreaTable` id,
|
||||
//! so a saved per-map setting survives a session and an Era table simply does not have a row for
|
||||
//! it.
|
||||
//!
|
||||
//! The ids themselves are evidenced, not guessed: they are read off the data files of the addon
|
||||
//! that consumes them, which carry the zone name in a comment beside each one
|
||||
//! (`Data/Levels.lua`, `Data/Grade.lua`, `Data/Tones.lua`), plus that addon's own source constants
|
||||
//! for the world sheet (`Map/Stitch.lua:56`, `WORLD = 947`), the two continents
|
||||
//! (`Map/Close.lua:85`, `CONT_OF = { [1415] = 0, [1414] = 1 }`) and three of the cities
|
||||
//! (`Map/Stitch.lua:52`). The remaining three cities are the gap that block leaves.
|
||||
|
||||
use super::worldmap::{WorldMapContinentView, WorldMapState, WorldMapZoneView};
|
||||
|
||||
/// The world sheet's uiMapID — Era's `Azeroth`, the root every continent hangs off.
|
||||
pub const WORLD_UI_MAP_ID: u32 = 947;
|
||||
|
||||
/// The world sheet's art folder. 1.12 draws it from `Interface\WorldMap\World\World1..12` and
|
||||
/// `GetMapInfo()` answers nil there, so there is no catalog row to read it off; the folder is the
|
||||
/// name the shipped art has.
|
||||
pub const WORLD_ART_DIR: &str = "World";
|
||||
|
||||
/// The `Enum.UIMapType` values this graph files a map under. The modern enum has three more
|
||||
/// (`Cosmic` 0, `Micro` 5, `Orphan` 6) and all seven names are published to Lua
|
||||
/// ([`crate::script::compat`]'s `enums.rs`), because a filter compares against the name; nothing
|
||||
/// here *is* one of those three, so they would be dead constants on this side.
|
||||
pub mod map_type {
|
||||
pub const WORLD: u8 = 1;
|
||||
pub const CONTINENT: u8 = 2;
|
||||
pub const ZONE: u8 = 3;
|
||||
pub const DUNGEON: u8 = 4;
|
||||
}
|
||||
|
||||
/// Era's uiMapID for each map 1.12 also has, keyed by the `WorldMapArea` art folder.
|
||||
///
|
||||
/// Read off OpenMapClassic's own generated data (see the module doc). The 40 outdoor zones carry
|
||||
/// their name in a comment beside the id in `Data/Levels.lua`/`Grade.lua`/`Tones.lua`; the folder
|
||||
/// each name maps to is this install's own catalog, dumped through `C_Map.GetMapAreaIDs`. The
|
||||
/// cities have no row in those files — no level range, no colour band — and come from the three
|
||||
/// the addon's source names outright (`1454` Orgrimmar, `1456` Thunder Bluff, `1458` Undercity,
|
||||
/// `Map/Stitch.lua:52`) plus the three ids left in that contiguous block, which is the one reading
|
||||
/// consistent with 1411..1452 being the zones and 1414/1415 the continents.
|
||||
const ERA_UI_MAP_IDS: &[(&str, u32)] = &[
|
||||
// The two continents (`Map/Close.lua:85`).
|
||||
("Kalimdor", 1414),
|
||||
("Azeroth", 1415),
|
||||
// Kalimdor's outdoor zones.
|
||||
("Durotar", 1411),
|
||||
("Mulgore", 1412),
|
||||
("Barrens", 1413),
|
||||
("Teldrassil", 1438),
|
||||
("Darkshore", 1439),
|
||||
("Ashenvale", 1440),
|
||||
("ThousandNeedles", 1441),
|
||||
("StonetalonMountains", 1442),
|
||||
("Desolace", 1443),
|
||||
("Feralas", 1444),
|
||||
("Dustwallow", 1445),
|
||||
("Tanaris", 1446),
|
||||
// This realm keeps 1.12's spelling of the folder, which is not the zone's spelling.
|
||||
("Aszhara", 1447),
|
||||
("Felwood", 1448),
|
||||
("UngoroCrater", 1449),
|
||||
("Moonglade", 1450),
|
||||
("Silithus", 1451),
|
||||
("Winterspring", 1452),
|
||||
// Eastern Kingdoms' outdoor zones.
|
||||
("Alterac", 1416),
|
||||
("Arathi", 1417),
|
||||
("Badlands", 1418),
|
||||
("BlastedLands", 1419),
|
||||
("Tirisfal", 1420),
|
||||
("Silverpine", 1421),
|
||||
("WesternPlaguelands", 1422),
|
||||
("EasternPlaguelands", 1423),
|
||||
// 1.12's folder drops an `l`, as above.
|
||||
("Hilsbrad", 1424),
|
||||
("Hinterlands", 1425),
|
||||
("DunMorogh", 1426),
|
||||
("SearingGorge", 1427),
|
||||
("BurningSteppes", 1428),
|
||||
("Elwynn", 1429),
|
||||
("DeadwindPass", 1430),
|
||||
("Duskwood", 1431),
|
||||
("LochModan", 1432),
|
||||
("Redridge", 1433),
|
||||
("Stranglethorn", 1434),
|
||||
("SwampOfSorrows", 1435),
|
||||
("Westfall", 1436),
|
||||
("Wetlands", 1437),
|
||||
// The cities.
|
||||
("Stormwind", 1453),
|
||||
("Orgrimmar", 1454),
|
||||
("Ironforge", 1455),
|
||||
("ThunderBluff", 1456),
|
||||
("Darnassus", 1457),
|
||||
("Undercity", 1458),
|
||||
// The battlegrounds, which are `direct_areas` here rather than zones of a continent.
|
||||
("AlteracValley", 1459),
|
||||
("WarsongGulch", 1460),
|
||||
("ArathiBasin", 1461),
|
||||
// Era's own two additions that this realm also has. Hyjal is a walkable zone on both;
|
||||
// Riverglades is Era's and is listed so a realm that adds it lands on the same id.
|
||||
("Hyjal", 2482),
|
||||
("Riverglades", 2548),
|
||||
];
|
||||
|
||||
/// Where minted ids start, chosen to clear Era's whole range (its highest classic-era uiMapID is
|
||||
/// in the 2000s) by enough that a future Era addition cannot collide with one of ours.
|
||||
const MINTED_BASE: u32 = 100_000;
|
||||
|
||||
/// A map with no Era counterpart takes `MINTED_BASE + areaID`: unique because `AreaTable` ids are,
|
||||
/// stable because the id is the realm's own, and far above anything Era mints.
|
||||
fn minted_zone_id(area_id: u32) -> u32 {
|
||||
MINTED_BASE + area_id
|
||||
}
|
||||
|
||||
/// A continent with no Era counterpart, keyed on its 1-based dropdown index rather than an area id,
|
||||
/// because a continent row's `areaID` is 0 by definition (`0x4a5d00` selects continents by it).
|
||||
fn minted_continent_id(index: u32) -> u32 {
|
||||
MINTED_BASE - 1_000 + index
|
||||
}
|
||||
|
||||
/// Era's id for an art folder, or `None` when this map is the realm's own.
|
||||
fn era_id(map_file: &str) -> Option<u32> {
|
||||
ERA_UI_MAP_IDS
|
||||
.iter()
|
||||
.find(|(folder, _)| folder.eq_ignore_ascii_case(map_file))
|
||||
.map(|(_, id)| *id)
|
||||
}
|
||||
|
||||
/// One node of the graph: a map, its place in the tree, and its rectangle on both its parent and
|
||||
/// the world sheet.
|
||||
///
|
||||
/// `rect` tuples are `(left, right, top, bottom)` normalized on the named map, the order
|
||||
/// `C_Map.GetMapRectOnMap` returns them in. `loc_rect` is the raw `WorldMapArea` rect in world
|
||||
/// yards, in that DBC's own `(left, right, top, bottom)` order, where left/right are the **y**
|
||||
/// (west) axis and top/bottom the **x** (north) axis.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct UiMapNode {
|
||||
pub ui_map_id: u32,
|
||||
/// The localized map name: `AreaTable.dbc`'s for a zone, `Map.dbc`'s for a continent.
|
||||
pub name: String,
|
||||
/// One of [`map_type`].
|
||||
pub map_type: u8,
|
||||
/// The parent's uiMapID; 0 for the world sheet and for an orphan with no placeable parent.
|
||||
pub parent_ui_map_id: u32,
|
||||
/// The `AreaTable.dbc` id, 0 for a continent and the world sheet.
|
||||
pub area_id: u32,
|
||||
/// The `Interface\WorldMap\<folder>\` art directory.
|
||||
pub map_file: String,
|
||||
/// Normalized on [`WORLD_UI_MAP_ID`]; a degenerate rect means "not placeable on the world".
|
||||
pub world_rect: (f32, f32, f32, f32),
|
||||
/// Normalized on [`Self::parent_ui_map_id`].
|
||||
pub parent_rect: (f32, f32, f32, f32),
|
||||
/// `WorldMapArea`'s own rect, world yards.
|
||||
pub loc_rect: (f32, f32, f32, f32),
|
||||
/// The 1.12 `(continent, zone)` dropdown selection this node is, both 1-based, `(0, 0)` for
|
||||
/// the world sheet. `None` for a `direct_areas` row, which the reference selects by id.
|
||||
pub selection: Option<(u32, u32)>,
|
||||
/// The `WorldMapArea` row id when this node is a `direct_areas` row — the reference's third
|
||||
/// selection cell, and the only handle an instance map has.
|
||||
pub direct_area: Option<u32>,
|
||||
/// The `Map.dbc` id of the continent this node sits on, which is what
|
||||
/// `C_Map.GetWorldPosFromMapPos` answers as its `continentID`. `None` for the world sheet and
|
||||
/// for an unplaceable orphan.
|
||||
pub continent_map_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl UiMapNode {
|
||||
/// Whether this node has a usable rectangle on the world sheet.
|
||||
pub fn placed_on_world(&self) -> bool {
|
||||
let (l, r, t, b) = self.world_rect;
|
||||
r > l && b > t
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate rect: the answer for a map with no place on the sheet, which `GetMapRectOnMap`
|
||||
/// reports as nothing rather than as a zero-area rectangle at the origin.
|
||||
const NO_RECT: (f32, f32, f32, f32) = (0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
/// Un-lerp a child's yard rect through its parent's, giving the child's normalized rect on the
|
||||
/// parent. `None` when either rect is degenerate, which is the DBC's way of saying a row has no
|
||||
/// map placement (every instance-map row has a zero `loc` rect).
|
||||
fn rect_on(
|
||||
parent: (f32, f32, f32, f32),
|
||||
child: (f32, f32, f32, f32),
|
||||
) -> Option<(f32, f32, f32, f32)> {
|
||||
let (pl, pr, pt, pb) = parent;
|
||||
let (cl, cr, ct, cb) = child;
|
||||
let span_u = pl - pr;
|
||||
let span_v = pt - pb;
|
||||
if span_u == 0.0 || span_v == 0.0 || cl - cr == 0.0 || ct - cb == 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
(pl - cl) / span_u,
|
||||
(pl - cr) / span_u,
|
||||
(pt - ct) / span_v,
|
||||
(pt - cb) / span_v,
|
||||
))
|
||||
}
|
||||
|
||||
/// Lerp a normalized rect on a parent into the parent's own normalized rect on the world sheet.
|
||||
fn rect_within(outer: (f32, f32, f32, f32), inner: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
|
||||
let (ol, or_, ot, ob) = outer;
|
||||
let (il, ir, it, ib) = inner;
|
||||
let (w, h) = (or_ - ol, ob - ot);
|
||||
(ol + il * w, ol + ir * w, ot + it * h, ot + ib * h)
|
||||
}
|
||||
|
||||
/// Build the whole graph from the pushed catalog. Pure: the same catalog always gives the same
|
||||
/// graph, which is why [`super::worldmap`]'s setters can call it and keep the two in step by
|
||||
/// construction rather than by a second code path that could disagree.
|
||||
pub fn build_graph(
|
||||
continents: &[WorldMapContinentView],
|
||||
direct_areas: &[(u32, WorldMapZoneView)],
|
||||
) -> Vec<UiMapNode> {
|
||||
let mut out = Vec::with_capacity(1 + continents.len() + continents.len() * 16);
|
||||
|
||||
// The root. No `AreaTable` row and no `WorldMapArea` row: 1.12's world level is a selection
|
||||
// state, not a map row, so its rect is the unit square by definition.
|
||||
out.push(UiMapNode {
|
||||
ui_map_id: WORLD_UI_MAP_ID,
|
||||
name: "Azeroth".to_string(),
|
||||
map_type: map_type::WORLD,
|
||||
parent_ui_map_id: 0,
|
||||
area_id: 0,
|
||||
map_file: WORLD_ART_DIR.to_string(),
|
||||
world_rect: (0.0, 1.0, 0.0, 1.0),
|
||||
parent_rect: (0.0, 1.0, 0.0, 1.0),
|
||||
loc_rect: NO_RECT,
|
||||
selection: Some((0, 0)),
|
||||
direct_area: None,
|
||||
continent_map_id: None,
|
||||
});
|
||||
|
||||
for (ci, cont) in continents.iter().enumerate() {
|
||||
let index = ci as u32 + 1;
|
||||
// The world sheet's own row is in this list and is not a continent. This realm's
|
||||
// `WorldMapArea.dbc` carries a third `areaID == 0` row whose art folder is `World` — the
|
||||
// sheet itself — and the reference's catalog walk (`0x4a5d00`, which selects continents by
|
||||
// exactly that column) files it beside Kalimdor and Azeroth, so the catalog pushed here
|
||||
// has three. Measured: `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`, and because
|
||||
// the row carries `Map.dbc` id 0 its zone list is Eastern Kingdoms' own, re-collected.
|
||||
//
|
||||
// Left in, it is a phantom continent on the sheet holding a second copy of all 39 Eastern
|
||||
// Kingdoms zones: `GetMapInfoAtPosition` could walk into it, and a caller caching per-map
|
||||
// art would build every one of those zones twice. The row is kept out rather than merged,
|
||||
// because its `loc` rect is in map 0's coordinate space and the sheet spans both
|
||||
// continents — using it as the sheet's own rect would place every Kalimdor position wrong.
|
||||
if cont.map_file.eq_ignore_ascii_case(WORLD_ART_DIR) {
|
||||
continue;
|
||||
}
|
||||
let cont_id = era_id(&cont.map_file).unwrap_or_else(|| minted_continent_id(index));
|
||||
// `world_rect` on the view is `(u0, v0, u1, v1)` from the `WorldMapContinent` tile bounds;
|
||||
// the graph's order is `(left, right, top, bottom)`.
|
||||
let (u0, v0, u1, v1) = cont.world_rect;
|
||||
let cont_world = (u0, u1, v0, v1);
|
||||
out.push(UiMapNode {
|
||||
ui_map_id: cont_id,
|
||||
name: cont.name.clone(),
|
||||
map_type: map_type::CONTINENT,
|
||||
parent_ui_map_id: WORLD_UI_MAP_ID,
|
||||
area_id: 0,
|
||||
map_file: cont.map_file.clone(),
|
||||
world_rect: cont_world,
|
||||
parent_rect: cont_world,
|
||||
loc_rect: cont.loc_rect,
|
||||
selection: Some((index, 0)),
|
||||
direct_area: None,
|
||||
continent_map_id: Some(cont.map_id),
|
||||
});
|
||||
|
||||
for (zi, zone) in cont.zones.iter().enumerate() {
|
||||
let zone_id = era_id(&zone.map_file).unwrap_or_else(|| minted_zone_id(zone.area_id));
|
||||
let on_parent = rect_on(cont.loc_rect, zone.loc_rect);
|
||||
out.push(UiMapNode {
|
||||
ui_map_id: zone_id,
|
||||
name: zone.name.clone(),
|
||||
map_type: map_type::ZONE,
|
||||
parent_ui_map_id: cont_id,
|
||||
area_id: zone.area_id,
|
||||
map_file: zone.map_file.clone(),
|
||||
world_rect: on_parent.map_or(NO_RECT, |r| rect_within(cont_world, r)),
|
||||
parent_rect: on_parent.unwrap_or(NO_RECT),
|
||||
loc_rect: zone.loc_rect,
|
||||
selection: Some((index, zi as u32 + 1)),
|
||||
direct_area: None,
|
||||
continent_map_id: Some(cont.map_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The orphans: `WorldMapArea` rows on no continent's map — every instance map, and the
|
||||
// battlegrounds. They have no placement on the world sheet (their `loc` rect is in the
|
||||
// instance's own coordinate space, not a continent's), so they are reachable by id and by
|
||||
// `GetBestMapForUnit` while the player is inside one, and nothing else.
|
||||
for (row_id, zone) in direct_areas {
|
||||
let id = era_id(&zone.map_file).unwrap_or_else(|| minted_zone_id(zone.area_id));
|
||||
out.push(UiMapNode {
|
||||
ui_map_id: id,
|
||||
name: zone.name.clone(),
|
||||
map_type: map_type::DUNGEON,
|
||||
parent_ui_map_id: 0,
|
||||
area_id: zone.area_id,
|
||||
map_file: zone.map_file.clone(),
|
||||
world_rect: NO_RECT,
|
||||
parent_rect: NO_RECT,
|
||||
loc_rect: zone.loc_rect,
|
||||
selection: None,
|
||||
direct_area: Some(*row_id),
|
||||
continent_map_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
// A duplicate id would make `GetMapInfo` ambiguous and `SetMapID` land on whichever came
|
||||
// first. It can happen two ways: two rows sharing an art folder (the realm does ship such
|
||||
// pairs — several instance entrances reuse one folder), or a minted id colliding with a row
|
||||
// whose `areaID` is 0. Keep the first and renumber the rest off their position, which is
|
||||
// stable for a given catalog.
|
||||
let mut seen = std::collections::HashSet::with_capacity(out.len());
|
||||
for (i, n) in out.iter_mut().enumerate() {
|
||||
if seen.insert(n.ui_map_id) {
|
||||
continue;
|
||||
}
|
||||
let mut alt = MINTED_BASE + 10_000 + i as u32;
|
||||
while !seen.insert(alt) {
|
||||
alt += 1;
|
||||
}
|
||||
n.ui_map_id = alt;
|
||||
}
|
||||
// Rewrite the parent links the renumber above may have broken: a continent that was renumbered
|
||||
// leaves its zones pointing at an id that is now someone else's.
|
||||
let parents: Vec<(usize, u32)> = out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| (n.map_type == map_type::CONTINENT).then_some((i, n.ui_map_id)))
|
||||
.collect();
|
||||
let mut cursor = 0usize;
|
||||
for (ci, (idx, cont_id)) in parents.iter().enumerate() {
|
||||
let end = parents.get(ci + 1).map_or(out.len(), |(j, _)| *j);
|
||||
debug_assert!(*idx >= cursor);
|
||||
for n in &mut out[idx + 1..end] {
|
||||
if n.map_type == map_type::ZONE {
|
||||
n.parent_ui_map_id = *cont_id;
|
||||
}
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// The node with this uiMapID.
|
||||
pub fn node(state: &WorldMapState, ui_map_id: u32) -> Option<&UiMapNode> {
|
||||
state.uimap.iter().find(|n| n.ui_map_id == ui_map_id)
|
||||
}
|
||||
|
||||
/// The node the 1.12 `(continent, zone)` selection names, for turning the engine's own selection
|
||||
/// into a uiMapID.
|
||||
pub fn node_for_selection(state: &WorldMapState, selection: (u32, u32)) -> Option<&UiMapNode> {
|
||||
state.uimap.iter().find(|n| n.selection == Some(selection))
|
||||
}
|
||||
|
||||
/// The node for a `direct_areas` row id.
|
||||
pub fn node_for_direct_area(state: &WorldMapState, row_id: u32) -> Option<&UiMapNode> {
|
||||
state.uimap.iter().find(|n| n.direct_area == Some(row_id))
|
||||
}
|
||||
|
||||
/// Every child of a map, in catalog order. `map_type` filters to one kind when given;
|
||||
/// `all_descendants` walks the whole subtree rather than one level, which is
|
||||
/// `C_Map.GetMapChildrenInfo`'s third argument.
|
||||
pub fn children(
|
||||
state: &WorldMapState,
|
||||
ui_map_id: u32,
|
||||
want_type: Option<u8>,
|
||||
all_descendants: bool,
|
||||
) -> Vec<&UiMapNode> {
|
||||
let mut out: Vec<&UiMapNode> = Vec::new();
|
||||
let mut frontier = vec![ui_map_id];
|
||||
while let Some(parent) = frontier.pop() {
|
||||
for n in state.uimap.iter().filter(|n| n.parent_ui_map_id == parent) {
|
||||
// The world sheet's own parent is 0 and so is an orphan's: without this an orphan
|
||||
// would read as a child of the world.
|
||||
if n.ui_map_id == ui_map_id {
|
||||
continue;
|
||||
}
|
||||
if want_type.is_none_or(|t| n.map_type == t) {
|
||||
out.push(n);
|
||||
}
|
||||
if all_descendants {
|
||||
frontier.push(n.ui_map_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A world-yard position's normalized place on a map, or `None` when the map has no yard rect.
|
||||
///
|
||||
/// The yard rect's left/right are the **y** axis and top/bottom the **x** axis, so `u` comes from
|
||||
/// `y` and `v` from `x` — the same pairing [`super::worldmap`]'s `area_grid_cell` un-lerps a hover
|
||||
/// through, and the reason `GetMapWorldSize` calls the `u` span the width.
|
||||
pub fn position_on(node: &UiMapNode, world_x: f32, world_y: f32) -> Option<(f32, f32)> {
|
||||
let (left, right, top, bottom) = node.loc_rect;
|
||||
let (span_u, span_v) = (left - right, top - bottom);
|
||||
if span_u == 0.0 || span_v == 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(((left - world_y) / span_u, (top - world_x) / span_v))
|
||||
}
|
||||
|
||||
/// The inverse of [`position_on`]: a normalized place on a map back to world yards `(x, y)`.
|
||||
pub fn world_position_of(node: &UiMapNode, u: f32, v: f32) -> Option<(f32, f32)> {
|
||||
let (left, right, top, bottom) = node.loc_rect;
|
||||
let (span_u, span_v) = (left - right, top - bottom);
|
||||
if span_u == 0.0 || span_v == 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((top - v * span_v, left - u * span_u))
|
||||
}
|
||||
|
||||
/// The deepest child of `ui_map_id` holding a normalized point, one level down: the rectangles are
|
||||
/// the only test there is, and they are the ones `GetMapRectOnMap` publishes, so a caller walking
|
||||
/// down with `GetMapInfoAtPosition` and then re-normalizing lands exactly where this did.
|
||||
///
|
||||
/// First match wins. Zone rects on a continent do not overlap in the shipped data, and where a
|
||||
/// realm's added rows do overlap, catalog order is the only order there is a reason for.
|
||||
pub fn child_at(state: &WorldMapState, ui_map_id: u32, x: f32, y: f32) -> Option<&UiMapNode> {
|
||||
state
|
||||
.uimap
|
||||
.iter()
|
||||
.filter(|n| n.parent_ui_map_id == ui_map_id && n.ui_map_id != ui_map_id)
|
||||
.find(|n| {
|
||||
let (l, r, t, b) = n.parent_rect;
|
||||
r > l && b > t && (l..=r).contains(&x) && (t..=b).contains(&y)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Elwynn's and Stormwind's rows as this install ships them, and Eastern Kingdoms', so the
|
||||
/// arithmetic below is the realm's rather than a fixture's.
|
||||
fn eastern_kingdoms() -> WorldMapContinentView {
|
||||
WorldMapContinentView {
|
||||
name: "Eastern Kingdoms".to_string(),
|
||||
map_file: "Azeroth".to_string(),
|
||||
map_id: 0,
|
||||
// `continent_sheet_rect`'s answer for the shipped EK row: `(u0, v0, u1, v1)`.
|
||||
world_rect: (0.62375, 0.02695, 0.92315, 0.87126),
|
||||
loc_rect: (17066.666, -17066.666, 12800.0, -12800.0),
|
||||
zone_grid: Vec::new(),
|
||||
zones: vec![
|
||||
WorldMapZoneView {
|
||||
name: "Elwynn Forest".to_string(),
|
||||
area_id: 12,
|
||||
map_file: "Elwynn".to_string(),
|
||||
loc_rect: (1535.4166, -1689.5834, -8449.0, -10598.0),
|
||||
overlays: Vec::new(),
|
||||
},
|
||||
WorldMapZoneView {
|
||||
name: "Stormwind City".to_string(),
|
||||
area_id: 1519,
|
||||
map_file: "Stormwind".to_string(),
|
||||
loc_rect: (1008.0, -195.0, -8313.0, -9115.0),
|
||||
overlays: Vec::new(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graph_reuses_eras_ids_for_the_maps_era_also_has() {
|
||||
let g = build_graph(&[eastern_kingdoms()], &[]);
|
||||
let id = |folder: &str| {
|
||||
g.iter()
|
||||
.find(|n| n.map_file == folder)
|
||||
.map(|n| n.ui_map_id)
|
||||
.unwrap_or_else(|| panic!("no node for {folder}"))
|
||||
};
|
||||
assert_eq!(id("World"), 947, "the root is Era's Azeroth");
|
||||
assert_eq!(id("Azeroth"), 1415, "the art folder, not the zone name");
|
||||
assert_eq!(id("Elwynn"), 1429);
|
||||
assert_eq!(id("Stormwind"), 1453);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_the_realm_added_is_minted_from_its_own_area_id() {
|
||||
let mut ek = eastern_kingdoms();
|
||||
ek.zones.push(WorldMapZoneView {
|
||||
name: "Alah'Thalas".to_string(),
|
||||
area_id: 2040,
|
||||
map_file: "AlahThalas".to_string(),
|
||||
loc_rect: (4000.0, 1000.0, -1000.0, -4000.0),
|
||||
overlays: Vec::new(),
|
||||
});
|
||||
let g = build_graph(&[ek], &[]);
|
||||
let node = g
|
||||
.iter()
|
||||
.find(|n| n.map_file == "AlahThalas")
|
||||
.expect("the realm's own zone is in the graph");
|
||||
assert_eq!(
|
||||
node.ui_map_id, 102_040,
|
||||
"a map Era does not have is minted from its AreaTable id, clear of Era's range"
|
||||
);
|
||||
assert_eq!(node.parent_ui_map_id, 1415);
|
||||
assert_eq!(node.map_type, map_type::ZONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_sits_inside_its_continents_rect_on_the_world_sheet() {
|
||||
let g = build_graph(&[eastern_kingdoms()], &[]);
|
||||
let cont = node_by_id(&g, 1415);
|
||||
let elwynn = node_by_id(&g, 1429);
|
||||
let (cl, cr, ct, cb) = cont.world_rect;
|
||||
let (zl, zr, zt, zb) = elwynn.world_rect;
|
||||
assert!(
|
||||
zl >= cl && zr <= cr && zt >= ct && zb <= cb,
|
||||
"Elwynn {:?} must lie inside Eastern Kingdoms {:?}",
|
||||
elwynn.world_rect,
|
||||
cont.world_rect
|
||||
);
|
||||
// Elwynn is in the southern half of Eastern Kingdoms, which the shipped rects do say.
|
||||
// Nothing is asserted about the other axis: its span straddles the continent's midpoint
|
||||
// (0.760..0.788 against a midpoint of 0.7735), so "east of the middle" would be a claim
|
||||
// about a tenth of a percent, not about the map.
|
||||
assert!(zt > (ct + cb) / 2.0, "{:?}", elwynn.world_rect);
|
||||
// And the world rect is exactly the parent rect composed into the continent's, which is
|
||||
// the law `C_Map.GetMapRectOnMap` composes a deeper chain with.
|
||||
let (pl, pr, pt, pb) = elwynn.parent_rect;
|
||||
let (w, h) = (cr - cl, cb - ct);
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1e-6;
|
||||
assert!(
|
||||
near(zl, cl + pl * w)
|
||||
&& near(zr, cl + pr * w)
|
||||
&& near(zt, ct + pt * h)
|
||||
&& near(zb, ct + pb * h),
|
||||
"{:?} is not {:?} composed into {:?}",
|
||||
elwynn.world_rect,
|
||||
elwynn.parent_rect,
|
||||
cont.world_rect
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_position_round_trips_through_a_zones_rect() {
|
||||
let g = build_graph(&[eastern_kingdoms()], &[]);
|
||||
let elwynn = node_by_id(&g, 1429);
|
||||
// Northshire, roughly: inside Elwynn's rect.
|
||||
let (x, y) = (-9000.0f32, 100.0f32);
|
||||
let (u, v) = position_on(elwynn, x, y).expect("Elwynn has a yard rect");
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v),
|
||||
"{u} {v}"
|
||||
);
|
||||
let (bx, by) = world_position_of(elwynn, u, v).expect("and inverts");
|
||||
assert!(
|
||||
(bx - x).abs() < 0.01 && (by - y).abs() < 0.01,
|
||||
"round trip gave ({bx}, {by}) for ({x}, {y})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_child_at_a_point_is_the_zone_whose_rect_holds_it() {
|
||||
let g = build_graph(&[eastern_kingdoms()], &[]);
|
||||
let state = WorldMapState {
|
||||
uimap: g,
|
||||
..Default::default()
|
||||
};
|
||||
let elwynn = node(&state, 1429).expect("Elwynn").parent_rect;
|
||||
let (x, y) = ((elwynn.0 + elwynn.1) / 2.0, (elwynn.2 + elwynn.3) / 2.0);
|
||||
let hit = child_at(&state, 1415, x, y).expect("a zone under Elwynn's own centre");
|
||||
assert_eq!(hit.ui_map_id, 1429);
|
||||
// Stormwind's rect is inside Elwynn's on the continent, so walking down one more level
|
||||
// from Elwynn's own frame finds nothing: the graph is a tree, and Stormwind is Elwynn's
|
||||
// sibling here, not its child.
|
||||
assert!(child_at(&state, 1429, 0.5, 0.5).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orphan_is_reachable_by_id_and_is_not_a_child_of_the_world() {
|
||||
let g = build_graph(
|
||||
&[eastern_kingdoms()],
|
||||
&[(
|
||||
42,
|
||||
WorldMapZoneView {
|
||||
name: "Alterac Valley".to_string(),
|
||||
area_id: 2597,
|
||||
map_file: "AlteracValley".to_string(),
|
||||
loc_rect: (0.0, 0.0, 0.0, 0.0),
|
||||
overlays: Vec::new(),
|
||||
},
|
||||
)],
|
||||
);
|
||||
let state = WorldMapState {
|
||||
uimap: g,
|
||||
..Default::default()
|
||||
};
|
||||
let av = node(&state, 1459).expect("Alterac Valley keeps Era's id");
|
||||
assert_eq!(av.direct_area, Some(42));
|
||||
assert!(!av.placed_on_world());
|
||||
assert!(
|
||||
!children(&state, WORLD_UI_MAP_ID, None, true)
|
||||
.iter()
|
||||
.any(|n| n.ui_map_id == 1459),
|
||||
"an orphan's parent is 0, and so is the world's — it must not read as the world's child"
|
||||
);
|
||||
assert_eq!(
|
||||
children(&state, WORLD_UI_MAP_ID, Some(map_type::CONTINENT), false).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
children(&state, WORLD_UI_MAP_ID, None, true).len(),
|
||||
3,
|
||||
"one continent and its two zones"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_sheets_own_catalog_row_is_not_a_continent() {
|
||||
// This realm's third `areaID == 0` row: art folder `World`, `Map.dbc` 0, and so the same
|
||||
// zone list Eastern Kingdoms has. Left in it would be a phantom continent carrying a
|
||||
// duplicate of every Eastern Kingdoms zone.
|
||||
let mut phantom = eastern_kingdoms();
|
||||
phantom.name = "Eastern Kingdoms".to_string();
|
||||
phantom.map_file = "World".to_string();
|
||||
let g = build_graph(&[eastern_kingdoms(), phantom], &[]);
|
||||
assert_eq!(
|
||||
g.iter()
|
||||
.filter(|n| n.map_type == map_type::CONTINENT)
|
||||
.count(),
|
||||
1,
|
||||
"only Eastern Kingdoms is a continent: {:?}",
|
||||
g.iter()
|
||||
.filter(|n| n.map_type == map_type::CONTINENT)
|
||||
.map(|n| (n.ui_map_id, n.map_file.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
g.iter().filter(|n| n.map_file == "Elwynn").count(),
|
||||
1,
|
||||
"and Elwynn is in the graph once"
|
||||
);
|
||||
// The sheet is still there, with its own id, and still the root.
|
||||
let world = node_by_id(&g, WORLD_UI_MAP_ID);
|
||||
assert_eq!(world.map_type, map_type::WORLD);
|
||||
assert_eq!(world.map_file, WORLD_ART_DIR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_rows_sharing_an_art_folder_do_not_share_an_id() {
|
||||
let mut ek = eastern_kingdoms();
|
||||
// The realm does ship pairs like this; without the renumber both would answer to 1429.
|
||||
ek.zones.push(WorldMapZoneView {
|
||||
name: "Elwynn Forest Annex".to_string(),
|
||||
area_id: 9001,
|
||||
map_file: "Elwynn".to_string(),
|
||||
loc_rect: (10.0, -10.0, -10.0, -20.0),
|
||||
overlays: Vec::new(),
|
||||
});
|
||||
let g = build_graph(&[ek], &[]);
|
||||
let mut ids: Vec<u32> = g.iter().map(|n| n.ui_map_id).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), before, "every node has its own id");
|
||||
assert_eq!(
|
||||
g.iter().filter(|n| n.map_file == "Elwynn").count(),
|
||||
2,
|
||||
"and both rows are still there"
|
||||
);
|
||||
}
|
||||
|
||||
fn node_by_id(g: &[UiMapNode], id: u32) -> &UiMapNode {
|
||||
g.iter()
|
||||
.find(|n| n.ui_map_id == id)
|
||||
.unwrap_or_else(|| panic!("no node {id}"))
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,12 @@ pub struct WorldMapContinentView {
|
||||
/// `Map.dbc`'s localized MapName, the dropdown label (`0x4a65a0`), not the art folder.
|
||||
pub name: String,
|
||||
pub map_file: String,
|
||||
/// The `Map.dbc` id this continent's `WorldMapArea` row names (0 Eastern Kingdoms, 1 Kalimdor
|
||||
/// as 1.12 ships them). Nothing the map *window* draws reads it — the window addresses a
|
||||
/// continent by its 1-based dropdown index — but it is what `C_Map.GetWorldPosFromMapPos`
|
||||
/// answers as its `continentID`, and the index is not that id: this install's own dropdown
|
||||
/// lists Kalimdor first, so index 1 is `Map.dbc` 1 and index 2 is `Map.dbc` 0.
|
||||
pub map_id: u32,
|
||||
/// The rect on the world sheet in UV `(u0, v0, u1, v1)`, from the `WorldMapContinent` tile
|
||||
/// bounds (`0x4a5d00`); a world-level click takes the first that holds it (`0x4a7100`).
|
||||
pub world_rect: (f32, f32, f32, f32),
|
||||
@@ -209,6 +215,15 @@ pub struct WorldMapState {
|
||||
/// Empty until pushed and on a run with no install, which `GetAreas` answers as an empty
|
||||
/// table — the same thing a caller sees today.
|
||||
pub areas: std::collections::BTreeMap<u32, String>,
|
||||
/// `C_Map.SetUserWaypoint`'s single map pin: `(uiMapID, x, y)`, normalized on that map.
|
||||
/// Engine state rather than a per-call answer because the modern client's pin is state — a
|
||||
/// caller sets it, reads it back and clears it, and the three must agree.
|
||||
pub user_waypoint: Option<(u32, f32, f32)>,
|
||||
/// The uiMapID graph over [`Self::continents`] and [`Self::direct_areas`]
|
||||
/// ([`crate::script::uimap`]), rebuilt by whichever of the two setters wrote last so it cannot
|
||||
/// describe a catalog that is no longer there. Empty until the catalog is pushed, which is
|
||||
/// what `C_Map.GetMapInfo` answers as nil — the same thing an unknown id answers.
|
||||
pub uimap: Vec<super::uimap::UiMapNode>,
|
||||
}
|
||||
|
||||
fn explored_bit(explored: &[u32], n: u32) -> bool {
|
||||
@@ -296,6 +311,39 @@ fn continent_hover(cont: &WorldMapContinentView, zone: &WorldMapZoneView) -> Opt
|
||||
})
|
||||
}
|
||||
|
||||
/// The continent-level highlight's numbers for one zone, for `C_Map`'s uiMapID-addressed
|
||||
/// `GetMapHighlightInfoAtPosition` ([`crate::script::compat`]). It reads the same
|
||||
/// [`continent_hover`] the stock window's own `UpdateMapHighlight` reads, so the outline the
|
||||
/// canvas lights up and the one the stock window lights up are the same rectangle by construction.
|
||||
pub(in crate::script) struct ZoneHighlight {
|
||||
/// The art folder; the highlight file is `<folder>\<folder>Highlight`.
|
||||
pub file: String,
|
||||
pub tex_pct_y: f64,
|
||||
pub texture: (f64, f64),
|
||||
pub scroll: (f64, f64),
|
||||
}
|
||||
|
||||
pub(in crate::script) fn continent_highlight(
|
||||
cont: &WorldMapContinentView,
|
||||
zone: &WorldMapZoneView,
|
||||
) -> Option<ZoneHighlight> {
|
||||
match continent_hover(cont, zone)? {
|
||||
Hover::Highlight {
|
||||
file,
|
||||
tex_pct_y,
|
||||
texture,
|
||||
scroll,
|
||||
..
|
||||
} => Some(ZoneHighlight {
|
||||
file,
|
||||
tex_pct_y,
|
||||
texture,
|
||||
scroll,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x4a7fa0`'s level dispatch. Continent level: the grid's zone highlights. Zone level: the
|
||||
/// overlay pre-search, then the grid through the displayed zone's rect (`0x4a7620`), naming a
|
||||
/// neighbour but never the displayed zone, name only (`0x4a812e`). World level: the tail, as the
|
||||
@@ -405,6 +453,10 @@ impl super::UiScript {
|
||||
{
|
||||
let mut model = self.model_mut();
|
||||
model.worldmap.continents = continents;
|
||||
model.worldmap.uimap = crate::script::uimap::build_graph(
|
||||
&model.worldmap.continents,
|
||||
&model.worldmap.direct_areas,
|
||||
);
|
||||
model
|
||||
.pending_events
|
||||
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
|
||||
@@ -433,6 +485,10 @@ impl super::UiScript {
|
||||
pub fn set_world_map_direct_areas(&mut self, direct_areas: Vec<(u32, WorldMapZoneView)>) {
|
||||
let mut model = self.model_mut();
|
||||
model.worldmap.direct_areas = direct_areas;
|
||||
model.worldmap.uimap = crate::script::uimap::build_graph(
|
||||
&model.worldmap.continents,
|
||||
&model.worldmap.direct_areas,
|
||||
);
|
||||
model
|
||||
.pending_events
|
||||
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
|
||||
|
||||
@@ -458,6 +458,7 @@ a loot roll"; it does not, because that module never runs.** The call path was n
|
||||
| `C_LootHistory` (4 members) | not built, **unreachable** | Needs the three `LOOT_HISTORY_*` events as well as the namespace — the module's `FullUpdate` returns unless its frame is shown, and only those events show it. The data is on the wire and already parsed (`LootStartRoll`/`LootRoll`/`LootRollWon` carry the roll key, the roller, the dice value, the vote and the winner), so a real history is buildable: accumulate per `(looted_target, item_slot)`, resolve roller GUIDs to name/class, fire the three events. A feature worth doing on its own, not an error to patch |
|
||||
| `C_TaxiMap.GetTaxiNodesForMap` — **the flight-master layer** | done | `compat/classic_api/taxi_map.rs` over a new feed, `ui_taxi/map_nodes.rs`; supersedes this member in the "not built" row below, which stands for the rest of its list. The guard that row cites is exactly why it mattered: pfQuest *is* guarded, so the absence produced no error at all — `pfDatabase:SearchFlightNodes` returned an empty table, the user's "flight masters" option stayed ticked, and nothing was ever drawn. Same silent-by-construction shape as `C_Map.GetAreas` above, and the same consumer. This install's pfQuest has **deleted** its shipped `pfDB.meta.flight` table for this call (`db/meta.lua:181` is a stub whose comment says the data "always comes live from the client (ClassicAPI)"), so there is no fallback behind it. **The contract is half read off the DLL and half off the caller**, and the file says which is which: `C_TaxiMap`, `GetTaxiNodesForMap`, `nodeID`, `faction`, `reachable`, `state` and the `FlightPathFaction`/`FlightPathState` enum names are all in the bundled `ClassicAPI.dll`'s string pool; the `areaID`/`mapX`/`mapY` triple is **not** separately visible there (the pool holds one copy of each string and `C_Map.GetAreaTriggerInfo` already uses all three), so for those the authority is `SearchFlightNodes` itself — it reads all six fields, gates on `if show and node.areaID and node.mapX and node.mapY`, and feeds the pair into `pfMap:AddNode` as `meta.x`/`meta.y`, which `UpdateNodes` divides by 100, so they are **percentages of the zone rect**. Faction comes from the two `MountCreatureID` columns, which is the reference's own gate (the taxi window asks for the mount of the player's half, `0x4a2650`): one column filled is that faction, both is neutral. A row with **neither** is reported neutral rather than dropped, deliberately and with the reason in the code — OctoWoW ships custom flight points and a custom row with unfilled mount columns is still a flight master standing in the world, so a stray icon is the cheaper mistake than a missing feature; the seed logs that count so the choice stays visible. Zone and percentage come from `WorldMapUiData::zone_at`, which asks `map_proj::zone_uv` for containment rather than comparing rects itself, so a node is only ever attributed to a zone whose own map the window would place it inside; where rects nest (every city sits inside its parent zone) the **smallest** wins, because a flight master in Ironforge belongs on the Ironforge map, which is also the map the player is looking at while standing there. 1.12 has no verb that asks this question, so that choice is ours and is labelled as such. **Seeded before the interface loads**, beside the world-map catalog and after it (it reads those rects), and the deadline is tighter than file scope: pfQuest replays its saved tracking selections once on `PLAYER_ENTERING_WORLD` and then calls `this:UnregisterAllEvents()` (`database.lua:131-151`), so a push one frame later is a push after the only read and the layer stays empty for the session. **Not built, each for the same reason**: `GetTaxiRoute`, `GetTaxiPaths` and `GetTaxiPathWaypoints` are `TaxiPath`/`TaxiPathNode` data, which `ui_taxi/routing.rs` loads for the window's own route search and has never pushed into the VM, and nothing in the corpus calls them; `GetAllTaxiNodes` is in the pool with no caller to read its shape against, and guessing it would be inventing a contract rather than matching one. Also absent from each node, with the reason stated in the file: `reachable` and `state`, which are session facts the open window owns (`model.taxi`, which exists only while a flight master has the frame up) — answering `reachable = false` for all 60 nodes because no window is open would be a confident wrong answer, where an absent field is a truthful "this build does not say" |
|
||||
| `C_EncodingUtil` (8), `C_FriendList.SetFriendNotes`, `C_Item.GetCurrentItemLevel`, `C_Map.GetAreaTriggerInfo`, `C_SpellBook.GetSkillLineRank`, `C_TaxiMap.GetTaxiNodesForMap`, `C_Spell.IsSpellInRange`/`IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`ResetsMeleeSwing`, `C_Item.RequestLoadItemDataByID`, `C_LootHistory.*` | not built | Each either guarded by its caller (all of pfQuest's) or behind a user action not yet taken: `SetFriendNotes` on editing a friend note, `SerializeJSON` on sharing a config, `GetCurrentItemLevel` behind `if ShaguScore` (an addon not installed), the hunter range pair on a hunter's bar. None raises in ordinary play as installed |
|
||||
| `C_Map`'s uiMapID half (14 members), `C_MapExplorationInfo`, `Enum.UIMapType`, `CreateVector2D`, `UiMapPoint` — **the map graph** | done | `script/uimap.rs` builds the graph, `compat/classic_api/uimap.rs` publishes it; supersedes the "nine waypoint members, the layered-art trio, the four-member projection family" clause of the `C_Map.GetAreas`/`GetAreaTriggerInfo` row above. **What 1.12 withholds is not a getter, it is the tree.** Its map is addressed by a `(continent, zone)` pair of *dropdown indices* — a position in two lists, which moves when a zone is inserted — and it publishes no map's rectangle on any other map at all, because the window draws one zone at a time into a fixed 1002x668 frame and never needs to know where that zone sits. A seamless, pannable map is exactly that missing relation: one rectangle per map composed up to a common root. **Built from the realm's own catalog, which is the point.** OctoWoW's `WorldMapArea.dbc` carries 175 rows against stock 1.12's 51 — 124 added (`Alah'Thalas`, `Balor`, `Blackstone Isle`, `Gilneas`, `Grim Reaches`, `Icepoint`, `Lapidis Isle`, `Moonwhisper`, `Northwind`, `Tel'Abim`, `Thalassian Highlands`, the Timbermaw tunnels, Hyjal) and 49 of the shipped rows' world rects re-tuned, measured with `dbc_diff`. A graph transcribed from Classic Era would be the right shape describing a different world. No new DBC read: the graph is a pure function of the catalog `ui_world_map.rs` already pushes, rebuilt inside `set_world_map_catalog`/`set_world_map_direct_areas` so the two cannot disagree. **Era's ids are reused where the map is the same map**, keyed on the `WorldMapArea` **art folder** rather than the zone name, because the folder is the column a realm has no reason to touch — this realm spells Azshara's `Aszhara` and Hillsbrad's `Hilsbrad`, exactly as 1.12 does, while its localized names are the ordinary ones. That join is what lets an Era addon's own uiMapID-keyed data (level ranges, colour grading, flight paths) find this realm's zones. The ids are **read off the consumer's own data files**, which carry the zone name in a comment beside each (`Data/Levels.lua`, `Data/Grade.lua`, `Data/Tones.lua` give the 40 outdoor zones) plus its source constants for the sheet (`WORLD = 947`), the two continents (`CONT_OF = { [1415] = 0, [1414] = 1 }`) and three cities; the other three cities are the gap that contiguous block leaves. A row with no Era counterpart is minted at `100000 + areaID` — unique because `AreaTable` ids are, stable because the id is the realm's own, and clear of anything Era can add. **One realm quirk had to be handled and it is measurable:** this install's `WorldMapArea.dbc` carries a *third* `areaID == 0` row whose art folder is `World` — the world sheet's own row — and the reference's catalog walk (`0x4a5d00`, which selects continents by exactly that column) files it beside Kalimdor and Azeroth. `SetMapZoom(3, 0)` then `GetMapInfo()` answers `World`, and because the row carries `Map.dbc` id 0 its zone list is Eastern Kingdoms' own re-collected, so left in it is a phantom continent holding a second copy of all 39 Eastern Kingdoms zones. It is kept out of the graph rather than merged into the sheet, because its `loc` rect is in map 0's coordinate space while the sheet spans both continents. **Art is answered as paths, not fileIDs**: `GetMapArtLayerTextures` hands back `Interface\WorldMap\<folder>\<folder>N`, so the asset chain serves whichever copy wins — on this install the upscaled pack, `patch-M.mpq`, three copies deep. The 1002x668 / 256x256 layer geometry is the **logical** size, not the shipped pixel size, because the drawn fractions are normalized and a uniform upscale leaves them unchanged; that is the same invariance `map.rs`'s `art_scale` has to *correct* for, and only because `GetMapOverlays` publishes absolute pixels. `C_MapExplorationInfo.GetExploredMapTextures` is the same `WorldMapOverlay` rows and the same reveal gate the stock window draws (`WorldMapFrame.lua:107-160`), in the shape a canvas reads. **Not built, listed rather than stubbed**: `GetMapPosFromWorldPos` (an inverse search over every rect, no caller), `GetMapGroupID`/`GetMapLinksForMap`/`GetBountySetIDForMap` (about maps this client has no concept of) and `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`. A member answering a plausible-looking nothing is worse than an absent one, which `if C_Map.X then` can see. **One harness gap closed with it**: `seat_a_session` now seats the player a world *position* and the per-frame map feed the live client pushes every frame, because every verb that answers *where* the player is reads the position rather than the zone name — with none answering, a map addon draws its art and no player arrow, which the survey's "drew something" column cannot tell from drawing nothing. Corpus roster unchanged by both: 85 ok / 1 fail, digest identical |
|
||||
|
||||
|
||||
## The offline UI-load harness — why 349 tests were failing, and what is fixed
|
||||
|
||||
Reference in New Issue
Block a user