//! 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) -> 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 ``. 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\\` 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 { 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` — ``, `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
{ 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::().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
{ 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//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::().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| { let model = lua.app_data_ref::().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::().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::>(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| { let meta: Table = lua.named_registry_value(REG_OVERLAY_ROW_META)?; let model = lua.app_data_ref::().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| { let found = { let model = lua.app_data_ref::().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::>(&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::( "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::(r#"return C_Map.GetMapAreaIDs()["Elwynn"]"#) .unwrap(), 12 ); assert_eq!( s.eval::(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::>(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::("return GetNumMapOverlays()").unwrap(), 0, "nothing is explored, so the stock revealed list is empty" ); assert_eq!( s.eval::("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::>("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::("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::("return table.getn(C_Map.GetMapOverlays(1519))") .unwrap(), 0 ); assert_eq!( s.eval::>("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::("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::("return type(C_Map.GetMapOverlays(12)[1].tiles)") .unwrap(), "table" ); assert_eq!( s.eval::("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::("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[]` /// (`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::("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::("return type(C_Map.GetAreas())").unwrap(), "table" ); assert_eq!( s.eval::("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::>(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::( "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::( "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::( 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::( 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::( 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::("return C_Map.GetAreaTriggerInfo(999999) == nil") .unwrap()); assert!(s .eval::("return C_Map.GetAreaTriggerInfo() == nil") .unwrap()); } }