From 3e92b4f015fb826233f4d924ad55794080b59c05 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Sep 2026 21:07:22 +0700 Subject: [PATCH] empty tooltips hide, the map opens on your zone, Questie's arrow lands - Questie's arrow points at the turn-in, not 100 yards off: the map selection sits on your zone from the moment you enter the world, as the reference's does, so Astrolabe reads the right sheet (2192) - an area the map has no row for sends the selection to your continent, not the world view; the reference fails its zone lookup and its continent lookup separately - a tooltip shown with no lines hides itself instead of drawing an empty plate at the last hover's size, as Questie's tracker did on some quests; each fresh hover restores full alpha, so a plate an addon dimmed does not stay dim - Questie's minimap button turns the quest icons back on: a nil handed to a colour setter stores zero and carries on, as the reference does, where benilla raised and jammed Questie's event queue from login (1973) - MonkeyDev loads without three red dialogs at login: an unknown frame type in an addon's XML is logged and skipped, as the reference does; only a script creating a frame of a bad type still raises (2191) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/examples/addon_harness.rs | 29 +- crates/benilla-app/src/addon_harness/probe.rs | 71 +++- .../src/addon_harness/probe_tests.rs | 16 +- crates/benilla-app/src/area.rs | 11 + crates/benilla-app/src/ui_script/addons.rs | 43 +++ .../src/ui_script/world_map_tests.rs | 64 ++++ crates/benilla-app/src/ui_world_map.rs | 325 +++++++++++++++--- crates/benilla-ui/src/loader/mod.rs | 34 +- crates/benilla-ui/src/loader/tests.rs | 86 ++++- crates/benilla-ui/src/script/colorselect.rs | 9 +- crates/benilla-ui/src/script/font.rs | 9 +- crates/benilla-ui/src/script/font_block.rs | 10 +- crates/benilla-ui/src/script/object.rs | 51 ++- crates/benilla-ui/src/script/region/paint.rs | 17 +- crates/benilla-ui/src/script/statusbar.rs | 13 +- crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/numeric_shape_gate.rs | 94 +++++ crates/benilla-ui/src/script/tests/tooltip.rs | 82 +++++ .../benilla-ui/src/script/tests/worldmap.rs | 47 +++ crates/benilla-ui/src/script/tooltip/mod.rs | 24 +- crates/benilla-ui/src/script/tooltip/verbs.rs | 36 +- crates/benilla-ui/src/script/worldmap.rs | 15 + 22 files changed, 976 insertions(+), 111 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/numeric_shape_gate.rs diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index a454c1d9..a6d253eb 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -2,7 +2,9 @@ //! //! ```text //! cargo run -q -p benilla-app --example addon_harness -- [--verbose] [--why ] [--deep [n]] [--status ] [--diff ] -//! or: ... -- --probe [--eval ]... (one addon, then ask its VM) +//! or: ... -- --probe [--eval | --mouse , | --tick ]... (one addon, then +//! ask its VM — the steps run in the order given, so a read can be taken with the cursor +//! parked somewhere the addon cares about) //! ``` //! //! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be @@ -404,13 +406,28 @@ fn main() { .position(|a| a == "--probe") .and_then(|i| rest.get(i + 1)) { - let evals: Vec = rest + // `--eval ` and `--mouse ,` are ONE ordered list, not two: a read taken + // before the cursor arrived and one taken after answer different questions, and which is + // which is the order the caller typed. + let steps: Vec = rest .iter() .enumerate() - .filter(|(_, a)| *a == "--eval") - .filter_map(|(i, _)| rest.get(i + 1).cloned()) + .filter_map(|(i, a)| match a.as_str() { + "--eval" => Some(addon_harness::probe::Step::Eval(rest.get(i + 1)?.clone())), + "--tick" => Some(addon_harness::probe::Step::Tick( + rest.get(i + 1)?.parse().ok()?, + )), + "--mouse" => { + let (x, y) = rest.get(i + 1)?.split_once(',')?; + Some(addon_harness::probe::Step::Mouse( + x.trim().parse().ok()?, + y.trim().parse().ok()?, + )) + } + _ => None, + }) .collect(); - let Some(out) = addon_harness::probe::probe(&root, name, &evals) else { + let Some(out) = addon_harness::probe::probe(&root, name, &steps) else { eprintln!( "no manifest under {}/{name} — is that an addon folder?", root.display() @@ -421,7 +438,7 @@ fn main() { report_lines("load errors", &out.load_errors); report_lines("session errors", &out.session_errors); if out.answers.is_empty() { - println!(" (no --eval given — load and session errors only)"); + println!(" (no --eval/--mouse given — load and session errors only)"); } for (chunk, answer) in &out.answers { println!("\n {chunk}"); diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index 1f66f62e..6844cee8 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -63,11 +63,34 @@ fn wrapped(chunk: &str) -> String { ) } -/// Load `name` out of `root` the way the survey does, drive the session start, then evaluate each -/// chunk in `evals` against the VM that is left. +/// One step of a probe run, in the order the caller asked for it. +/// +/// A probe used to be a list of chunks alone, which cannot ask the question half of this corpus +/// is about: an addon's map, bar and tooltip behaviour is **hover-driven**, and the state a read +/// finds with the cursor parked off-screen is not the state a player is in. Cartographer's world +/// map is the case that forced this — its area label is hidden at every zone change and shown +/// again only by `WorldMapButton`'s `OnEnter`, so `WorldMapFrameAreaLabel:IsShown()` reads `nil` +/// in a cursor-less VM whether the hover works or not. Interleaving the move with the reads is +/// what tells those two apart. +#[derive(Debug, Clone)] +pub enum Step { + /// Lua to evaluate against the VM as it stands. + Eval(String), + /// Move the cursor to `(x, y)` in UI units (y-up from the bottom-left), firing the real + /// `OnLeave`/`OnEnter` pair exactly as [`UiScript::mouse_move`] does for the app. + Mouse(f32, f32), + /// Advance the engine one frame of `secs` — the `OnUpdate` pump. An addon that hooks a + /// FrameXML `OnUpdate` (Cartographer secure-hooks `WorldMapButton_OnUpdate`) raises there and + /// nowhere else, so a probe that never ticks reports it clean. The answer is what the tick + /// raised, or `no errors`. + Tick(f32), +} + +/// Load `name` out of `root` the way the survey does, drive the session start, then run each +/// [`Step`] against the VM that is left. /// /// `None` when the folder has no manifest — the same refusal [`super::survey`] makes by filtering. -pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option { +pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option { let toc_path = super::manifest_path(root, name)?; // Decoded, not `read_to_string`'d, for the reason `survey_one` states: a cp1252 manifest read // as UTF-8 parses as an EMPTY toc, and an addon with no files reads as a clean pass. @@ -114,15 +137,41 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option script.mark_addon_loaded(name); let session_errors = super::drive_session_start(&mut script, name, &installed); - let answers = evals + let answers = steps .iter() - .map(|chunk| { - let answer = script - .eval::(&wrapped(chunk)) - // A raise HERE is the wrapper failing to compile the caller's chunk — a syntax - // error in what they typed — not the chunk raising, which `pcall` already caught. - .unwrap_or_else(|e| format!("SYNTAX: {e}")); - (chunk.clone(), answer) + .map(|step| match step { + Step::Eval(chunk) => { + let answer = script + .eval::(&wrapped(chunk)) + // A raise HERE is the wrapper failing to compile the caller's chunk — a syntax + // error in what they typed — not the chunk raising, which `pcall` already + // caught. + .unwrap_or_else(|e| format!("SYNTAX: {e}")); + (chunk.clone(), answer) + } + Step::Tick(secs) => { + let before = script.errors().len(); + script.tick(*secs); + let raised: Vec = script.errors().into_iter().skip(before).collect(); + ( + format!("--tick {secs}"), + if raised.is_empty() { + "= no errors".to_string() + } else { + format!("= RAISED: {}", raised.join(" | ")) + }, + ) + } + Step::Mouse(x, y) => { + // `resolve` first: the hit-test reads resolved rects, and a frame the addon + // created or moved since the last resolve has none (`pointer`'s module doc). + script.resolve(); + script.mouse_move(*x, *y); + let focus = script + .hit_test_name(*x, *y) + .unwrap_or_else(|| "".into()); + (format!("--mouse {x},{y}"), format!("= over {focus}")) + } }) .collect(); diff --git a/crates/benilla-app/src/addon_harness/probe_tests.rs b/crates/benilla-app/src/addon_harness/probe_tests.rs index c1ff8058..040a5af9 100644 --- a/crates/benilla-app/src/addon_harness/probe_tests.rs +++ b/crates/benilla-app/src/addon_harness/probe_tests.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; -use super::probe::probe; +use super::probe::{probe, Step}; /// One throwaway AddOns root, cleaned up on drop even if a test panics. struct Fixtures(PathBuf); @@ -71,10 +71,10 @@ fn the_probe_reads_the_vm_after_the_session_start() { fx.root(), "Talker", &[ - "return TalkerFileScope".to_string(), - "return TalkerAtLogin".to_string(), - "return nil + 1".to_string(), - "return TalkerFileScope".to_string(), + Step::Eval("return TalkerFileScope".to_string()), + Step::Eval("return TalkerAtLogin".to_string()), + Step::Eval("return nil + 1".to_string()), + Step::Eval("return TalkerFileScope".to_string()), ], ) .expect("the fixture has a manifest"); @@ -115,9 +115,9 @@ fn the_probe_sees_the_whole_folder_installed() { fx.root(), "Asker", &[ - "return AskerSaw".to_string(), - "return GetNumAddOns()".to_string(), - "return SiblingRan".to_string(), + Step::Eval("return AskerSaw".to_string()), + Step::Eval("return GetNumAddOns()".to_string()), + Step::Eval("return SiblingRan".to_string()), ], ) .expect("the fixture has a manifest"); diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index 6145b931..ed799f45 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -36,6 +36,17 @@ //! **query B** (the hit group's own row, exact key, `0x69d8f0`) re-populates the subzone //! ("Main Hall"). No name-set retry, no cross-row fallback anywhere. //! +//! **`0x494780` has one more effect than the election, and it does NOT live here** (2192): when +//! the cached zone id was 0 — the first world-enter of a session — it also calls `0x4a6650`, the +//! player→(continent, zone) resolver, and hands the answer to the world map's `SetMap` setter, so +//! a freshly-logged-in client is already on the player's own zone map before any Lua asks. That +//! half is modelled where the map data lives, in +//! [`crate::ui_world_map::world_enter_selection`] — the gate there is the same precondition +//! (`0x67e510` never reaches this updater with a zero zone id), read off our own area feed rather +//! than off the cache below. wow-re carved it as +//! `system/ui/scratch/worldmap-selection-autosync.md`, correcting this note's own earlier source, +//! which had dismissed the call as unrelated. +//! //! The area *authority* stays `terrain_stream::CurrentArea` (decision 0232 — the MCNK `areaId` //! with the WMO-interior override, now off the same faces-only claim). Host globals are written //! before the event fires, so a handler's `GetZoneText()` already sees the new state. diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index c270cc83..2ad80c28 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -1543,6 +1543,49 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + /// **A `.toc` that lists its own `Bindings.xml` loads without a script error** (decision 2191) + /// — MonkeyDev's manifest, the director's live report: three red dialogs reading + /// `CreateFrame( name="MONKEYDEV_STEPUP"): unknown frame type`. + /// + /// The `.toc` line runner hands every non-`.lua` entry to the one file loader, so the file is + /// walked as FrameXML and each `` is a frame element of no registered type. The + /// reference logs `"Unknown frame type: %s"` for each and carries on (`0x6ee356`); it raises + /// nothing, and the file's reading AS bindings is `0x51f400`'s, which happens anyway. + #[test] + fn a_toc_listed_bindings_xml_is_not_a_script_error() { + let tmp = + std::env::temp_dir().join(format!("benilla-addon-tocbindings-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let root = tmp.join("AddOns"); + std::fs::create_dir_all(root.join("Probe")).unwrap(); + std::fs::write( + root.join("Probe/Bindings.xml"), + r#" + ProbeStep_Inc() + ProbeStep_Dec() +"#, + ) + .unwrap(); + std::fs::write(root.join("Probe/Probe.lua"), "PROBE_RAN = 1").unwrap(); + let addon = Addon { + name: "Probe".into(), + toc: Toc::parse("## Interface: 11200\nBindings.xml\nProbe.lua\n"), + source: Source::Dir(root), + }; + + let script = UiScript::new().unwrap(); + let failures = addon.load(&script); + assert!( + failures.is_empty(), + "a listed Bindings.xml costs log lines, not script errors: {failures:?}" + ); + assert!( + script.eval::("return PROBE_RAN == 1").unwrap(), + "and the manifest carries on to the next line" + ); + let _ = std::fs::remove_dir_all(&tmp); + } + /// **A `.toc`-listed `.lua` and an XML-referenced one get the SAME chunk name** — the defect /// decision 2155 found, asserted from the outside because it is only visible from there. /// diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs index 9070a332..b2b9149c 100644 --- a/crates/benilla-app/src/ui_script/world_map_tests.rs +++ b/crates/benilla-app/src/ui_script/world_map_tests.rs @@ -750,3 +750,67 @@ fn a_click_over_a_blip_still_answers_the_maps_uv() { "the close button is not the map" ); } + +/// **The real-data hover** — the player's own DBCs, through the reference's own OnUpdate, to the +/// string the label ends up wearing. +/// +/// Every other world-map test in the tree stands on a hand-written catalog, which can only ever +/// prove the engine's *arithmetic*. What a player hovers is the catalog +/// [`crate::ui_world_map::build_catalog`] builds out of `WorldMapArea` × `AreaTable` × +/// `WorldMapOverlay`, indexed the way the engine indexes it, addressed by the UV +/// `WorldMapButton_OnUpdate` computes from the live cursor through a scaled frame. Three joins, +/// none of them covered, and the director's report ("hovering Dire Maul on the Feralas map names +/// it in the reference and not in benilla") is exactly a break in one of them. +/// +/// Feralas, the 5875 numbers: `WorldMapArea` 121, the `DIREMAUL` overlay's hit rect +/// `(top 235, left 525, bottom 325, right 655)` px of the 1002×668 detail frame, naming +/// `AreaTable` 2577 "Dire Maul" behind explore bit 953. The cursor goes at the map UV the +/// director's own screenshot reports (58.8, 42.6), converted to a screen point through the +/// button's rect — so the test crosses the same scale conversion the live frame does. +#[test] +fn the_real_feralas_catalog_names_dire_maul_under_the_cursor() { + let data = benilla_formats::wow_data_or_skip!(); + let mut s = harness(); + + let mut chain = benilla_formats::open_chain(&data).expect("chain"); + let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"); + let maps = + benilla_assets::MapCatalogRes(benilla_formats::load_map_catalog(&mut chain).expect("Map")); + let (views, _) = + crate::ui_world_map::build_catalog(&mut chain, &areas, &maps).expect("the real catalog"); + // Feralas' place in the pushed catalog, found the way Lua would. + let (ci, zi) = views + .iter() + .enumerate() + .find_map(|(ci, c)| { + c.zones + .iter() + .position(|z| z.name == "Feralas") + .map(|zi| (ci + 1, zi + 1)) + }) + .expect("Feralas is in the catalog"); + s.set_world_map_catalog(views); + s.set_world_map_explored(vec![u32::MAX; 64]); // a fully-discovered character + s.run(&format!("SetMapZoom({ci}, {zi})")).unwrap(); + s.run("ToggleWorldMap()").unwrap(); + s.resolve(); + + // (58.8 %, 42.6 %) of the detail frame, in screen units — the same conversion + // `WorldMapButton_OnUpdate` inverts. + let (l, r, t, b, eff) = s + .eval::<(f32, f32, f32, f32, f32)>( + "return WorldMapButton:GetLeft(), WorldMapButton:GetRight(), WorldMapButton:GetTop(), \ + WorldMapButton:GetBottom(), WorldMapButton:GetEffectiveScale()", + ) + .unwrap(); + let (x, y) = ((l + (r - l) * 0.588) * eff, (t - (t - b) * 0.426) * eff); + s.mouse_move(x, y); + update(&mut s); + + assert_eq!( + s.eval::("return WorldMapFrameAreaLabel:GetText()") + .unwrap(), + "Dire Maul", + "the stock OnUpdate puts the hovered sub-area's name in the label" + ); +} diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index 206ac9f1..e8442a0a 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -52,7 +52,7 @@ pub(crate) struct WorldMapUiData { continents: Vec, } -struct ContinentEntry { +pub(crate) struct ContinentEntry { map_id: u32, proj: Option, rect: ZoneRect, @@ -73,39 +73,28 @@ fn zone_rect(a: &WorldMapArea) -> ZoneRect { } } -/// Build + push the static catalog once the patch chain, the VM, and the Map.dbc catalog all -/// exist (Update-gated rather than Startup-ordered, like every feed that needs the script). -fn load_world_map_ui( - mut done: Local>, - script: Option>, - world_assets: Option>, - maps: Option>, - areas: Option>, - mut commands: Commands, -) { - let (Some(mut script), Some(assets), Some(maps), Some(areas)) = - (script, world_assets, maps, areas) - else { - return; - }; - // Once per **VM** (1290), not once per process: the catalog is static, the VM it is pushed - // into is not — a login builds a fresh one, and without this the map window has no continents. - if !done.claim(&script) { - return; - } - let areas = &areas.0; - - let mut chain = assets.chain.lock_recover(); - let loaded = load_world_map_area_catalog(&mut chain).and_then(|wma| { - let wmc = load_world_map_continent_catalog(&mut chain)?; - let wmo = load_world_map_overlay_catalog(&mut chain)?; +/// Build the catalog off the patch chain — the pure half of [`load_world_map_ui`], split out +/// so a test can drive the REAL `WorldMapArea` × `AreaTable` × `WorldMapContinent` × `Map` × +/// `.zmp` build rather than a hand-written stand-in. A fixture catalog can only ever prove the +/// engine's arithmetic; what the player hovers is this, and the two were never joined before +/// ([`super::ui_script::world_map_tests`]'s real-data hover). +/// +/// `None` when a DBC fails to load — the caller disables the map window and says so. +pub(crate) fn build_catalog( + chain: &mut benilla_formats::Chain, + areas: &benilla_formats::AreaTableCatalog, + maps: &MapCatalogRes, +) -> Option<(Vec, Vec)> { + let loaded = load_world_map_area_catalog(&mut *chain).and_then(|wma| { + let wmc = load_world_map_continent_catalog(&mut *chain)?; + let wmo = load_world_map_overlay_catalog(&mut *chain)?; Ok((wma, wmc, wmo)) }); let (wma, wmc, wmo) = match loaded { Ok(t) => t, Err(e) => { error!("world map: DBC load failed, map window disabled: {e:#}"); - return; + return None; } }; @@ -157,7 +146,7 @@ fn load_world_map_ui( // The continent's area bitmap, remapped from raw AreaTable ids to 1-based zone indices — // the client's load-time remap (one-hop parent rollup, then the (mapId, areaId) match; // wow-re Q1(b)), fused with its zone-index resolution since our engine consumes indices. - let zone_grid: Vec = match load_zone_map(&mut chain, &cont.name) { + let zone_grid: Vec = match load_zone_map(&mut *chain, &cont.name) { Ok(grid) => grid .iter() .map(|&raw| { @@ -246,7 +235,37 @@ fn load_world_map_ui( .collect(), }); } + Some((views, entries)) +} + +/// Build + push the static catalog once the patch chain, the VM, and the Map.dbc catalog all +/// exist (Update-gated rather than Startup-ordered, like every feed that needs the script). +fn load_world_map_ui( + mut done: Local>, + script: Option>, + world_assets: Option>, + maps: Option>, + areas: Option>, + mut commands: Commands, +) { + let (Some(mut script), Some(assets), Some(maps), Some(areas)) = + (script, world_assets, maps, areas) + else { + return; + }; + // Once per **VM** (1290), not once per process: the catalog is static, the VM it is pushed + // into is not — a login builds a fresh one, and without this the map window has no continents. + if !done.claim(&script) { + return; + } + let areas = &areas.0; + + let mut chain = assets.chain.lock_recover(); + let built = build_catalog(&mut chain, areas, &maps); drop(chain); + let Some((views, entries)) = built else { + return; + }; info!( "world map: catalog — {} continents, {} zones", @@ -424,6 +443,53 @@ fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u3 } } +/// `SetMapToCurrentZone`'s resolver, whose two halves fail SEPARATELY — see the call site in +/// [`feed_world_map`] for the byte citation. `map_id` is the player's `Map.dbc` id; `top_zone` is +/// their current area walked up to its top-level zone, or `None` while the area feed has not +/// answered. The answer is the 1-based `(continent, zone)` the Lua selection verbs speak, with +/// **zone 0 meaning the continent map** — the reference's `SetMap(continent, −1)` as +/// `GetCurrentMapZone` reads it back. +fn resolve_player_zone( + data: &WorldMapUiData, + map_id: u32, + top_zone: Option, +) -> Option<(u32, u32)> { + let ci = data.continents.iter().position(|c| c.map_id == map_id)?; + let zone = top_zone + .and_then(|top| { + data.continents[ci] + .zones + .iter() + .position(|z| z.area_id == top) + }) + .map_or(0, |zi| zi as u32 + 1); + Some((ci as u32 + 1, zone)) +} + +/// The engine-side world-enter sync's gate and answer — `0x494780`'s `old == 0` leg into the +/// resolver `0x4a6650`. `None` = do not sync this frame; `Some(sel)` = apply `sel` and never +/// sync again this session. +/// +/// Two things it has to get right, both from the bytes: +/// +/// - **The trigger is the zone id becoming known, not the frame count.** `0x67e510` bails before +/// `0x494780` whenever the resolved zone id is 0, so the reference's sync cannot fire before +/// the player's area is real. `top_zone.is_some()` is that same precondition; without it we +/// would sync on frame 1 to the bare continent and — since the reference never re-syncs — stay +/// there. +/// - **Every exit of `0x4a6650` writes the selection**, including its two failure legs +/// (`0x4a667e`/`0x4a670b` ⇒ `SetMap(−1, −1)`). So an unresolvable player — an instance map, no +/// continent row — still counts as synced, at the world view, rather than re-arming the gate +/// every frame. That is `player_zone.unwrap_or((0, 0))`: our `None` IS the reference's +/// `(−1, −1)`, which `GetCurrentMapContinent` reads back to Lua as `0`. +fn world_enter_selection( + synced: bool, + top_zone: Option, + player_zone: Option<(u32, u32)>, +) -> Option<(u32, u32)> { + (!synced && top_zone.is_some()).then(|| player_zone.unwrap_or((0, 0))) +} + /// Per frame: selection read-back → projection → feed push (see the module doc). /// One projection law for every blip on the DISPLAYED map: world-sheet mode (selection /// `(0, _)`) projects through the POSITION's own map's continent constants; continent and zone @@ -456,6 +522,21 @@ pub(crate) fn project_on_displayed( } } +/// [`feed_world_map`]'s memos, bundled behind ONE [`crate::ui_script::VmMemo`] because the feed +/// sits at Bevy's system-parameter ceiling. Keeping the memo on the outside rather than on each +/// field is the point: a login is a new VM, and the whole bundle resets with it in one place — +/// there is no field that can be added later and quietly outlive the session it is memory about +/// (decision 1290). +#[derive(Default)] +struct FeedMemos { + /// The last pushed `PLAYER_EXPLORED_ZONES` bitset. + explored: Option>, + /// The inputs the last landmark rebuild was keyed on. + landmarks: Option, + /// The reference's `old == 0` gate on the engine-side map sync — see the call site. + map_synced: bool, +} + #[allow(clippy::too_many_arguments)] fn feed_world_map( script: Option>, @@ -474,8 +555,7 @@ fn feed_world_map( unit_pos: Query<&GlobalTransform, With>, pois: Option>, world_states: Res, - mut last_explored: Local>>>, - mut last_landmarks: Local>>, + mut memo: Local>, ) { let (Some(mut script), Some(data), Some(map), Some(areas)) = (script, data, map, areas) else { return; @@ -494,9 +574,9 @@ fn feed_world_map( }) .unwrap_or_default(); { - let last_explored = last_explored.get(&script); - if !explored.is_empty() && last_explored.as_ref() != Some(&explored) { - *last_explored = Some(explored.clone()); + let memo = memo.get(&script); + if !explored.is_empty() && memo.explored.as_ref() != Some(&explored) { + memo.explored = Some(explored.clone()); script.set_world_map_explored(explored.clone()); } } @@ -505,18 +585,59 @@ fn feed_world_map( // The player's (continent, zone) — CurrentArea's MCNK areaId walked up the AreaTable parent // chain to its top-level zone, matched against the displayed catalog (1-based indices). - let player_zone = world - .area() - .and_then(|aid| areas.0.top_zone(aid)) - .and_then(|top| { - data.continents.iter().enumerate().find_map(|(ci, cont)| { - if cont.map_id != map.0 { - return None; - } - let zi = cont.zones.iter().position(|z| z.area_id == top)?; - Some((ci as u32 + 1, zi as u32 + 1)) - }) - }); + // + // **The two halves fail SEPARATELY, and that is the reference's law, not a convenience.** + // `SetMapToCurrentZone 0x4a7e20`'s resolver `0x4a6650` matches the player's map id against + // each continent record's `+0x00`, then walks that record's sorted child-zone array for the + // zone whose WMA areaID equals the player's current-area global — and **"no zone match ⇒ + // `SetMap(continent, −1)`"**, i.e. the CONTINENT map, with only "no continent match" falling + // through to the orphan list and then `SetMap(−1, −1)` = the world view (wow-re + // `system/ui/scratch/geometry.md` §"Worldmap data model"; `GetCurrentMapZone 0x4a7f00` is + // `DAT_00845070 + 1`, so that −1 reads back to Lua as **0**). + // + // We used to resolve both or neither, so a zone the catalog has no row for — and, before the + // area feed answers, a zone we simply do not know yet — slammed the selection to the WORLD + // view instead of the player's continent. That matters beyond the picture: at the world level + // `GetPlayerMapPosition` answers a world-SHEET uv (`0x4a7360`'s step-2 continent formula), and + // every consumer that assumes a zone uv — Astrolabe, and so every addon built on it — silently + // mis-scales it. + // + // NOT modelled, and deliberately: the `−2` orphan/direct-area leg for a map that is no + // continent's child (the instance maps). We have no orphan list, so those still land on the + // world view rather than the reference's direct-area selection. + let top_zone = world.area().and_then(|aid| areas.0.top_zone(aid)); + let player_zone = resolve_player_zone(&data, map.0, top_zone); + + // ── First world-enter: the ENGINE selects the player's zone, with no Lua in the loop. + // + // `0x494780` — the zone updater we already transcribe verbatim in `crate::area` for its + // ZONE_CHANGED event election — carries one more effect that election note called "an + // unrelated side call": when the cached zone id was **0** (the zeroed BSS, i.e. the first + // world-enter of the session) it also calls `0x4a6650`, the same player→(continent, zone) + // resolver `SetMapToCurrentZone` uses, and hands it to the `SetMap` setter `0x4a67a0`. So a + // freshly-logged-in reference client is ALREADY on the player's own zone map before a single + // line of FrameXML or addon Lua has asked for it. + // + // We modelled the election and dropped the side call, so our selection stayed at the world + // level until something opened the map. That is not cosmetic: at the world level + // `GetPlayerMapPosition` answers a world-SHEET uv (`0x4a7360` step 2) instead of a zone uv, + // and every addon built on Astrolabe scales it with the *zone's* yard dimensions — Questie's + // quest arrow pointed ~100 yards off the turn-in, on a client where the reference is exact. + // Astrolabe's own rescue cannot save it: that is gated on `(x <= 0 and y <= 0)`, and a + // world-sheet uv is a perfectly non-zero pair. + // + // **The gate is the zone id becoming known, not the frame count.** The reference reaches + // `0x494780` only with a nonzero resolved zone (`0x67e510` bails on 0), so its sync cannot + // fire before the player's area is real; ours must not either, or `resolve_player_zone` + // answers the bare continent and nothing re-asks. `top_zone.is_some()` is that same + // precondition. Per **VM**, not per process: a new login is a new session's first enter. + { + let memo = memo.get(&script); + if let Some((c, z)) = world_enter_selection(memo.map_synced, top_zone, player_zone) { + memo.map_synced = true; + script.sync_world_map_to_player_zone(c, z); + } + } // The player's UV on the DISPLAYED map. Off-map (wrong continent, outside the rect, or an // instance map) resolves to None/(0,0) — the reference's hide-the-blip sentinel. @@ -543,8 +664,9 @@ fn feed_world_map( .on_map(map.0) .map(|m| (m.continent_id, m.pos.map(f32::to_bits), m.icon)); let states_gen = world_states.generation(); - let unchanged = last_landmarks + let unchanged = memo .get(&script) + .landmarks .as_ref() .is_some_and(|k| k.matches((c, z), map.0, states_gen, &explored, marker)); if !unchanged { @@ -588,7 +710,7 @@ fn feed_world_map( } } } - *last_landmarks.get(&script) = Some(LandmarkKey { + memo.get(&script).landmarks = Some(LandmarkKey { selection: (c, z), map: map.0, states: states_gen, @@ -1051,3 +1173,110 @@ mod tests { ); } } + +#[cfg(test)] +mod player_zone_tests { + use super::*; + + fn rect() -> ZoneRect { + ZoneRect { + left: 1.0, + right: -1.0, + top: 1.0, + bottom: -1.0, + } + } + + /// Azeroth (map 0) with Elwynn at catalog index 9 (→ Lua zone 10), plus Kalimdor (map 1). + fn data() -> WorldMapUiData { + WorldMapUiData { + continents: vec![ + ContinentEntry { + map_id: 1, + proj: None, + rect: rect(), + zones: Vec::new(), + }, + ContinentEntry { + map_id: 0, + proj: None, + rect: rect(), + zones: (0..10) + .map(|i| ZoneEntry { + // index 9 is Elwynn's areaId 12; the rest are filler ids. + area_id: if i == 9 { 12 } else { 900 + i }, + rect: rect(), + }) + .collect(), + }, + ], + } + } + + /// **The two halves fail separately** — `SetMapToCurrentZone`'s resolver `0x4a6650`: + /// "no zone match ⇒ `SetMap(continent, −1)`", and only "no continent match" reaches the world + /// view (wow-re `system/ui/scratch/geometry.md`). Resolving both-or-neither sent a player + /// whose zone the catalog cannot name — and, for the frames before the area feed answers, + /// EVERY player — to the world level, where `GetPlayerMapPosition` answers a world-SHEET uv + /// that every zone-uv consumer silently mis-scales. + #[test] + fn an_unresolved_zone_falls_back_to_the_continent_not_the_world() { + let d = data(); + + // Both halves resolve: Elwynn is continent 2, zone 10. + assert_eq!(resolve_player_zone(&d, 0, Some(12)), Some((2, 10))); + + // The continent resolves, the zone does not — an area the catalog has no row for. + assert_eq!( + resolve_player_zone(&d, 0, Some(4242)), + Some((2, 0)), + "SetMap(continent, -1); GetCurrentMapZone reads that back as 0" + ); + + // The area feed has not answered yet — same leg, not the world view. + assert_eq!( + resolve_player_zone(&d, 0, None), + Some((2, 0)), + "a zone we do not know YET is still this continent" + ); + + // No continent match at all (an instance map) — the one leg that is genuinely None, and + // whose reference counterpart is the -2 orphan list we do not model. + assert_eq!(resolve_player_zone(&d, 389, Some(12)), None); + } + + /// **The engine selects the player's zone itself, on the first world-enter** — the side call + /// `0x494780` makes to `0x4a6650` when the cached zone id was 0. We transcribed that + /// function's event election (`crate::area`) and dropped this half; wow-re carved it as + /// `system/ui/scratch/worldmap-selection-autosync.md` (`0x4947ac`, guarded by `sete al` on + /// `[0xb4e314] == 0`, with the new zone committed *before* the call). + /// + /// Dropping it is why Questie's quest arrow pointed ~100 yards off the turn-in while the + /// reference was exact: with no zone selected, `GetPlayerMapPosition` answers a world-SHEET + /// uv, and Astrolabe scales it with the *zone's* yard dimensions. Astrolabe's own rescue + /// cannot catch it — that is gated on `(x <= 0 and y <= 0)`, and a world-sheet uv is a + /// perfectly non-zero pair. + #[test] + fn the_engine_selects_the_players_zone_on_first_world_enter() { + // Frame 1, no area yet: the reference cannot be here at all (`0x67e510` bails before + // `0x494780` on a zero zone id), so neither are we — syncing now would take the + // continent leg and, since the engine never re-syncs, strand us there. + assert_eq!(world_enter_selection(false, None, Some((2, 0))), None); + + // The area answers: sync, once, to the player's own zone. + assert_eq!( + world_enter_selection(false, Some(12), Some((2, 10))), + Some((2, 10)) + ); + + // And never again. A later zone change — even cross-continent — leaves the selection + // alone: the guard is `old == 0`, and only the world-session teardown `0x491180` + // re-zeroes `[0xb4e314]`. + assert_eq!(world_enter_selection(true, Some(40), Some((2, 22))), None); + + // An unresolvable player still counts as synced rather than re-arming the gate every + // frame: EVERY exit of `0x4a6650` writes the selection, and its two failure legs write + // `SetMap(-1, -1)` — which `GetCurrentMapContinent` reads back to Lua as `0`. + assert_eq!(world_enter_selection(false, Some(12), None), Some((0, 0))); + } +} diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index a9a7bb80..f779d786 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -75,10 +75,12 @@ mod widgets; pub struct LoadReport { /// Tolerable issues: a missing include/script provider, an unsupported-in-v1 attribute or script /// handler, an unregistered `inherits=` font object, and the parse/expand warnings folded in from - /// the document layer. None of these dropped a frame. + /// the document layer. None of these dropped a frame — except one, on purpose: an **unknown + /// frame type** drops its own node here rather than in `errors`, because that is the channel + /// the reference uses for it at the XML door (`0x6ee356`, logged and non-fatal; decision 2191). pub warnings: Vec, - /// Things that dropped a frame or a handler: an unknown frame type, a handler that failed to - /// compile, a method call that errored, a malformed included document. + /// Things that dropped a frame or a handler and that the reference RAISES on: a handler that + /// failed to compile, a method call that errored, a malformed included document. pub errors: Vec, /// **A named file the provider does not have** — an `` or `