Both over DBCs 1.12 reads C-side and publishes to Lua nowhere, so both
are facts no addon on this realm could reach.
`C_Item.GetItemSetInfo`: the tooltip prints a set block and `GetItemInfo`
reports no set at all, so nothing can tell which pieces complete one. The
table's shape is its caller's own documentation (`SuperCleveRoidMacros/
ClassicAPI.lua:171-174`) — including the bonus pair being
`{ spellID, threshold }`, which is the REVERSE of the DBC's column order,
so the swap happens once in the host and not in every reader.
`C_Map.GetAreaTriggerInfo`: pfQuest's own comment says it reads trigger
geometry from this "instead of a shipped coordinate table", and the three
fields it takes — areaID, mapX, mapY — go straight into `pfMap:AddNode`,
whose coordinates are 0-100. So the percentages are the consumer's
measured shape rather than a guess. The zone resolution is the host's,
through the same `zone_uv` law the map window's own hover uses
(`WorldMapUiData::zone_at`), so a trigger lands in the zone the map would
name for that point — quirk included: a point exactly on a rect's east or
south edge reads as outside, which is that law's own `(0, 0)`. The three
map fields are absent TOGETHER when the centre resolves to no zone (an
instance interior), which is exactly the case pfQuest guards for.
Both ride host probes rather than pushed tables: the resolution needs the
`WorldMapArea` rects, which do not cross the seam, and neither catalog is
something to copy per VM for a verb nothing calls at boot. Two catalogs
gained an `iter()` for it, each said to be for this.
That leaves four of the original 56. Two have no caller in the corpus;
`C_FriendList.SetFriendNotes` has no note field on the 1.12 wire at all,
so a note typed and lost at logout would be worse than a button that does
nothing; and `C_BattleNet.SendGameData` stays deliberately absent, because
every caller guards it and registering it would claim a BattleNet this
client does not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1151 lines
54 KiB
Rust
1151 lines
54 KiB
Rust
//! ClassicAPI's `C_Map`, the four members that read data already in this VM.
|
||
//!
|
||
//! The whole namespace exists because 1.12 hands Lua almost nothing about the map *tables*: the
|
||
//! stock verbs answer only about the map currently being *viewed* (`GetMapInfo`, `GetMapZones`,
|
||
//! `GetNumMapOverlays`/`GetMapOverlayInfo`), and `GetMapOverlayInfo` is further narrowed to the
|
||
//! overlays the player has already explored. An addon that wants the shipped table — every zone's
|
||
//! yard size, every map folder's area id, every sub-area rectangle whether discovered or not — has
|
||
//! no path to it at all on the reference client.
|
||
//!
|
||
//! This install's addons lean on that hard: pfQuest's shipped zone-size and subzone-placement
|
||
//! tables have been **deleted** in favour of these calls (`db/zones.lua`, `db/minimap.lua` and
|
||
//! `db/areatrigger.lua` are now stubs whose own comments say so), and pfUI's map-reveal module
|
||
//! replaced its hand-measured `pfMapOverlayData` with `C_Map.GetMapOverlays`. So an absent `C_Map`
|
||
//! is not 19 log lines' worth of damage — most call sites are guarded with
|
||
//! `if C_Map and C_Map.X then` and degrade *silently* into a pfQuest with no zone sizes and no
|
||
//! subzone index. The one unguarded site is pfUI's (`modules/mapreveal.lua:119`), which is where
|
||
//! the errors came from.
|
||
//!
|
||
//! **What is built, and why these four.** Each reads `Model::worldmap` — the catalog
|
||
//! `benilla-app`'s `ui_world_map.rs` already pushes once at world entry out of `WorldMapArea.dbc`,
|
||
//! `WorldMapOverlay.dbc` and `AreaTable.dbc`. No new DBC read and no second source that could
|
||
//! disagree with what the map window itself draws:
|
||
//!
|
||
//! - `GetMapWorldSize(areaID)` → the zone's `WorldMapArea` loc rect as a yard span. The same rect
|
||
//! `worldmap.rs`'s own `area_grid_cell` un-lerps hovers through, so the sizes an addon scales
|
||
//! its minimap pins by are the ones the map frame places its player arrow by.
|
||
//! - `GetMapAreaIDs()` → every map art folder's `AreaTable` id. The `area_id` column was in the
|
||
//! model and read by *nothing*; this is its first reader.
|
||
//! - `GetMapOverlays([areaID])` → the zone's `WorldMapOverlay` rows. **Every row, explored or
|
||
//! not** — which is the one thing that makes this different from the stock
|
||
//! `GetNumMapOverlays`/`GetMapOverlayInfo` pair rather than a rename of it. pfUI's call site
|
||
//! says so in as many words ("full overlay list for the viewed zone (explored + unexplored)")
|
||
//! and then diffs it against `GetMapOverlayInfo` to find what is still fogged; pfQuest needs the
|
||
//! full list because it is building a static subzone index, not a reveal state.
|
||
//! - `GetAreas()` → `{ [areaID] = name }` for the **whole** `AreaTable.dbc`, 1,480 rows on this
|
||
//! install, not the ~80 the catalog keeps. This one *is* a new feed
|
||
//! (`WorldMapState::areas`, pushed by `ui_world_map::seed_area_names`), because the map window
|
||
//! only ever needed the mapped rows. It is the gate on pfQuest's whole minimap half: its
|
||
//! minimap pass resolves the zone by **name** (`GetMapIDByName(GetRealZoneText())`,
|
||
//! `map.lua:1365`) where its world-map pass has a map-folder fallback (`map.lua:655-657`), so
|
||
//! an empty name table left the world map drawing pins and the minimap drawing none.
|
||
//!
|
||
//! **Not built**, each needing a feed that does not exist rather than a decision:
|
||
//! - `GetAreaTriggerInfo(id)` → `AreaTrigger.dbc` plus a zone-rect projection of the trigger's
|
||
//! world position. `AreaTriggers` is loaded app-side and never reaches the VM, and the `mapX`
|
||
//! /`mapY`/`areaID` the caller reads are a projection only the app can do.
|
||
//! - The other 22 of upstream's 27: waypoints (nine members of a later-era feature with no 1.12
|
||
//! state at all), `GetMapArtLayers`/`GetMapArtLayerTextures`/`MapHasArt` (a layered-art model
|
||
//! 1.12's single-sheet maps have no counterpart for), and the
|
||
//! `GetBestMapForUnit`/`GetMapPosFromWorldPos`/`GetWorldPosFromMapPos`/`GetMapRectOnMap`
|
||
//! projection family, which is `map_proj`'s job app-side. Two of the 22 are a different case
|
||
//! worth naming: `GetMapInfo` and `GetPlayerMapPosition` are 1.12's **own** globals and already
|
||
//! exist here (`script/worldmap.rs`), so a `C_Map.` spelling of either would be a pure alias —
|
||
//! not a gap, a naming decision, and not one to take for a caller nothing in the corpus has.
|
||
//! `docs/ADDON_COMPAT.md` carries the row.
|
||
|
||
use mlua::{Lua, MultiValue, Table, Value};
|
||
|
||
use crate::script::worldmap::{current_zone, WorldMapState, WorldMapZoneView};
|
||
use crate::script::Model;
|
||
|
||
/// The zone, city or instance map whose `AreaTable` id this is.
|
||
///
|
||
/// Both collections, because the catalog splits them the way the reference's builder does: a
|
||
/// continent's rows live in its own zone list (cities included — `ui_world_map.rs` takes *every*
|
||
/// non-continent row of the continent's map), and the handful of rows on no continent's map at all
|
||
/// (the three battlegrounds) live in `direct_areas`. A caller asking by area id does not know or
|
||
/// care which side a map landed on.
|
||
///
|
||
/// First match wins. Two `WorldMapArea` rows sharing an area id would be a malformed table, and
|
||
/// answering the first is what every other reader of this catalog already does.
|
||
fn zone_by_area_id(wm: &WorldMapState, area_id: u32) -> Option<&WorldMapZoneView> {
|
||
if area_id == 0 {
|
||
// The continent rows carry area id 0 and are not zones; `ui_world_map.rs` filters them out
|
||
// of every zone list, so nothing can match and a caller passing 0 must not get row one.
|
||
return None;
|
||
}
|
||
wm.continents
|
||
.iter()
|
||
.flat_map(|c| c.zones.iter())
|
||
.chain(wm.direct_areas.iter().map(|(_, z)| z))
|
||
.find(|z| z.area_id == area_id)
|
||
}
|
||
|
||
/// The zone an overlay query means: the one named, else the one on screen.
|
||
fn queried_zone(wm: &WorldMapState, area_id: Option<u32>) -> Option<&WorldMapZoneView> {
|
||
match area_id {
|
||
Some(id) => zone_by_area_id(wm, id),
|
||
None => current_zone(wm),
|
||
}
|
||
}
|
||
|
||
/// The reference's overlay tile stride in detail-frame pixels (`WorldMapFrame.lua:114-115, 156`):
|
||
/// a `WorldMapOverlay` row's rect is cut into 256-pixel cells, numbered row-major from 1, and the
|
||
/// `n`-th cell's art is `<texture><n>`.
|
||
const TILE: u32 = 256;
|
||
|
||
/// The drawn span of grid position `i` of `n` along an axis of total length `total`, in
|
||
/// detail-frame pixels: every cell but the last is the full [`TILE`], and the last is the
|
||
/// remainder, or a full cell when the total divides exactly. The reference's own `mod` arms
|
||
/// (`WorldMapFrame.lua:124-135, 140-151`).
|
||
fn tile_span(total: u32, i: u32, n: u32) -> u32 {
|
||
if i + 1 < n {
|
||
return TILE;
|
||
}
|
||
match total % TILE {
|
||
0 => TILE,
|
||
r => r,
|
||
}
|
||
}
|
||
|
||
/// The file size the reference *assumes* a cell's art has: the next power of two at or above its
|
||
/// drawn span, floored at 16 (`WorldMapFrame.lua:131-135`). An interior cell is exactly [`TILE`].
|
||
fn assumed_pad(span: u32) -> u32 {
|
||
let mut pad = 16;
|
||
while pad < span {
|
||
pad *= 2;
|
||
}
|
||
pad
|
||
}
|
||
|
||
/// The factor between the file sizes the reference's loop assumes and the ones this install
|
||
/// actually ships, measured off the overlay's **first** tile — the one cell whose assumed size is
|
||
/// unambiguous, since it is the full stride whenever the grid has more than one cell on that axis.
|
||
///
|
||
/// It exists because the art here is not the reference's. OctoWoW ships every
|
||
/// `Interface\WorldMap\<Zone>\` overlay re-exported at twice the authored resolution: 684 of the
|
||
/// 707 rows in this install's `WorldMapOverlay.dbc` have tile files whose dimensions are exactly
|
||
/// `2 ×` what the stock loop assumes, and **not one** matches `1 ×`. The stock loop survives that
|
||
/// only because its tex-coord fraction `span / assumed_pad` is scale-invariant — which holds right
|
||
/// up to the point where a shipped pad is *not* the assumed one times this factor, and then it
|
||
/// squashes or stretches the cell. Measuring the factor is what lets the fraction be taken against
|
||
/// the real denominator instead.
|
||
///
|
||
/// A power of two, at least 1. Taken as the larger of the two axes' ratios, and only from an axis
|
||
/// whose assumed pad clears the 16-texel floor: below it the floor flattens the ratio to 1 and the
|
||
/// axis says nothing. `None` from the probe (no host, or art that does not decode) answers 1,
|
||
/// which reduces every number below to exactly what the stock loop computes.
|
||
fn art_scale(pad: Option<(u32, u32)>, first_w: u32, first_h: u32) -> u32 {
|
||
let Some((pw, ph)) = pad else {
|
||
return 1;
|
||
};
|
||
let ratio = |measured: u32, assumed: u32| (assumed > 16).then(|| measured / assumed.max(1));
|
||
let s = ratio(pw, assumed_pad(first_w))
|
||
.into_iter()
|
||
.chain(ratio(ph, assumed_pad(first_h)))
|
||
.max()
|
||
.unwrap_or(1)
|
||
.max(1);
|
||
// Down to a power of two: a ratio that is not one means art that is not a clean re-export,
|
||
// and the tex-coord arithmetic below only stays exact on powers of two.
|
||
1 << s.ilog2()
|
||
}
|
||
|
||
/// The five numbers a row's `tiles` grid is cut from, the subset of a `WorldMapOverlay` row that
|
||
/// decides its art. Named apart from the view because the lazy `__index` below rebuilds them by
|
||
/// reading the overlay row's **own** fields back out of Lua, so there is exactly one definition of
|
||
/// what the grid depends on and the two paths cannot drift.
|
||
struct OverlayArt<'a> {
|
||
/// The full engine path, `texturePath`; a cell's file is this plus its 1-based number.
|
||
texture: &'a str,
|
||
width: u32,
|
||
height: u32,
|
||
offset_x: u32,
|
||
offset_y: u32,
|
||
}
|
||
|
||
/// Registry key of the overlay-row metatable, one per VM, whose `__index` builds `tiles` on first
|
||
/// read. See [`overlay_row_meta`].
|
||
const REG_OVERLAY_ROW_META: &str = "__benilla_cmap_overlay_row_meta";
|
||
|
||
/// The metatable every `GetMapOverlays` row carries: `tiles` is resolved on **first read**, then
|
||
/// `rawset` onto the row so a second read is a plain hit.
|
||
///
|
||
/// It is lazy because the grid costs real I/O and most callers never look at it. Building it
|
||
/// eagerly means a texel probe per cell, and the probe decodes the file (`sprite_dimensions`):
|
||
/// **1,019 tile files across this install's 707 overlay rows, 1.5 s measured** on a warm page
|
||
/// cache. Nothing paid that until `C_Map.GetAreas` landed, because the one caller that walks
|
||
/// *every* zone — pfQuest's subzone index, `database.lua:1099-1118`, which asks each area id in
|
||
/// `pfDB.zones.loc` for its overlays and reads only `areaID` and the four `hitRect*` — had an
|
||
/// empty name table to walk. With the table filled it asks all ~80 mapped zones in one go, and
|
||
/// eager tiles would be a 1.5 s stall inside pfQuest's `OnUpdate` at the first quest search.
|
||
/// pfUI's reveal (`modules/mapreveal.lua:151`) is the only reader of `tiles` and is itself gated
|
||
/// on `C.appearance.worldmap.mapreveal`, so a player with reveal off never pays it at all.
|
||
///
|
||
/// A field read is all this changes: `overlay.tiles` answers the same array it always did, and
|
||
/// `ipairs` walks it the same way. A `pairs(overlay)` would not list the key until something has
|
||
/// read it — no caller in the corpus iterates an overlay row, and both do read it by name.
|
||
fn overlay_row_meta(lua: &Lua) -> mlua::Result<Table> {
|
||
let meta = lua.create_table()?;
|
||
meta.set(
|
||
"__index",
|
||
lua.create_function(|lua, (row, key): (Table, mlua::String)| {
|
||
// Only `tiles` is deferred; every other field was written at build time, so a miss
|
||
// here is a caller's typo and must stay nil rather than become an error.
|
||
if key.as_bytes().as_ref() != b"tiles" {
|
||
return Ok(Value::Nil);
|
||
}
|
||
// Read back the row's own fields, raw: the geometry on the row *is* the geometry the
|
||
// grid is cut from, so there is no second copy to keep in step.
|
||
let texture: String = row.raw_get("texturePath")?;
|
||
let tiles = overlay_tiles(
|
||
lua,
|
||
&OverlayArt {
|
||
texture: &texture,
|
||
width: row.raw_get("textureWidth")?,
|
||
height: row.raw_get("textureHeight")?,
|
||
offset_x: row.raw_get("offsetX")?,
|
||
offset_y: row.raw_get("offsetY")?,
|
||
},
|
||
)?;
|
||
row.raw_set("tiles", &tiles)?;
|
||
Ok(Value::Table(tiles))
|
||
})?,
|
||
)?;
|
||
Ok(meta)
|
||
}
|
||
|
||
/// The `tiles` array of one overlay row: every cell of the reference's own grid that has art,
|
||
/// pre-resolved to what a caller hands `SetSize`/`SetTexCoord`/`SetPoint`/`SetTexture`.
|
||
///
|
||
/// **Why this is here and not in the caller's Lua.** The stock overlay loop
|
||
/// (`WorldMapFrame.lua:112-159`) does this arithmetic in FrameXML, so an addon drawing *unexplored*
|
||
/// overlays — which no stock verb exposes at all — has to reimplement it. pfUI's map-reveal module
|
||
/// did, and its own comment now refuses to: deriving the grid from `textureWidth`/`textureHeight`
|
||
/// alone shears this install's quirkier rows, because the loop's assumed file sizes are a
|
||
/// *prediction* where the shipped ones are a fact. So the grid shape still comes from the DBC rect
|
||
/// (it has to — see below) and every file size comes from the host's texel probe.
|
||
///
|
||
/// Each cell answers:
|
||
/// - `file` — `<texture><n>`, `n` row-major from 1, exactly the reference's numbering
|
||
/// (`WorldMapFrame.lua:157`). A cell whose file the probe cannot measure is **left out** rather
|
||
/// than listed: the stock loop calls `SetTexture` on it and silently draws nothing, and 4 rows in
|
||
/// this install ship fewer files than their rect asks for.
|
||
/// - `width`/`height` — the drawn span in detail-frame pixels, normally the rect's own; clamped to
|
||
/// `pad / scale` when the shipped art is *smaller* than the rect asks for, so the cell draws at
|
||
/// true scale instead of magnifying what art there is.
|
||
/// - `texCoordX`/`texCoordY` — the fraction of the file the cell shows, against the **measured**
|
||
/// pad. Identical to the stock loop's `span / assumed_pad` for every row whose art is a clean
|
||
/// re-export; for the rest it keeps the cell undistorted and clips the overhang, which is the
|
||
/// conservative half of the two ways to be wrong.
|
||
/// - `offsetX`/`offsetY` — the cell's top-left in the 1002×668 detail frame, pixels down and
|
||
/// right, as the reference's `SetPoint` computes it (`offset + 256 * (k - 1)`). The caller
|
||
/// negates `offsetY` itself, as the stock loop does.
|
||
///
|
||
/// **What this still cannot fix**, stated because a caller cannot tell: the grid *shape* is
|
||
/// `ceil(w / 256) × ceil(h / 256)` off the DBC rect, and cannot come from the files, because a
|
||
/// file's power-of-two pad bounds its content without determining it — a single-column, two-row
|
||
/// row (`Alterac/CORRAHNSDAGGER`) and a two-column, one-row one ship indistinguishable pad
|
||
/// sequences. 23 of this install's 707 rows have art that disagrees with their rect, in three
|
||
/// shapes: 6 ship **fewer** files than the grid asks for (those cells are omitted here), 3 ship
|
||
/// **more** (ignored — the extras are not part of this rect; 121 further rows ship extras past a
|
||
/// grid that otherwise matches exactly, so appended files are ordinary here, not a signal), and 14
|
||
/// ship the right *number* of files at the wrong *sizes* — `Icepoint/KANEQNUUN` among them, the row
|
||
/// pfUI's comment names, which has a sliver column the rect rounds away entirely and whose own
|
||
/// tiles are trimmed on the other axis too, so no consistent grid fits it at all. Those 14 keep the
|
||
/// rect's geometry with the measured denominator, which is undistorted-and-clipped rather than
|
||
/// stretched, and is as close as a pad that bounds its content can get.
|
||
fn overlay_tiles(lua: &Lua, o: &OverlayArt) -> mlua::Result<Table> {
|
||
let out = lua.create_table()?;
|
||
let (w, h) = (o.width, o.height);
|
||
let (wide, tall) = (w.div_ceil(TILE).max(1), h.div_ceil(TILE).max(1));
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let probe = |path: &str| {
|
||
model
|
||
.texture_size_probe
|
||
.as_ref()
|
||
.and_then(|probe| probe(path))
|
||
};
|
||
let scale = art_scale(
|
||
probe(&format!("{}1", o.texture)),
|
||
tile_span(w, 0, wide),
|
||
tile_span(h, 0, tall),
|
||
);
|
||
let mut n = 0;
|
||
for j in 0..tall {
|
||
for k in 0..wide {
|
||
n += 1;
|
||
let file = format!("{}{n}", o.texture);
|
||
let (span_w, span_h) = (tile_span(w, k, wide), tile_span(h, j, tall));
|
||
// Unmeasurable — a file the rect asks for that this install does not ship (6 of its
|
||
// 707 rows), or a host with no probe at all (a bare VM, the addon survey) — is **left
|
||
// out**, never listed with guessed numbers: the stock loop's answer for such a cell is
|
||
// to `SetTexture` a path that resolves to nothing and draw nothing, and a caller told
|
||
// the cell exists would acquire a texture for it instead.
|
||
let Some((pad_w, pad_h)) = probe(&file) else {
|
||
continue;
|
||
};
|
||
// `want` is the file texels the rect asks this cell for. Fits: show that fraction at
|
||
// the rect's own size. Does not fit: the art is short, so draw all of it at true scale.
|
||
let axis = |span: u32, pad: u32| {
|
||
let want = span * scale;
|
||
if want <= pad {
|
||
(span, f64::from(want) / f64::from(pad))
|
||
} else {
|
||
(pad / scale, 1.0)
|
||
}
|
||
};
|
||
let (draw_w, tc_x) = axis(span_w, pad_w);
|
||
let (draw_h, tc_y) = axis(span_h, pad_h);
|
||
if draw_w == 0 || draw_h == 0 {
|
||
continue;
|
||
}
|
||
let row = lua.create_table()?;
|
||
row.set("file", file.as_str())?;
|
||
row.set("width", draw_w)?;
|
||
row.set("height", draw_h)?;
|
||
row.set("texCoordX", tc_x)?;
|
||
row.set("texCoordY", tc_y)?;
|
||
row.set("offsetX", o.offset_x + TILE * k)?;
|
||
row.set("offsetY", o.offset_y + TILE * j)?;
|
||
out.set(out.raw_len() + 1, row)?;
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
||
let t = lua.create_table()?;
|
||
// One overlay-row metatable for the VM, parked in the registry: `GetMapOverlays` stamps every
|
||
// row it builds with it, and its `__index` resolves `tiles` on first read.
|
||
lua.set_named_registry_value(REG_OVERLAY_ROW_META, overlay_row_meta(lua)?)?;
|
||
|
||
// GetAreas() → { [areaID] = name }, every row of `AreaTable.dbc`.
|
||
//
|
||
// **The whole table, not the mapped zones.** pfQuest's own generator settles the shape: its
|
||
// extractor writes `pfDB["zones"][locale][id] = name` straight out of
|
||
// `SELECT * FROM AreaTable ORDER BY id ASC` (`toolbox/extractor.lua:2084-2104`), and this
|
||
// install has deleted the generated file (`db/<locale>/zones.lua` is a stub saying so) in
|
||
// favour of this call. So the subzone rows are not optional padding — `SearchZoneID` labels a
|
||
// node with `pfDB.zones.loc[id]` for a *sub*zone id (`database.lua:1147`), and `GetZoneIndex`
|
||
// walks the keys as the set of zones to ask for overlays (`database.lua:1104`).
|
||
//
|
||
// Keyed by id in ascending order, which is both the generator's order and the only order
|
||
// there is a reason for: the caller's reverse `name → id` index keeps the **first** id it
|
||
// meets for a name (`database.lua:285-287`), and 70 of this install's 1,480 names are carried
|
||
// by more than one row (`Westfall` by 40 and 206, both top-level). Ascending is what the
|
||
// shipped file it replaces would have produced; nothing here can make the caller's rule
|
||
// unambiguous, and inventing a "better" id for a name would be this file deciding something
|
||
// the DBC does not say.
|
||
//
|
||
// A table always, empty on a run with no install — the same answer `GetMapAreaIDs` beside it
|
||
// gives, and what the caller's `if zonenames then` already treats as "nothing to index".
|
||
t.set(
|
||
"GetAreas",
|
||
lua.create_function(|lua, ()| {
|
||
let out = lua.create_table()?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
for (id, name) in &model.worldmap.areas {
|
||
out.set(*id, name.as_str())?;
|
||
}
|
||
Ok(out)
|
||
})?,
|
||
)?;
|
||
|
||
// GetMapWorldSize(areaID) → (width, height) in yards.
|
||
//
|
||
// From the zone's `WorldMapArea` loc rect `(left, right, top, bottom)`, whose spans are
|
||
// `left - right` across the map and `top - bottom` down it — the same two spans
|
||
// `worldmap.rs`'s `area_grid_cell` computes (`span_u`, `span_v`) to walk a hover through the
|
||
// rect, so width is the u axis here for the same reason it is there. pfQuest consumes the pair
|
||
// in exactly that order, dividing its x coordinate by the first (`map.lua:1367-1371`).
|
||
//
|
||
// **No values** rather than zeros for an unknown zone *or a degenerate rect*, and the second
|
||
// half matters: pfQuest's caller tests `(w and h)` and then divides by the width, so a
|
||
// truthful `0` would become an infinite scale and put every pin at the same place. A row with
|
||
// no usable rect is a row we cannot size, which is the `nil` case, not the `0` case.
|
||
t.set(
|
||
"GetMapWorldSize",
|
||
lua.create_function(|lua, area_id: Option<u32>| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let size = area_id
|
||
.and_then(|id| zone_by_area_id(&model.worldmap, id))
|
||
.map(|z| {
|
||
let (left, right, top, bottom) = z.loc_rect;
|
||
(f64::from(left - right), f64::from(top - bottom))
|
||
})
|
||
.filter(|(w, h)| *w > 0.0 && *h > 0.0);
|
||
Ok(match size {
|
||
Some((w, h)) => MultiValue::from_vec(vec![Value::Number(w), Value::Number(h)]),
|
||
None => MultiValue::new(),
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// GetMapAreaIDs() → { [mapFileName] = areaID }, keyed by the art folder `GetMapInfo()` returns.
|
||
//
|
||
// That key is the join pfQuest needs: `GetMapZones()` enumerates only what the zone dropdown
|
||
// lists, so a city or an instance map has no index to look itself up by, and its own
|
||
// `GetMapInfo()` string is the only handle it has (`map.lua:636`). Cities are already in the
|
||
// per-continent zone lists here, so one walk of both collections covers them.
|
||
//
|
||
// First writer wins on a duplicate folder name, matching [`zone_by_area_id`]'s own rule.
|
||
t.set(
|
||
"GetMapAreaIDs",
|
||
lua.create_function(|lua, ()| {
|
||
let out = lua.create_table()?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let wm = &model.worldmap;
|
||
for zone in wm
|
||
.continents
|
||
.iter()
|
||
.flat_map(|c| c.zones.iter())
|
||
.chain(wm.direct_areas.iter().map(|(_, z)| z))
|
||
{
|
||
if zone.map_file.is_empty() || zone.area_id == 0 {
|
||
continue;
|
||
}
|
||
if out.get::<Option<u32>>(zone.map_file.as_str())?.is_none() {
|
||
out.set(zone.map_file.as_str(), zone.area_id)?;
|
||
}
|
||
}
|
||
Ok(out)
|
||
})?,
|
||
)?;
|
||
|
||
// GetMapOverlays([areaID]) → an array of overlay tables for that zone, or the viewed one.
|
||
//
|
||
// nil, not an empty table, when no zone resolves: a zone with no overlay rows (every
|
||
// instance map, the world sheet) is a real answer of "none", and "there is no such zone / no
|
||
// zone is being viewed" is a different fact. Both callers branch on it either way — pfUI
|
||
// writes `C_Map.GetMapOverlays() or {}` and pfQuest `if overlays then` — so the distinction
|
||
// costs them nothing and says something true.
|
||
//
|
||
// Field names are the call sites' own reads, which is the only specification available for
|
||
// them: `textureName`/`texturePath`/`textureWidth`/`textureHeight`/`offsetX`/`offsetY`
|
||
// (pfUI `modules/mapreveal.lua:121-127`) and `areaID` plus the four `hitRect*`
|
||
// (pfQuest `database.lua:1109-1113`). `texturePath` is the full engine path `SetTexture` takes
|
||
// and `textureName` the bare DBC leaf; they are the one string and its tail, carried
|
||
// separately so they cannot drift (`WorldMapOverlayView::texture_name`).
|
||
t.set(
|
||
"GetMapOverlays",
|
||
lua.create_function(|lua, area_id: Option<u32>| {
|
||
let meta: Table = lua.named_registry_value(REG_OVERLAY_ROW_META)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some(zone) = queried_zone(&model.worldmap, area_id) else {
|
||
return Ok(None);
|
||
};
|
||
let out = lua.create_table()?;
|
||
for (i, o) in zone.overlays.iter().enumerate() {
|
||
let row = lua.create_table()?;
|
||
row.set("textureName", o.texture_name.as_str())?;
|
||
row.set("texturePath", o.texture.as_str())?;
|
||
row.set("textureWidth", o.width)?;
|
||
row.set("textureHeight", o.height)?;
|
||
row.set("offsetX", o.offset_x)?;
|
||
row.set("offsetY", o.offset_y)?;
|
||
row.set("areaID", o.area_id)?;
|
||
// `hit_rect` is stored in the DBC's own column order, `(top, left, bottom,
|
||
// right)`; named fields here so a caller cannot mistake one edge for another.
|
||
let (top, left, bottom, right) = o.hit_rect;
|
||
row.set("hitRectTop", top)?;
|
||
row.set("hitRectLeft", left)?;
|
||
row.set("hitRectBottom", bottom)?;
|
||
row.set("hitRectRight", right)?;
|
||
// The art itself, pre-cut to the reference's own grid; see [`overlay_tiles`].
|
||
// Always an array on read and never nil, even when empty (no file measured):
|
||
// pfUI's reveal pass walks it with an unguarded `ipairs(overlay.tiles)`
|
||
// (`modules/mapreveal.lua:151`), which is where the session's reveal failure was.
|
||
// Resolved on first read rather than here — see [`overlay_row_meta`] for the 1.5 s
|
||
// that buys back now that `GetAreas` lets a caller walk every zone.
|
||
row.set_metatable(Some(meta.clone()))?;
|
||
out.set(i + 1, row)?;
|
||
}
|
||
Ok(Some(out))
|
||
})?,
|
||
)?;
|
||
|
||
// `GetAreaTriggerInfo(triggerID)` -> the trigger's geometry and where it is on the map, or
|
||
// nil for an id `AreaTrigger.dbc` does not carry.
|
||
//
|
||
// **pfQuest is the reader, and it replaces a shipped coordinate table with this**: its own
|
||
// comment says it "reads the trigger's geometry live from the client's AreaTrigger.dbc via
|
||
// ClassicAPI instead of a shipped coordinate table" (`database.lua:781-782`), and the three
|
||
// fields it takes — `areaID`, `mapX`, `mapY` — go straight into `pfMap:AddNode`, whose
|
||
// coordinates are 0-100. So the percentages are the measured shape, from the consumer.
|
||
//
|
||
// `areaID`/`mapX`/`mapY` are **absent together** when the centre resolves to no zone rect (an
|
||
// instance interior, or a trigger outside every `WorldMapArea`), which is exactly the case
|
||
// pfQuest tests for before using them. The geometry fields are always there, since they are
|
||
// the DBC row itself.
|
||
//
|
||
// Resolution is the host's, through the same `zone_uv` law the map window's own hover uses
|
||
// (`ui_world_map.rs`'s `WorldMapUiData::zone_at`), so a trigger lands in the zone the map
|
||
// would name for that point rather than in one this file decided on.
|
||
t.set(
|
||
"GetAreaTriggerInfo",
|
||
lua.create_function(|lua, trigger_id: Option<u32>| {
|
||
let found = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let probe = model.area_trigger_probe.as_ref();
|
||
trigger_id
|
||
.filter(|id| *id != 0)
|
||
.and_then(|id| probe.and_then(|p| p(id)))
|
||
};
|
||
let Some(row) = found else {
|
||
return Ok(Value::Nil);
|
||
};
|
||
let out = lua.create_table()?;
|
||
out.set("mapID", row.map_id)?;
|
||
out.set("x", f64::from(row.position[0]))?;
|
||
out.set("y", f64::from(row.position[1]))?;
|
||
out.set("z", f64::from(row.position[2]))?;
|
||
out.set("radius", f64::from(row.radius))?;
|
||
if let Some((area_id, map_x, map_y)) = row.zone {
|
||
out.set("areaID", area_id)?;
|
||
out.set("mapX", f64::from(map_x))?;
|
||
out.set("mapY", f64::from(map_y))?;
|
||
}
|
||
Ok(Value::Table(out))
|
||
})?,
|
||
)?;
|
||
|
||
Ok(t)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use crate::script::worldmap::{WorldMapContinentView, WorldMapOverlayView, WorldMapZoneView};
|
||
use crate::script::UiScript;
|
||
|
||
fn overlay(name: &str, area_id: u32) -> WorldMapOverlayView {
|
||
WorldMapOverlayView {
|
||
texture: format!("Interface\\WorldMap\\Elwynn\\{name}"),
|
||
texture_name: name.to_owned(),
|
||
area_id,
|
||
width: 256,
|
||
height: 128,
|
||
offset_x: 10,
|
||
offset_y: 20,
|
||
// An unexplored overlay: no reveal bit is set in the (empty) explored bitset, which is
|
||
// what makes it invisible to `GetMapOverlayInfo` and visible to `GetMapOverlays`.
|
||
explore_bits: vec![7],
|
||
hit_rect: (1, 2, 3, 4),
|
||
area_name: Some("Goldshire".to_owned()),
|
||
}
|
||
}
|
||
|
||
fn seeded() -> UiScript {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_world_map_catalog(vec![WorldMapContinentView {
|
||
name: "Azeroth".to_owned(),
|
||
map_file: "Azeroth".to_owned(),
|
||
zones: vec![
|
||
WorldMapZoneView {
|
||
name: "Elwynn Forest".to_owned(),
|
||
area_id: 12,
|
||
map_file: "Elwynn".to_owned(),
|
||
// left > right and top > bottom, as every shipped row is.
|
||
loc_rect: (-8000.0, -11600.0, 5300.0, 2900.0),
|
||
overlays: vec![overlay("GOLDSHIRE", 87), overlay("NORTHSHIRE", 9)],
|
||
},
|
||
WorldMapZoneView {
|
||
name: "Stormwind City".to_owned(),
|
||
area_id: 1519,
|
||
map_file: "Stormwind".to_owned(),
|
||
loc_rect: (0.0, 0.0, 0.0, 0.0),
|
||
overlays: Vec::new(),
|
||
},
|
||
],
|
||
..Default::default()
|
||
}]);
|
||
s
|
||
}
|
||
|
||
/// The rect's two spans, in the order pfQuest divides by them.
|
||
#[test]
|
||
fn get_map_world_size_is_the_loc_rects_two_spans_in_yards() {
|
||
let s = seeded();
|
||
assert_eq!(
|
||
s.eval::<(f64, f64)>("return C_Map.GetMapWorldSize(12)")
|
||
.unwrap(),
|
||
(3600.0, 2400.0),
|
||
"width across the map (left - right), height down it (top - bottom)"
|
||
);
|
||
}
|
||
|
||
/// pfQuest's `(w and h)` guard then divides by the width, so a degenerate rect must answer
|
||
/// nothing rather than a truthful zero — `mapZoom / 0` puts every pin in one place.
|
||
#[test]
|
||
fn a_degenerate_or_unknown_zone_answers_no_values_not_zeros() {
|
||
let s = seeded();
|
||
for arg in ["1519", "999", "0", ""] {
|
||
assert_eq!(
|
||
s.eval::<Option<f64>>(&format!("return C_Map.GetMapWorldSize({arg})"))
|
||
.unwrap(),
|
||
None,
|
||
"area id `{arg}` has no usable rect"
|
||
);
|
||
}
|
||
// pfQuest's own expression (`map.lua:58-61`).
|
||
assert!(
|
||
!s.eval::<bool>(
|
||
"local w, h = C_Map.GetMapWorldSize(1519) return (w and h) and true or false"
|
||
)
|
||
.unwrap(),
|
||
"pfQuest's own guard must take its false branch"
|
||
);
|
||
}
|
||
|
||
/// The art folder is the key, because that is the only handle a city or instance map has:
|
||
/// `GetMapInfo()` returns it and the zone dropdown never lists the map at all.
|
||
#[test]
|
||
fn get_map_area_ids_keys_the_area_id_by_the_map_info_folder() {
|
||
let s = seeded();
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return C_Map.GetMapAreaIDs()["Elwynn"]"#)
|
||
.unwrap(),
|
||
12
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return C_Map.GetMapAreaIDs()["Stormwind"]"#)
|
||
.unwrap(),
|
||
1519,
|
||
"a city is in the per-continent zone list and must be in this table too"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<Option<i64>>(r#"return C_Map.GetMapAreaIDs()["Nowhere"]"#)
|
||
.unwrap(),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// **The property that separates this from the stock pair.** `GetNumMapOverlays` answers only
|
||
/// what the player has explored; `GetMapOverlays` answers the shipped table. With an empty
|
||
/// explored bitset the two must disagree, and pfUI's reveal module is built on exactly that
|
||
/// difference.
|
||
#[test]
|
||
fn get_map_overlays_answers_unexplored_rows_that_the_stock_getter_hides() {
|
||
let s = seeded();
|
||
s.run("SetMapZoom(1, 1)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<i64>("return GetNumMapOverlays()").unwrap(),
|
||
0,
|
||
"nothing is explored, so the stock revealed list is empty"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return table.getn(C_Map.GetMapOverlays())")
|
||
.unwrap(),
|
||
2,
|
||
"both shipped rows, explored or not"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn get_map_overlays_carries_every_field_the_call_sites_read() {
|
||
let s = seeded();
|
||
assert_eq!(
|
||
s.eval::<(String, String, i64, i64, i64, i64)>(
|
||
"local o = C_Map.GetMapOverlays(12)[1] \
|
||
return o.textureName, o.texturePath, o.textureWidth, o.textureHeight, \
|
||
o.offsetX, o.offsetY"
|
||
)
|
||
.unwrap(),
|
||
(
|
||
"GOLDSHIRE".to_owned(),
|
||
"Interface\\WorldMap\\Elwynn\\GOLDSHIRE".to_owned(),
|
||
256,
|
||
128,
|
||
10,
|
||
20
|
||
),
|
||
"the bare leaf and the full path are the one string and its tail"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(i64, i64, i64, i64, i64)>(
|
||
"local o = C_Map.GetMapOverlays(12)[1] \
|
||
return o.areaID, o.hitRectTop, o.hitRectLeft, o.hitRectBottom, o.hitRectRight"
|
||
)
|
||
.unwrap(),
|
||
(87, 1, 2, 3, 4),
|
||
"pfQuest keys its subzone index on areaID and centres it on the hit rect"
|
||
);
|
||
}
|
||
|
||
/// No argument means the zone on screen, which is what pfUI's reveal module wants: it is
|
||
/// drawing over `WorldMapDetailFrame`.
|
||
#[test]
|
||
fn no_argument_means_the_viewed_zone_and_nil_when_none_is_viewed() {
|
||
let s = seeded();
|
||
assert_eq!(
|
||
s.eval::<Option<i64>>("return C_Map.GetMapOverlays()")
|
||
.unwrap(),
|
||
None,
|
||
"at world level no zone is displayed — nil, which pfUI's `or` fallback absorbs"
|
||
);
|
||
s.run("SetMapZoom(1, 1)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return C_Map.GetMapOverlays()[1].textureName")
|
||
.unwrap(),
|
||
"GOLDSHIRE"
|
||
);
|
||
}
|
||
|
||
/// A zone with no rows answers an empty array; a zone that does not exist answers nil. Both
|
||
/// are false-ish to `ipairs`, but only one of them is a claim about a map.
|
||
#[test]
|
||
fn an_overlayless_zone_is_empty_and_an_unknown_one_is_nil() {
|
||
let s = seeded();
|
||
assert_eq!(
|
||
s.eval::<i64>("return table.getn(C_Map.GetMapOverlays(1519))")
|
||
.unwrap(),
|
||
0
|
||
);
|
||
assert_eq!(
|
||
s.eval::<Option<i64>>("return C_Map.GetMapOverlays(999)")
|
||
.unwrap(),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// pfUI's loop (`modules/mapreveal.lua:121-137`), reduced to its reads. It is an `ipairs` walk
|
||
/// over the result of an unguarded `C_Map.GetMapOverlays()`, which is where the session's 19
|
||
/// errors came from.
|
||
#[test]
|
||
fn pfuis_reveal_loop_runs() {
|
||
let s = seeded();
|
||
s.run("SetMapZoom(1, 1)").unwrap();
|
||
s.run(
|
||
"seen = 0 \
|
||
local zoneData = C_Map.GetMapOverlays() or {} \
|
||
for _, overlay in ipairs(zoneData) do \
|
||
local name = overlay.textureName \
|
||
local textureName = overlay.texturePath \
|
||
local w, h = overlay.textureWidth, overlay.textureHeight \
|
||
local ox, oy = overlay.offsetX, overlay.offsetY \
|
||
if name and textureName and w and h and ox and oy then seen = seen + 1 end \
|
||
end",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(s.eval::<i64>("return seen").unwrap(), 2);
|
||
}
|
||
|
||
/// A probe-less VM must still answer a `tiles` array, because the caller walks it with an
|
||
/// unguarded `ipairs` — but it has nothing to answer *about*, since a cell whose file cannot
|
||
/// be measured is left out rather than claimed. Empty, not nil: `ipairs(nil)` is the raise
|
||
/// this closes.
|
||
#[test]
|
||
fn an_unmeasurable_overlays_tiles_are_an_empty_array_never_nil() {
|
||
let s = seeded();
|
||
assert_eq!(
|
||
s.eval::<String>("return type(C_Map.GetMapOverlays(12)[1].tiles)")
|
||
.unwrap(),
|
||
"table"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return table.getn(C_Map.GetMapOverlays(12)[1].tiles)")
|
||
.unwrap(),
|
||
0,
|
||
"no texel probe, so no cell can be resolved"
|
||
);
|
||
}
|
||
|
||
/// The grid, over a probe that reports the sizes the **reference's own loop** assumes — so
|
||
/// every number here must equal what `WorldMapFrame.lua:112-159` computes for the same row.
|
||
/// The row is 600 x 300, i.e. 3 columns (256, 256, 88) by 2 rows (256, 44), numbered row-major
|
||
/// from 1 at the rect's own offset.
|
||
#[test]
|
||
fn the_tile_grid_is_the_reference_overlay_loops_own_cells_at_reference_art_sizes() {
|
||
let mut s = seeded();
|
||
s.set_texture_size_probe(Box::new(|path| {
|
||
// The stock loop's assumption: full cells 256 square, the last column padded from 88
|
||
// to 128, the last row from 44 to 64.
|
||
let pads: [(u32, u32); 6] = [
|
||
(256, 256),
|
||
(256, 256),
|
||
(128, 256),
|
||
(256, 64),
|
||
(256, 64),
|
||
(128, 64),
|
||
];
|
||
let n: usize = path
|
||
.rsplit('\\')
|
||
.next()?
|
||
.trim_start_matches(|c: char| c.is_ascii_alphabetic())
|
||
.parse()
|
||
.ok()?;
|
||
pads.get(n - 1).copied()
|
||
}));
|
||
s.set_world_map_catalog(vec![WorldMapContinentView {
|
||
name: "Azeroth".to_owned(),
|
||
map_file: "Azeroth".to_owned(),
|
||
zones: vec![WorldMapZoneView {
|
||
name: "Elwynn Forest".to_owned(),
|
||
area_id: 12,
|
||
map_file: "Elwynn".to_owned(),
|
||
loc_rect: (-8000.0, -11600.0, 5300.0, 2900.0),
|
||
overlays: vec![WorldMapOverlayView {
|
||
width: 600,
|
||
height: 300,
|
||
..overlay("GOLDSHIRE", 87)
|
||
}],
|
||
}],
|
||
..Default::default()
|
||
}]);
|
||
|
||
assert_eq!(
|
||
s.eval::<i64>("return table.getn(C_Map.GetMapOverlays(12)[1].tiles)")
|
||
.unwrap(),
|
||
6,
|
||
"ceil(600/256) x ceil(300/256)"
|
||
);
|
||
// Cell 3 is the last of row 1: 88 wide in a 128-wide file, a full 256 tall.
|
||
assert_eq!(
|
||
s.eval::<(String, i64, i64, f64, f64, i64, i64)>(
|
||
"local t = C_Map.GetMapOverlays(12)[1].tiles[3] \
|
||
return t.file, t.width, t.height, t.texCoordX, t.texCoordY, t.offsetX, t.offsetY"
|
||
)
|
||
.unwrap(),
|
||
(
|
||
"Interface\\WorldMap\\Elwynn\\GOLDSHIRE3".to_owned(),
|
||
88,
|
||
256,
|
||
88.0 / 128.0,
|
||
1.0,
|
||
10 + 512,
|
||
20
|
||
)
|
||
);
|
||
// Cell 4 opens row 2: a full 256 wide, 44 tall in a 64-tall file, one stride down.
|
||
assert_eq!(
|
||
s.eval::<(String, i64, i64, f64, f64, i64, i64)>(
|
||
"local t = C_Map.GetMapOverlays(12)[1].tiles[4] \
|
||
return t.file, t.width, t.height, t.texCoordX, t.texCoordY, t.offsetX, t.offsetY"
|
||
)
|
||
.unwrap(),
|
||
(
|
||
"Interface\\WorldMap\\Elwynn\\GOLDSHIRE4".to_owned(),
|
||
256,
|
||
44,
|
||
1.0,
|
||
44.0 / 64.0,
|
||
10,
|
||
20 + 256
|
||
)
|
||
);
|
||
}
|
||
|
||
/// **The whole reason the texel probe is consulted at all.** This install's overlay art is
|
||
/// re-exported at 2x, so every shipped pad is twice the one the stock loop assumes. The drawn
|
||
/// geometry must not move — the rect is still the rect — and the tex-coord fractions must come
|
||
/// out identical, because `span / pad` is scale-invariant. A row whose art does *not* follow
|
||
/// the factor is the case that shears, and there the cell keeps its scale and clips instead.
|
||
#[test]
|
||
fn doubled_art_draws_at_the_rects_size_and_an_odd_pad_clips_instead_of_stretching() {
|
||
let mut s = seeded();
|
||
// 2x art for cells 1 and 2; cell 3's pad is four times what the factor predicts, the shape
|
||
// of this install's 23 odd rows.
|
||
s.set_texture_size_probe(Box::new(|path| match path.chars().last()? {
|
||
'1' | '2' => Some((512, 512)),
|
||
'3' => Some((1024, 512)),
|
||
_ => None,
|
||
}));
|
||
s.set_world_map_catalog(vec![WorldMapContinentView {
|
||
name: "Azeroth".to_owned(),
|
||
map_file: "Azeroth".to_owned(),
|
||
zones: vec![WorldMapZoneView {
|
||
name: "Elwynn Forest".to_owned(),
|
||
area_id: 12,
|
||
map_file: "Elwynn".to_owned(),
|
||
loc_rect: (-8000.0, -11600.0, 5300.0, 2900.0),
|
||
overlays: vec![WorldMapOverlayView {
|
||
width: 600,
|
||
height: 200,
|
||
..overlay("GOLDSHIRE", 87)
|
||
}],
|
||
}],
|
||
..Default::default()
|
||
}]);
|
||
|
||
// Cell 2: a full interior cell. 2x art, so the whole file, drawn at the rect's 256.
|
||
assert_eq!(
|
||
s.eval::<(i64, i64, f64, f64)>(
|
||
"local t = C_Map.GetMapOverlays(12)[1].tiles[2] \
|
||
return t.width, t.height, t.texCoordX, t.texCoordY"
|
||
)
|
||
.unwrap(),
|
||
(256, 200, 1.0, 400.0 / 512.0),
|
||
"the rect's own size; the height fraction is 200*2 of 512, which is also 200/256"
|
||
);
|
||
// Cell 3: 88 of 600 wide. The factor says the pad should be 256; it is 1024, so the
|
||
// fraction is 176/1024 — a quarter of what the stock loop's 88/128 would show across the
|
||
// same 88 pixels, which is precisely the 4x horizontal squash this avoids.
|
||
assert_eq!(
|
||
s.eval::<(i64, f64)>(
|
||
"local t = C_Map.GetMapOverlays(12)[1].tiles[3] \
|
||
return t.width, t.texCoordX"
|
||
)
|
||
.unwrap(),
|
||
(88, 176.0 / 1024.0),
|
||
"undistorted at the rect's width, clipping the overhang"
|
||
);
|
||
}
|
||
|
||
/// Every row of the table, keyed by id, including the rows with no `WorldMapArea` map at all:
|
||
/// the ~80 mapped zones are what the catalog keeps, and the other ~1,400 subzone rows are the
|
||
/// point of this call — pfQuest labels a quest node with `pfDB.zones.loc[<subzone id>]`
|
||
/// (`database.lua:1147`) and walks the keys as the ids to ask for overlays
|
||
/// (`database.lua:1104`).
|
||
#[test]
|
||
fn get_areas_answers_the_whole_area_table_name_column_keyed_by_id() {
|
||
let mut s = seeded();
|
||
s.set_area_names(
|
||
[
|
||
(12u32, "Elwynn Forest".to_owned()),
|
||
(87, "Goldshire".to_owned()),
|
||
(9, "Northshire Valley".to_owned()),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(String, String, String)>(
|
||
"local a = C_Map.GetAreas() \
|
||
return a[12], a[87], a[9]"
|
||
)
|
||
.unwrap(),
|
||
(
|
||
"Elwynn Forest".to_owned(),
|
||
"Goldshire".to_owned(),
|
||
"Northshire Valley".to_owned()
|
||
),
|
||
"a subzone row (87, 9) is in the table beside its parent zone (12)"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("local n = 0 for _ in pairs(C_Map.GetAreas()) do n = n + 1 end return n")
|
||
.unwrap(),
|
||
3,
|
||
"the whole table and nothing else"
|
||
);
|
||
}
|
||
|
||
/// A table, never nil, on a run with no install — the same answer `GetMapAreaIDs` beside it
|
||
/// gives, and what the caller's `if zonenames then` reads as "nothing to index".
|
||
#[test]
|
||
fn get_areas_answers_an_empty_table_with_no_table_pushed() {
|
||
let s = UiScript::new().unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return type(C_Map.GetAreas())").unwrap(),
|
||
"table"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("local n = 0 for _ in pairs(C_Map.GetAreas()) do n = n + 1 end return n")
|
||
.unwrap(),
|
||
0
|
||
);
|
||
}
|
||
|
||
/// **The minimap half of pfQuest, transcribed.** Its two passes resolve the zone differently
|
||
/// and only one of them has a fallback: the world map takes `GetMapZones()`'s name and, on a
|
||
/// miss, `customids[GetMapInfo()]` built from `C_Map.GetMapAreaIDs` (`map.lua:632-660`), while
|
||
/// `UpdateMinimap` has only `GetMapIDByName(GetRealZoneText())` (`map.lua:1365`) over the
|
||
/// name table this call fills. With the table empty the minimap's `data[mapID]` gate indexes
|
||
/// by nil and the pin loop never runs — pins on the world map, none on the minimap, no raise.
|
||
///
|
||
/// Transcribes `database.lua:283-289`'s index build and `map.lua:583-595`'s lookup verbatim
|
||
/// rather than asserting on our own shape, because what has to hold is *that addon's* lookup
|
||
/// answering the id its node tables are keyed by.
|
||
#[test]
|
||
fn pfquests_minimap_gate_opens_on_the_zone_the_area_name_table_names() {
|
||
// pfQuest's minimap pass, from `GetRealZoneText()` to the gate that admits the pin loop:
|
||
// `pfDatabase.Reload`'s index (`database.lua:283-289`), `pfMap:GetMapIDByName`
|
||
// (`map.lua:583-595`), the `minimap_sizes` metatable (`map.lua:50-64`) and the gate
|
||
// (`map.lua:1383`). `data` stands for `pfMap.nodes[addon]`, keyed by zone id exactly as
|
||
// `AddNode` keys it — the same key the world-map pass reaches through `GetMapAreaIDs`,
|
||
// which is why the two passes must agree on it.
|
||
const PFQUEST_MINIMAP_GATE: &str = r#"
|
||
local zonenames = C_Map.GetAreas()
|
||
local zoneids = {}
|
||
for id, name in pairs(zonenames) do
|
||
if not zoneids[name] then zoneids[name] = id end
|
||
end
|
||
local function GetMapIDByName(search)
|
||
if zoneids then return zoneids[search] end
|
||
for id, name in pairs(zonenames) do
|
||
if name == search then return id end
|
||
end
|
||
end
|
||
local minimap_sizes = setmetatable({}, {
|
||
__index = function(t, mapID)
|
||
if not mapID then return false end
|
||
local w, h
|
||
if C_Map and C_Map.GetMapWorldSize then
|
||
w, h = C_Map.GetMapWorldSize(mapID)
|
||
end
|
||
local size = (w and h) and { w, h } or false
|
||
rawset(t, mapID, size)
|
||
return size
|
||
end,
|
||
})
|
||
local data = { [12] = { ["42|58"] = { title = "The Fargodeep Mine" } } }
|
||
|
||
local mapID = GetMapIDByName(GetRealZoneText())
|
||
if data[mapID] and minimap_sizes[mapID] then
|
||
return mapID, minimap_sizes[mapID][1], minimap_sizes[mapID][2]
|
||
end
|
||
return nil
|
||
"#;
|
||
|
||
let mut s = seeded();
|
||
s.lua()
|
||
.globals()
|
||
.set("__benilla_real_zone_name", "Elwynn Forest")
|
||
.unwrap();
|
||
|
||
// With no name table the gate is shut and nothing raises: `data[nil]` is legal Lua, so the
|
||
// pin loop is skipped for the whole session — pins on the world map, none on the minimap.
|
||
assert_eq!(
|
||
s.eval::<Option<i64>>(PFQUEST_MINIMAP_GATE).unwrap(),
|
||
None,
|
||
"an empty C_Map.GetAreas is a silently shut gate, which is the reported symptom"
|
||
);
|
||
|
||
s.set_area_names(
|
||
[
|
||
(12u32, "Elwynn Forest".to_owned()),
|
||
(87, "Goldshire".to_owned()),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(i64, f64, f64)>(PFQUEST_MINIMAP_GATE).unwrap(),
|
||
(12, 3600.0, 2400.0),
|
||
"the zone id its node tables are keyed by, and the yard spans it scales pins through"
|
||
);
|
||
}
|
||
|
||
/// A row's `tiles` resolves on **first read**, not when the row is built, and the resolved
|
||
/// array is then cached on the row.
|
||
///
|
||
/// The cost is the reason: a cell is measured by decoding its file, and walking every zone's
|
||
/// overlays — which is exactly what pfQuest's subzone index does now that `GetAreas` gives it
|
||
/// the zone list (`database.lua:1099-1118`) — is 1,019 files and 1.5 s on this install, for a
|
||
/// field that index never reads. pfUI's reveal is the only reader and is itself optional.
|
||
#[test]
|
||
fn an_overlay_rows_tiles_are_measured_on_first_read_and_cached_after() {
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
let probes = Arc::new(AtomicUsize::new(0));
|
||
let mut s = seeded();
|
||
let counter = Arc::clone(&probes);
|
||
s.set_texture_size_probe(Box::new(move |_| {
|
||
counter.fetch_add(1, Ordering::Relaxed);
|
||
Some((256, 128))
|
||
}));
|
||
|
||
// The two reads pfQuest's subzone index makes of every row: the covered area and its rect.
|
||
s.run(
|
||
"for _, o in ipairs(C_Map.GetMapOverlays(12)) do \
|
||
local _ = o.areaID + o.hitRectLeft + o.hitRectRight \
|
||
end",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
probes.load(Ordering::Relaxed),
|
||
0,
|
||
"building the rows must not touch the art"
|
||
);
|
||
|
||
// pfUI's reveal pass, the one reader that wants the grid.
|
||
assert_eq!(
|
||
s.eval::<i64>(
|
||
"local n = 0 \
|
||
for _, o in ipairs(C_Map.GetMapOverlays(12)) do \
|
||
for _ in ipairs(o.tiles) do n = n + 1 end \
|
||
end \
|
||
return n"
|
||
)
|
||
.unwrap(),
|
||
2,
|
||
"one 256x128 cell per overlay, as before"
|
||
);
|
||
let after_first = probes.load(Ordering::Relaxed);
|
||
assert!(after_first > 0, "reading .tiles is what measures the art");
|
||
|
||
// Same table object, second read: the array was `rawset` onto the row.
|
||
assert!(
|
||
s.eval::<bool>(
|
||
"local o = C_Map.GetMapOverlays(12)[1] \
|
||
local a = o.tiles \
|
||
return a == o.tiles"
|
||
)
|
||
.unwrap(),
|
||
"a resolved grid is cached on its row, not rebuilt per read"
|
||
);
|
||
assert_eq!(
|
||
probes.load(Ordering::Relaxed) - after_first,
|
||
// One fresh row per `GetMapOverlays` call, so the second call measures once more; the
|
||
// host's own probe memoises the file, which is why this is cheap and not free.
|
||
2,
|
||
"the second read of the same row re-measures nothing"
|
||
);
|
||
}
|
||
/// `GetAreaTriggerInfo` in the shape pfQuest reads (`database.lua:789-806`): the three fields
|
||
/// it tests for, in percent, and absent **together** when the centre resolves to no zone.
|
||
#[test]
|
||
fn the_area_trigger_table_carries_what_pfquest_tests_for() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_area_trigger_probe(Box::new(|id| match id {
|
||
// A trigger on a mapped zone: resolved.
|
||
4128 => Some(crate::script::AreaTriggerLookup {
|
||
map_id: 0,
|
||
position: [-8900.0, -130.0, 80.0],
|
||
radius: 7.0,
|
||
zone: Some((1519, 48.5, 62.25)),
|
||
}),
|
||
// One inside an instance, where no `WorldMapArea` rect holds it.
|
||
100 => Some(crate::script::AreaTriggerLookup {
|
||
map_id: 33,
|
||
position: [0.0, 0.0, 0.0],
|
||
radius: 0.0,
|
||
zone: None,
|
||
}),
|
||
_ => None,
|
||
}));
|
||
|
||
// pfQuest's own guard and reads, transcribed.
|
||
assert!(s
|
||
.eval::<bool>(
|
||
r#"local t = C_Map.GetAreaTriggerInfo(4128)
|
||
if not t or not t.areaID or not t.mapX or not t.mapY then return false end
|
||
return t.areaID == 1519 and t.mapX == 48.5 and t.mapY == 62.25"#
|
||
)
|
||
.unwrap());
|
||
// The geometry is there either way, since it is the DBC row itself.
|
||
assert!(s
|
||
.eval::<bool>(
|
||
r#"local t = C_Map.GetAreaTriggerInfo(4128)
|
||
return t.mapID == 0 and t.radius == 7 and t.x == -8900"#
|
||
)
|
||
.unwrap());
|
||
// An unresolvable centre drops the three together — which is what that guard is for.
|
||
assert!(s
|
||
.eval::<bool>(
|
||
r#"local t = C_Map.GetAreaTriggerInfo(100)
|
||
return t ~= nil and t.areaID == nil and t.mapX == nil and t.mapY == nil
|
||
and t.mapID == 33"#
|
||
)
|
||
.unwrap());
|
||
// An id the DBC does not carry is nil, not an empty table.
|
||
assert!(s
|
||
.eval::<bool>("return C_Map.GetAreaTriggerInfo(999999) == nil")
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>("return C_Map.GetAreaTriggerInfo() == nil")
|
||
.unwrap());
|
||
}
|
||
}
|