Files
benilla-octo/crates/benilla-ui/src/script/worldmap.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

828 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The world-map bindings `WorldMapFrame.lua` reads, over a catalog and a per-frame feed the app
//! pushes (projection stays in `map_proj`); the engine owns the selection, so `SetMapZoom` reads
//! back in the same click.
//!
//! The Lua selection is the client's indices plus one (`0x4a7ed0`, `0x4a7f00`): continent 0 is the
//! world sheet, zone 0 the whole continent; continents in `WorldMapArea` file order (`0x4a5d00`),
//! zones by case-insensitive name (`0x4a6390`). An instance map is a third state (`0x4a67a0`).
//!
//! Deviation: `WORLD_MAP_UPDATE` fires at the next tick, where the reference fires it
//! synchronously, because it only triggers a repaint and one tick is invisible.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One zone of a continent; its 1-based position is the Lua zone index.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldMapZoneView {
/// `AreaTable.dbc`'s localized name, "Elwynn Forest".
pub name: String,
/// The `AreaTable.dbc` id the feed's player-zone resolution joins on.
pub area_id: u32,
/// The `Interface\WorldMap\<file>\` art folder, `WorldMapArea`'s internal name ("Elwynn").
pub map_file: String,
/// The `WorldMapArea` loc rect `(left, right, top, bottom)`; at zone level, the grid's window.
pub loc_rect: (f32, f32, f32, f32),
pub overlays: Vec<WorldMapOverlayView>,
}
/// A POI icon on the displayed map, projected to map UV. The reference's builder (`0x4a67a0`)
/// lists the `AreaPOI.dbc` rows past its level, exploration and world-state gates, then the
/// guard-directions marker (`SMSG_GOSSIP_POI`, `+0x10 == 1`), ungated.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldMapLandmarkView {
pub name: String,
/// The second tooltip line, a live status such as "In Conflict"; empty for the guard marker.
pub description: String,
/// The `Interface\Minimap\POIIcons` cell in its 8×8 grid.
pub texture_index: u32,
/// Position on the displayed map, UV from its top-left.
pub uv: (f32, f32),
}
/// A sub-area's map art, drawn over the base tiles once any of its explore bits is set.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldMapOverlayView {
/// The path prefix `Interface\WorldMap\<Zone>\<TexName>`; the Lua appends the piece number.
pub texture: String,
/// `WorldMapOverlay.dbc`'s own `TextureName` leaf, bare and uppercase ("DRYGULCHRAVINE"), the
/// tail of [`Self::texture`]. Carried rather than split back out of the path, which is built
/// by joining this to the zone's art folder: ClassicAPI's `C_Map.GetMapOverlays` hands both
/// spellings to its caller and pfUI reads the bare one as a label and a cache key
/// (`modules/mapreveal.lua:121`), so the two must be the same string by construction.
pub texture_name: String,
/// `WorldMapOverlay.dbc`'s first `AreaTable` slot — the sub-area this overlay *is*, as opposed
/// to [`Self::explore_bits`], which is every slot's reveal bit. The same slot `area_name`
/// reads, so the two cannot name different areas. 0 for a row with none.
pub area_id: u32,
pub width: u32,
pub height: u32,
/// Placement in the 1002×668 detail frame, pixels from its top-left.
pub offset_x: u32,
pub offset_y: u32,
/// The `AreaTable.dbc` exploreFlag bits that reveal it, any one sufficing; empty never shows.
pub explore_bits: Vec<u32>,
/// The DBC's `HitRect*` `(top, left, bottom, right)` in detail-frame pixels, which the
/// zone-level hover tests with both edges inclusive (`0x4a7ffc..0x4a80ea`).
pub hit_rect: (u32, u32, u32, u32),
/// The hover name: the `AreaTable.dbc` name of the first area slot only (`0x4a7fa0` reads
/// `+0x8`); `None` walks past the overlay as if the cursor were outside.
pub area_name: Option<String>,
}
/// One continent; its 1-based position is the Lua continent index.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldMapContinentView {
/// `Map.dbc`'s localized MapName, the dropdown label (`0x4a65a0`), not the art folder.
pub name: String,
pub map_file: String,
/// The rect on the world sheet in UV `(u0, v0, u1, v1)`, from the `WorldMapContinent` tile
/// bounds (`0x4a5d00`); a world-level click takes the first that holds it (`0x4a7100`).
pub world_rect: (f32, f32, f32, f32),
/// The `WorldMapArea` loc rect the cell law un-lerps a hover or click through.
pub loc_rect: (f32, f32, f32, f32),
/// The 128×128 area bitmap (`<Continent>.zmp`, read at `0x4a5feb`) as 1-based indices into
/// [`Self::zones`], 0 for none; empty when no bitmap ships, leaving hover and click inert.
pub zone_grid: Vec<u16>,
pub zones: Vec<WorldMapZoneView>,
}
/// The `0x4a6ec0` cell law: clamp the UV, un-lerp through the loc rect to world coordinates,
/// scale by `0x806544` (1/(64·533.33)), range-check, and index `col - row·128`, `row` being
/// truncated from the negated axis and so `<= 0`.
fn area_grid_cell(loc: (f32, f32, f32, f32), u: f32, v: f32) -> Option<usize> {
const K: f32 = 2.929_687_6e-5;
let (left, right, top, bottom) = loc;
let clamp01 = |p: f32| {
if p >= 1.0 {
1.0
} else if p >= 0.0 {
p
} else {
0.0
}
};
let (u, v) = (clamp01(u), clamp01(v));
let span_u = left - right;
let span_v = top - bottom;
if span_u == 0.0 || span_v == 0.0 {
return None;
}
let f1 = 0.5 - ((1.0 - u) * span_u + right) * K;
let f2 = 0.5 - ((1.0 - v) * span_v + bottom) * K;
if !(0.0..=1.0).contains(&f1) || !(0.0..=1.0).contains(&f2) {
return None;
}
let col = (f1 * 128.0) as i32; // __ftol: truncate toward zero
let row = (f2 * -128.0) as i32;
usize::try_from(col - row * 128)
.ok()
.filter(|&i| i < 128 * 128)
}
/// The 1-based zone or city under a hover or click: the continent's one grid, windowed by its own
/// loc rect at continent level and by the displayed zone's at zone level (`0x4a6ec0`).
fn grid_area(state: &WorldMapState, u: f32, v: f32) -> Option<u16> {
// An instance map has no grid (`0x4a6ec0` opens `cmp esi,-2; je 0x4a70ed`); tested apart from
// `c == 0`, which the world sheet shares.
if state.direct_area.is_some() {
return None;
}
let (c, z) = state.selection;
if c == 0 {
return None;
}
let cont = state.continents.get(c as usize - 1)?;
if cont.zone_grid.is_empty() {
return None;
}
let rect = if z == 0 {
cont.loc_rect
} else {
cont.zones.get(z as usize - 1)?.loc_rect
};
let cell = area_grid_cell(rect, u, v)?;
match cont.zone_grid.get(cell).copied().unwrap_or(0) {
0 => None,
zi => Some(zi),
}
}
/// The client's power-of-two round-up (`0x4a833c`).
fn next_pow2(n: i64) -> i64 {
let mut p = 1i64;
while p < n {
p <<= 1;
}
p
}
/// The engine-side world-map state: the pushed catalog and feed, and the selection.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldMapState {
/// The catalog, pushed once at startup; until then every binding answers as at world level.
pub continents: Vec<WorldMapContinentView>,
/// The orphan list the reference's catalog builder also fills (`0x4a6130`, `0x4a61c9`): each
/// `WorldMapArea` row with `areaID != 0` on no continent's map, the three battlegrounds.
pub direct_areas: Vec<(u32, WorldMapZoneView)>,
/// The displayed `(continent, zone)`, `(0, 0)` the world sheet, unless [`Self::direct_area`].
pub selection: (u32, u32),
/// An instance map's `WorldMapArea` row id: the reference's third selection cell `[0x845074]`
/// beside continent `[0x84506c]` (then -2) and zone `[0x845070]`. The setter `0x4a67a0` keeps
/// it exclusive with the pair (`0x4a67c1`, `0x4a67ea`); [`store_selection`] is the one writer.
pub direct_area: Option<u32>,
/// The player's `(continent, zone)`, where `SetMapToCurrentZone` lands.
pub player_zone: Option<(u32, u32)>,
/// The resolver's orphan leg, the `WorldMapArea` row id on the player's map. `0x4a6650`
/// reaches it only when its continent loop misses (`0x4a66c3`), so at most one is set.
pub player_direct_area: Option<u32>,
/// `GetPlayerMapPosition("player")` on the displayed map; `None` is off it.
pub player_uv: Option<(f32, f32)>,
/// The arrow frames' wrapper ids, built once per session; `None` until created.
pub arrow_world: Option<u32>,
pub arrow_mini: Option<u32>,
/// `party1`..`party4` in `party_slots` order; `None` or missing answers `(0, 0)`.
pub party_uv: Vec<Option<(f32, f32)>>,
/// `raid1`..`raid40` in roster order, fed like [`Self::party_uv`].
pub raid_uv: Vec<Option<(f32, f32)>>,
/// Radians, 0 north, counterclockwise-positive; `UpdateWorldMapArrowFrames` turns the arrows.
pub player_facing: f32,
/// `GetCorpseMapPosition()` on the displayed map; `None` is no corpse or off the map.
pub corpse_uv: Option<(f32, f32)>,
/// `PLAYER_EXPLORED_ZONES_1`, 64 words, bit n being `AreaTable.dbc` exploreFlag n.
pub explored: Vec<u32>,
pub landmarks: Vec<WorldMapLandmarkView>,
/// The **whole** `AreaTable.dbc` name column, `{ id: name }`, for ClassicAPI's
/// `C_Map.GetAreas` (`compat/classic_api/map.rs`). Nothing the map *window* draws reads it:
/// [`Self::continents`] already carries the ~80 rows that have a `WorldMapArea` map, and this
/// is the other ~1400 — every subzone, instance wing and lake — which only an addon asks for.
/// It lives beside the catalog because it comes off the same `AreaTableCatalog` the catalog is
/// built from (`ui_world_map.rs`), so the two cannot disagree about a name.
///
/// Empty until pushed and on a run with no install, which `GetAreas` answers as an empty
/// table — the same thing a caller sees today.
pub areas: std::collections::BTreeMap<u32, String>,
}
fn explored_bit(explored: &[u32], n: u32) -> bool {
explored
.get((n / 32) as usize)
.is_some_and(|w| w & (1 << (n % 32)) != 0)
}
/// The f32 reciprocals `0x4a7fa0` scales a hit rect's detail-frame pixels by (`fild`, `fmul`,
/// `fstp dword`) to meet the normalized cursor, so the edges are f32.
const OVERLAY_RECIP_X: f32 = 1.0 / 1002.0;
const OVERLAY_RECIP_Y: f32 = 1.0 / 668.0;
/// The zone-level overlay pre-search (`0x4a7ffc..0x4a80ea`): the first revealed overlay whose hit
/// rect holds the cursor, edges inclusive, names its first area; a zero-width rect or a NaN cursor
/// never hits, and a nameless overlay is walked past. The list is the revealed one (`[0xb6e630]`,
/// built by `0x4a6ad9`, gated at `0x4a6bfa`), so a fogged sub-area has no name.
fn overlay_hover(state: &WorldMapState, x: f32, y: f32) -> Option<String> {
revealed_overlays(state).into_iter().find_map(|o| {
let (top, left, bottom, right) = o.hit_rect;
let (xlo, xhi) = (
left as f32 * OVERLAY_RECIP_X,
right as f32 * OVERLAY_RECIP_X,
);
let (ylo, yhi) = (
top as f32 * OVERLAY_RECIP_Y,
bottom as f32 * OVERLAY_RECIP_Y,
);
let inside = xhi - xlo != 0.0
&& yhi - ylo != 0.0
&& (xlo..=xhi).contains(&x)
&& (ylo..=yhi).contains(&y);
if inside {
o.area_name.clone()
} else {
None
}
})
}
/// What `UpdateMapHighlight` answers for a cursor at map UV `(x, y)` under the current selection.
enum Hover {
Highlight {
name: String,
file: String,
tex_pct_y: f64,
texture: (f64, f64),
scroll: (f64, f64),
},
Name(String),
Miss,
}
/// The continent-level highlight (`0x4a81de` → `0x4a822a`): the zone's art folder, drawn as
/// `<file>\<file>Highlight`, and the coords seating it over the zone's rect in the client's mixed
/// f32 and f64.
fn continent_hover(cont: &WorldMapContinentView, zone: &WorldMapZoneView) -> Option<Hover> {
let (cl, cr, ct, cb) = cont.loc_rect;
let (zl, zr, zt, zb) = zone.loc_rect;
let (w, h) = (zl - zr, zt - zb);
let (cont_w, cont_h) = (cl - cr, ct - cb);
if w == 0.0 || h == 0.0 || cont_w == 0.0 || cont_h == 0.0 {
return None;
}
let recip_x = 1.0f32 / cont_w;
let recip_y_f32 = 1.0f32 / cont_h;
let texture_x = f64::from(recip_x * w);
let texture_y = (1.0f64 / f64::from(cont_h)) * f64::from(h);
let scroll_x = f64::from(recip_x * (cl - zl));
let scroll_y = f64::from(recip_y_f32 * (ct - zt));
// texPctY = dim / potdim, dim = __ftol(h·128 / w).
let dim = ((h * 128.0) / w) as i64;
let potdim = next_pow2(dim);
let tex_pct_y = if potdim > 0 {
dim as f64 / potdim as f64
} else {
0.0
};
Some(Hover::Highlight {
name: zone.name.clone(),
file: zone.map_file.clone(),
tex_pct_y,
texture: (texture_x, texture_y),
scroll: (scroll_x, scroll_y),
})
}
/// `0x4a7fa0`'s level dispatch. Continent level: the grid's zone highlights. Zone level: the
/// overlay pre-search, then the grid through the displayed zone's rect (`0x4a7620`), naming a
/// neighbour but never the displayed zone, name only (`0x4a812e`). World level: the tail, as the
/// continent highlight the reference draws there is not built here.
fn hover(wm: &WorldMapState, x: f32, y: f32) -> Hover {
// An instance map resolves no area (`0x4a7620` opens `cmp esi,-2; je 0x4a76d7`).
if wm.direct_area.is_some() {
return Hover::Miss;
}
let (c, z) = wm.selection;
let Some(cont) = c.checked_sub(1).and_then(|i| wm.continents.get(i as usize)) else {
return Hover::Miss;
};
if z != 0 {
if let Some(name) = overlay_hover(wm, x, y) {
return Hover::Name(name);
}
return match grid_area(wm, x, y) {
Some(zi) if u32::from(zi) != z => cont
.zones
.get(zi as usize - 1)
.map_or(Hover::Miss, |zone| Hover::Name(zone.name.clone())),
_ => Hover::Miss,
};
}
grid_area(wm, x, y)
.and_then(|zi| cont.zones.get(zi as usize - 1))
.and_then(|zone| continent_hover(cont, zone))
.unwrap_or(Hover::Miss)
}
/// The direct area's row by id, bound-checked as the reference's `[0xc0d5bc]` lookup is
/// (`0x4a6d24`); an id no row carries is `None`, its NULL.
fn direct_row(state: &WorldMapState) -> Option<&WorldMapZoneView> {
let id = state.direct_area?;
state
.direct_areas
.iter()
.find(|(row_id, _)| *row_id == id)
.map(|(_, row)| row)
}
/// The displayed zone's or instance map's row; `0x4a67a0` keys the -2 state's overlays by
/// `[0x845074]`, so an instance map's pass the same explored-bit gate.
///
/// Visible to the rest of `script/` for ClassicAPI's `C_Map.GetMapOverlays` with no argument,
/// which means "the zone being viewed" (`compat/classic_api/map.rs`).
pub(in crate::script) fn current_zone(state: &WorldMapState) -> Option<&WorldMapZoneView> {
if state.direct_area.is_some() {
return direct_row(state);
}
let (c, z) = state.selection;
if c == 0 || z == 0 {
return None;
}
state
.continents
.get(c as usize - 1)?
.zones
.get(z as usize - 1)
}
/// The displayed zone's revealed overlays in catalog order, the i-th `GetMapOverlayInfo(i)`.
fn revealed_overlays(state: &WorldMapState) -> Vec<&WorldMapOverlayView> {
current_zone(state)
.map(|zone| {
zone.overlays
.iter()
.filter(|o| {
o.explore_bits
.iter()
.any(|&b| explored_bit(&state.explored, b))
})
.collect()
})
.unwrap_or_default()
}
impl super::UiScript {
/// Pushes the catalog at startup, queueing `WORLD_MAP_UPDATE` so an open map repaints.
pub fn set_world_map_catalog(&mut self, continents: Vec<WorldMapContinentView>) {
let n = continents.len();
{
let mut model = self.model_mut();
model.worldmap.continents = continents;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
// TurtleWoW's WorldMapFrame reads `CONTINENTS_LENGTH` as the continent dropdown's length
// (line 457), so it must be set before the WORLD_MAP_UPDATE above runs the dropdown's
// OnEvent — a global its patch-4 WorldMapFrame.lua expects that the stock client never
// shipped.
self.lua
.globals()
.set("CONTINENTS_LENGTH", n as i64)
.expect("CONTINENTS_LENGTH is a number");
}
/// Pushes the whole `AreaTable.dbc` name column for `C_Map.GetAreas`.
///
/// No `WORLD_MAP_UPDATE`: the map window reads none of it, so a repaint would be a frame's
/// work for nothing. Pushed at the same edge as the catalog, because the one caller
/// (pfQuest's `pfDatabase.Reload`) reads it at **file scope** — a push one frame later is a
/// push too late, and pfQuest caches the answer in an upvalue for the session.
pub fn set_area_names(&mut self, areas: std::collections::BTreeMap<u32, String>) {
self.model_mut().worldmap.areas = areas;
}
/// Pushes the orphan list beside the catalog, both filled in one walk (`0x4a5d00`).
pub fn set_world_map_direct_areas(&mut self, direct_areas: Vec<(u32, WorldMapZoneView)>) {
let mut model = self.model_mut();
model.worldmap.direct_areas = direct_areas;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
/// Pushes the resolver's orphan leg; the continent leg rides [`Self::set_world_map_feed`].
pub fn set_world_map_player_direct_area(&mut self, direct_area: Option<u32>) {
self.model_mut().worldmap.player_direct_area = direct_area;
}
/// Pushes the discovery bitset; a change queues `WORLD_MAP_UPDATE` so an open map fills in.
pub fn set_world_map_explored(&mut self, explored: Vec<u32>) {
let mut model = self.model_mut();
if model.worldmap.explored != explored {
model.worldmap.explored = explored;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
}
/// Pushes the displayed map's landmarks; a change queues `WORLD_MAP_UPDATE` to re-seat them.
pub fn set_world_map_landmarks(&mut self, landmarks: Vec<WorldMapLandmarkView>) {
let mut model = self.model_mut();
if model.worldmap.landmarks != landmarks {
model.worldmap.landmarks = landmarks;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
}
/// Pushes the per-frame feed: the player's zone, the projected positions and the facing.
pub fn set_world_map_feed(
&mut self,
player_zone: Option<(u32, u32)>,
player_uv: Option<(f32, f32)>,
player_facing: f32,
corpse_uv: Option<(f32, f32)>,
party_uv: Vec<Option<(f32, f32)>>,
raid_uv: Vec<Option<(f32, f32)>>,
) {
let mut model = self.model_mut();
model.worldmap.player_zone = player_zone;
model.worldmap.player_uv = player_uv;
model.worldmap.player_facing = player_facing;
model.worldmap.corpse_uv = corpse_uv;
model.worldmap.party_uv = party_uv;
model.worldmap.raid_uv = raid_uv;
}
/// The selection's three cells, which the app reads each frame to project the feed: continent,
/// zone, and the direct-area id that overrides them when `Some`. The pair alone reads an
/// instance map as the world sheet.
pub fn world_map_selection(&self) -> (u32, u32, Option<u32>) {
let wm = &self.model_ref().worldmap;
(wm.selection.0, wm.selection.1, wm.direct_area)
}
/// Moves the selection from the engine, as the zone updater `0x494780` calls the setter
/// `0x4a67a0`: a fresh login shows the player's map before any Lua runs, not the world sheet,
/// whose `GetPlayerMapPosition` is sheet UV (`0x4a7360`). It takes the resolver's whole
/// answer, orphan leg included, as `0x4947ac` calls `0x4a6650`.
pub fn sync_world_map_to_player_zone(
&mut self,
continent: u32,
zone: u32,
direct_area: Option<u32>,
) {
let mut model = self.model_mut();
select_resolved(&mut model, continent, zone, direct_area);
}
/// The map UV at UI point `(x, y)`, or `None` off the art, normalized over `WorldMapButton`
/// as `WorldMapButton_OnClick` does (WorldMapFrame.lua:287-291). A pure query for the app's
/// dev map-jump, which must not drill in as the click path does.
pub fn world_map_uv_at(&self, x: f32, y: f32) -> Option<(f32, f32)> {
let id = self.hit_test(x, y)?;
let model = self.model_ref();
let hit = model.id_to_frame.get(&id).copied()?;
// The button or anything on it: POI icons (WorldMapFrame.lua:178), the player arrow's
// button and the blips are its children and sit on top.
let mut fh = hit;
while model.arena.frame(fh)?.name.as_deref() != Some("WorldMapButton") {
fh = model.arena.frame(fh)?.parent?;
}
let r = model.resolved.get(&fh)?;
let (w, h) = (r.right - r.left, r.top - r.bottom);
(w > 0.0 && h > 0.0).then(|| ((x - r.left) / w, (r.top - y) / h))
}
}
/// The one writer of the three selection cells, as the setter `0x4a67a0` is: every leg but
/// `ecx == -2` clears the direct cell (`0x4a67ea`), and that one stores the id (`0x4a67c1`).
fn store_selection(model: &mut Model, selection: (u32, u32), direct_area: Option<u32>) {
model.worldmap.selection = selection;
model.worldmap.direct_area = direct_area;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
/// Clamps and stores a continent and zone, clearing the direct area: the tail of `SetMapZoom`,
/// `SetMapToCurrentZone` and `ProcessMapClick`.
fn select(model: &mut Model, continent: i64, zone: i64) {
let c = continent.clamp(0, model.worldmap.continents.len() as i64) as u32;
let z = if c == 0 {
0
} else {
let n = model.worldmap.continents[c as usize - 1].zones.len() as i64;
zone.clamp(0, n) as u32
};
store_selection(model, (c, z), None);
}
/// Selects an instance map, `0x4a67a0` with `ecx == -2`: the zone cell goes to -1 (our 0) and the
/// row id is stored unchecked, as every reader bound-checks it ([`direct_row`]).
fn select_direct(model: &mut Model, wma_id: u32) {
store_selection(model, (0, 0), Some(wma_id));
}
/// Applies the resolver `0x4a6650`'s answer, the tail of `SetMapToCurrentZone` and of the login
/// sync (`0x4947ac`).
fn select_resolved(model: &mut Model, continent: u32, zone: u32, direct_area: Option<u32>) {
match direct_area {
Some(id) => select_direct(model, id),
None => select(model, i64::from(continent), i64::from(zone)),
}
}
/// Register the world-map globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetMapContinents() → the continent names, in index order.
g.set(
"GetMapContinents",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let names: Vec<Value> = model
.worldmap
.continents
.iter()
.map(|c| Ok(Value::String(lua.create_string(&c.name)?)))
.collect::<mlua::Result<_>>()?;
Ok(MultiValue::from_vec(names))
})?,
)?;
// GetMapZones(continent) → the zone names. A negative continent answers none, as `0x4a7d10`'s
// bound is unsigned (`dec edi; cmp edi,[0xb6e664]; jae`): an instance map's empty zone list is
// what keeps `WorldMapZoneButton_OnClick` from calling `SetMapZoom(-1, id)`.
g.set(
"GetMapZones",
lua.create_function(|lua, c: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let zones = usize::try_from(c)
.ok()
.and_then(|c| c.checked_sub(1))
.and_then(|c| model.worldmap.continents.get(c))
.map(|cont| cont.zones.as_slice())
.unwrap_or(&[]);
let names: Vec<Value> = zones
.iter()
.map(|z| Ok(Value::String(lua.create_string(&z.name)?)))
.collect::<mlua::Result<_>>()?;
Ok(MultiValue::from_vec(names))
})?,
)?;
// GetCurrentMapContinent() / GetCurrentMapZone(): each cell plus one (`0x4a7ed0`, `0x4a7f00`),
// so an instance map answers -1 and 0.
g.set(
"GetCurrentMapContinent",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match model.worldmap.direct_area {
Some(_) => -1i64,
None => i64::from(model.worldmap.selection.0),
})
})?,
)?;
g.set(
"GetCurrentMapZone",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.worldmap.selection.1))
})?,
)?;
g.set(
"SetMapZoom",
lua.create_function(|lua, (c, z): (i64, Option<i64>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
select(&mut model, c, z.unwrap_or(0));
Ok(())
})?,
)?;
// SetMapToCurrentZone(): the resolver's whole answer, orphan leg included (`0x4a7e20` is
// `call 0x4a6650; xor eax,eax; ret`).
g.set(
"SetMapToCurrentZone",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (c, z) = model.worldmap.player_zone.unwrap_or((0, 0));
let direct = model.worldmap.player_direct_area;
select_resolved(&mut model, c, z, direct);
Ok(())
})?,
)?;
// GetMapInfo() → mapFileName and two texture dimensions (`0x4a7e30`, `mov eax,3`): nil, 0, 0
// at world level. The name is the unlocalized art folder (`0x4a6cf0` reads `[row+0xc]`).
g.set(
"GetMapInfo",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let wm = &model.worldmap;
let file = match (wm.direct_area, wm.selection) {
// An instance map, by id through the orphan list: `0x4a6cf0`'s `jl 0x4a6d24`
// sends both negative continents there, and world level's third cell is -1.
(Some(_), _) => direct_row(wm).map(|row| row.map_file.clone()),
(None, (0, _)) => None,
(None, (c, z)) => wm.continents.get(c as usize - 1).map(|cont| match z {
0 => cont.map_file.clone(),
z => cont
.zones
.get(z as usize - 1)
.map(|zone| zone.map_file.clone())
.unwrap_or_else(|| cont.map_file.clone()),
}),
};
// No caller reads the dimensions, 0 here on every map; the reference computes them
// from `WorldMapContinent.dbc` (`0x4a6d50`), which lacks an instance map's mapID.
let file = match file {
Some(f) => Value::String(lua.create_string(&f)?),
None => Value::Nil,
};
Ok((file, 0i64, 0i64))
})?,
)?;
// GetCorpseMapPosition() → map UV, or (0, 0), which WorldMapFrame.lua:445 hides.
g.set(
"GetCorpseMapPosition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (x, y) = model.worldmap.corpse_uv.unwrap_or((0.0, 0.0));
Ok((f64::from(x), f64::from(y)))
})?,
)?;
// GetPlayerMapPosition(unit) → map UV, or (0, 0) off the map; `player`, `party1`..`party4`
// and `raid1`..`raid40` resolve, anything else is (0, 0).
g.set(
"GetPlayerMapPosition",
lua.create_function(|lua, unit: String| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = |prefix: &str, list: &[Option<(f32, f32)>]| {
unit.strip_prefix(prefix)
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| *n >= 1)
.and_then(|n| list.get(n - 1).copied().flatten())
};
let (x, y) = match unit.as_str() {
"player" => model.worldmap.player_uv,
u if u.starts_with("party") => slot("party", &model.worldmap.party_uv),
u if u.starts_with("raid") => slot("raid", &model.worldmap.raid_uv),
_ => None,
}
.unwrap_or((0.0, 0.0));
Ok((f64::from(x), f64::from(y)))
})?,
)?;
// GetPlayerFacing() → the facing in radians. Not a 1.12 global.
g.set(
"GetPlayerFacing",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(f64::from(model.worldmap.player_facing))
})?,
)?;
// ProcessMapClick(x, y) (`0x4a7f30`): at world level the first continent whose sheet rect
// holds the click (`0x4a7100`); otherwise the zone or city under it through the displayed
// rect (`0x4a6ec0`), which it drills into.
g.set(
"ProcessMapClick",
lua.create_function(|lua, (x, y): (f32, f32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// An instance map swallows the click (`0x4a7540` opens `cmp esi,-2; je 0x4a760d`),
// tested first as it shares the world sheet's `continent == 0`.
if model.worldmap.direct_area.is_some() {
return Ok(());
}
if model.worldmap.selection.0 == 0 {
let hit = model.worldmap.continents.iter().position(|cont| {
let (u0, v0, u1, v1) = cont.world_rect;
(u0..=u1).contains(&x) && (v0..=v1).contains(&y)
});
if let Some(i) = hit {
select(&mut model, i as i64 + 1, 0);
}
} else if let Some(zi) = grid_area(&model.worldmap, x, y) {
let c = i64::from(model.worldmap.selection.0);
select(&mut model, c, i64::from(zi));
}
Ok(())
})?,
)?;
// UpdateMapHighlight(x, y) → name, fileName, texPctX, texPctY, textureX, textureY,
// scrollChildX, scrollChildY (`0x4a7fa0`): eight values always, a nil fileName hiding the quad.
g.set(
"UpdateMapHighlight",
lua.create_function(|lua, (x, y): (f32, f32)| {
let hit = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
hover(&model.worldmap, x, y)
};
let string = |s: &str| Ok::<_, mlua::Error>(Value::String(lua.create_string(s)?));
Ok(match hit {
Hover::Highlight {
name,
file,
tex_pct_y,
texture,
scroll,
} => (
string(&name)?,
string(&file)?,
1.0f64,
tex_pct_y,
texture.0,
texture.1,
scroll.0,
scroll.1,
),
Hover::Name(name) => (string(&name)?, Value::Nil, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
Hover::Miss => (Value::Nil, Value::Nil, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
})
})?,
)?;
g.set(
"GetNumMapLandmarks",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.worldmap.landmarks.len() as i64)
})?,
)?;
// GetMapLandmarkInfo(i) → name, description, textureIndex, x, y (`0x4a8740`). textureIndex is
// a row's Icon, or 15 at zone level without `Flags & 0x80` (`0x4a8848`), and the guard
// marker's packet Icon; an empty description is nil, as `0x6f3890` pushes for a NULL string.
g.set(
"GetMapLandmarkInfo",
lua.create_function(|lua, i: i64| {
let landmark = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
usize::try_from(i)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.worldmap.landmarks.get(i).cloned())
};
let Some(l) = landmark else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let description = match l.description.is_empty() {
true => Value::Nil,
false => Value::String(lua.create_string(&l.description)?),
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&l.name)?),
description,
Value::Integer(i64::from(l.texture_index)),
Value::Number(f64::from(l.uv.0)),
Value::Number(f64::from(l.uv.1)),
]))
})?,
)?;
// GetNumMapOverlays(): the displayed map's revealed overlays; 0 at world and continent level.
g.set(
"GetNumMapOverlays",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(revealed_overlays(&model.worldmap).len() as i64)
})?,
)?;
// GetMapOverlayInfo(i) → textureName, textureWidth, textureHeight, offsetX, offsetY,
// mapPointX, mapPointY; mapPointX and mapPointY are 0 on every 5875 row.
g.set(
"GetMapOverlayInfo",
lua.create_function(|lua, i: i64| {
let overlay = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
usize::try_from(i)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| revealed_overlays(&model.worldmap).get(i).cloned().cloned())
};
let Some(o) = overlay else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&o.texture)?),
Value::Integer(i64::from(o.width)),
Value::Integer(i64::from(o.height)),
Value::Integer(i64::from(o.offset_x)),
Value::Integer(i64::from(o.offset_y)),
Value::Integer(0),
Value::Integer(0),
]))
})?,
)?;
Ok(())
}