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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--deep [n]] [--status <file>] [--diff <file>]
|
||||
//! or: ... -- <folder> --probe <Name> [--eval <lua>]... (one addon, then ask its VM)
|
||||
//! or: ... -- <folder> --probe <Name> [--eval <lua> | --mouse <x>,<y> | --tick <secs>]... (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<String> = rest
|
||||
// `--eval <lua>` and `--mouse <x>,<y>` 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<addon_harness::probe::Step> = 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}");
|
||||
|
||||
@@ -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<ProbeOutcome> {
|
||||
pub fn probe(root: &Path, name: &str, steps: &[Step]) -> Option<ProbeOutcome> {
|
||||
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<ProbeOutcome>
|
||||
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::<String>(&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::<String>(&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<String> = 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(|| "<nothing>".into());
|
||||
(format!("--mouse {x},{y}"), format!("= over {focus}"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<Binding> 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 `<Binding>` 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#"<Bindings>
|
||||
<Binding name="PROBE_STEPUP" header="PROBE">ProbeStep_Inc()</Binding>
|
||||
<Binding name="PROBE_STEPDOWN">ProbeStep_Dec()</Binding>
|
||||
</Bindings>"#,
|
||||
)
|
||||
.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::<bool>("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.
|
||||
///
|
||||
|
||||
@@ -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::<String>("return WorldMapFrameAreaLabel:GetText()")
|
||||
.unwrap(),
|
||||
"Dire Maul",
|
||||
"the stock OnUpdate puts the hovered sub-area's name in the label"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(crate) struct WorldMapUiData {
|
||||
continents: Vec<ContinentEntry>,
|
||||
}
|
||||
|
||||
struct ContinentEntry {
|
||||
pub(crate) struct ContinentEntry {
|
||||
map_id: u32,
|
||||
proj: Option<WorldProj>,
|
||||
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<crate::ui_script::VmMemo<bool>>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
world_assets: Option<ResMut<WorldAssets>>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
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<WorldMapContinentView>, Vec<ContinentEntry>)> {
|
||||
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<u16> = match load_zone_map(&mut chain, &cont.name) {
|
||||
let zone_grid: Vec<u16> = 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<crate::ui_script::VmMemo<bool>>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
world_assets: Option<ResMut<WorldAssets>>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
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<u32>,
|
||||
) -> 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<u32>,
|
||||
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<Vec<u32>>,
|
||||
/// The inputs the last landmark rebuild was keyed on.
|
||||
landmarks: Option<LandmarkKey>,
|
||||
/// 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<NonSendMut<UiScript>>,
|
||||
@@ -474,8 +555,7 @@ fn feed_world_map(
|
||||
unit_pos: Query<&GlobalTransform, With<crate::net::NetEntity>>,
|
||||
pois: Option<Res<crate::area_poi::AreaPoiRes>>,
|
||||
world_states: Res<crate::world_state::WorldStates>,
|
||||
mut last_explored: Local<crate::ui_script::VmMemo<Option<Vec<u32>>>>,
|
||||
mut last_landmarks: Local<crate::ui_script::VmMemo<Option<LandmarkKey>>>,
|
||||
mut memo: Local<crate::ui_script::VmMemo<FeedMemos>>,
|
||||
) {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// **A named file the provider does not have** — an `<Include file=>` or `<Script file=>` whose
|
||||
/// resolved path hit nothing (decision 2155).
|
||||
@@ -767,6 +769,27 @@ impl Loader<'_> {
|
||||
.name()
|
||||
.map(|raw| framexml::resolve_name(raw, parent_name));
|
||||
|
||||
// 0 · **The type lookup, and its XML miss LOGS and skips the node** (decision 2191).
|
||||
//
|
||||
// `Instantiate 0x6ee280` looks the element's own tag up first (`0x6ee2e5 mov
|
||||
// edi,[esi+8]`, before `parent=` is read), and on a miss prints `"Unknown frame type:
|
||||
// %s"` (`0x871124`) at `0x6ee356` into the document's log and makes no object for that
|
||||
// node — non-fatal, and the walk goes on to the next sibling (wow-re
|
||||
// `taxiroute-widget-type.md`, `lootbutton-widget-type.md` §1). Only the Lua door
|
||||
// raises. We used to reach the registry through the Lua `CreateFrame`, so an XML miss
|
||||
// became a `report.errors` row — a script error the player sees — for a document the
|
||||
// client shrugs at. The live case: an addon whose `.toc` lists its own `Bindings.xml`
|
||||
// (MonkeyDev). The toc line runner hands it to the same file loader as any `.xml`, its
|
||||
// `<Binding>` children are not `Include`/`Script`/`Font`, so each is a frame element
|
||||
// whose type does not exist — three log lines in the reference, three red dialogs here
|
||||
// — while the file's real reading as bindings happens at `0x51f400` regardless.
|
||||
if crate::script::object::registered_frame_kind(self.lua(), &el.tag).is_none() {
|
||||
self.report
|
||||
.warnings
|
||||
.push(format!("Unknown frame type: {}", el.tag));
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1a · `parent="SomeFrame"` — the OTHER way an element names its parent, and the one the
|
||||
// reference's own FrameXML uses most, because its files are flat: `<Frame name="X"
|
||||
// parent="UIParent">` at top level rather than nested inside `<Frames>`. We only ever
|
||||
@@ -811,8 +834,9 @@ impl Loader<'_> {
|
||||
let parent_name = attr_parent_name.as_deref().unwrap_or(parent_name);
|
||||
let parent = attr_parent.as_ref().or(parent);
|
||||
|
||||
// 1 · CreateFrame(kind = element tag, name, parent). An unknown frame type errors here (the
|
||||
// factory-table miss, rf24 `0x6ee280`) — record it and skip the whole subtree.
|
||||
// 1 · CreateFrame(kind = element tag, name, parent). The type is already known to resolve
|
||||
// (step 0), so an error here is something else the call raised — record it and skip
|
||||
// the whole subtree.
|
||||
let create: Function = match self.lua().globals().get("CreateFrame") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
|
||||
@@ -742,15 +742,93 @@ mod loader_tests {
|
||||
.any(|w| w.contains("OnAttributeChanged")));
|
||||
}
|
||||
|
||||
/// An unknown frame type is an error that drops that subtree but not the rest of the load.
|
||||
/// **An unknown frame type at the XML door is LOGGED, and its node is skipped — nothing
|
||||
/// raises** (decision 2191).
|
||||
///
|
||||
/// `Instantiate 0x6ee280` prints `"Unknown frame type: %s"` (`0x871124`) at `0x6ee356` and makes
|
||||
/// no object for that node; only the Lua `CreateFrame` binding raises (`0x872fa8` via
|
||||
/// `luaL_error`). The node's own `<Frames>` go with it — there is no object to parent them —
|
||||
/// and the walk carries on to the next sibling.
|
||||
#[test]
|
||||
fn unknown_frame_type_errors_but_continues() {
|
||||
fn an_unknown_xml_frame_type_is_logged_and_its_node_skipped() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let doc = parse(r#"<Ui><Bogus name="X"/><Frame name="Real"/></Ui>"#);
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<Bogus name="X"><Frames><Frame name="Inside"/></Frames></Bogus>
|
||||
<Frame name="Real"/>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"nothing raised on the XML door: {:?}",
|
||||
report.errors
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w == "Unknown frame type: Bogus"),
|
||||
"the reference's own wording, in the log channel: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert_eq!(report.frames, 1, "only the real frame built");
|
||||
assert!(report.errors.iter().any(|e| e.contains("CreateFrame")));
|
||||
assert!(s.eval::<bool>("return X == nil and Inside == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return Real ~= nil").unwrap());
|
||||
// …and the Lua door still raises, on the same lookup.
|
||||
let err = s.run(r#"CreateFrame("Bogus")"#).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown frame type 'Bogus'"), "{err}");
|
||||
}
|
||||
|
||||
/// **A `.toc`-listed `Bindings.xml` loads as XML and costs nothing but log lines** (decision
|
||||
/// 2191) — MonkeyDev's shape, the director's live report. The `.toc` line runner hands every
|
||||
/// non-`.lua` entry to the same file loader (`0x6edd51` → `0x6ede10`), whose walk ignores the
|
||||
/// root's own tag (`<Bindings>`, tolerated) and sees three `<Binding>` elements: three unknown
|
||||
/// frame types, logged. The file's real reading as bindings is `0x51f400`'s, which runs anyway.
|
||||
#[test]
|
||||
fn a_bindings_document_loaded_as_framexml_raises_nothing() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let doc = parse(
|
||||
r#"<Bindings>
|
||||
<Binding name="MONKEYDEV_STEPUP" header="MONKEYDEV">MonkeyStep_Inc()</Binding>
|
||||
<Binding name="MONKEYDEV_STEPDOWN">MonkeyStep_Dec()</Binding>
|
||||
</Bindings>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
assert_eq!(
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.filter(|w| *w == "Unknown frame type: Binding")
|
||||
.count(),
|
||||
2,
|
||||
"one log line per <Binding>: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return MONKEYDEV_STEPUP == nil").unwrap(),
|
||||
"and no frame published under a binding's name"
|
||||
);
|
||||
}
|
||||
|
||||
/// The WorldFrame's one-shot record, at the XML door: a second `<WorldFrame>` is an unknown
|
||||
/// type, so it logs and builds nothing (decisions 1984, 2191) where `CreateFrame` raises.
|
||||
#[test]
|
||||
fn a_second_xml_world_frame_is_logged_not_raised() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(r#"CreateFrame("WorldFrame", "WorldFrame")"#).unwrap();
|
||||
let report = load(
|
||||
&s,
|
||||
&parse(r#"<Ui><WorldFrame name="Another"/></Ui>"#),
|
||||
&no_files,
|
||||
);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
assert!(report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w == "Unknown frame type: WorldFrame"));
|
||||
assert!(s.eval::<bool>("return Another == nil").unwrap());
|
||||
}
|
||||
|
||||
/// Env-gated smoke test over a real extracted FrameXML file (never committed; extract with
|
||||
|
||||
@@ -249,7 +249,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
m.set(
|
||||
"SetColorRGB",
|
||||
lua.create_function(|lua, (this, r, g, b): (Table, f64, f64, f64)| {
|
||||
// Shape C on r, g, b (`ColorSelect:SetColorRGB 0x78eae0`, `2=C 3=C 4=C 5=B`, wow-re
|
||||
// `numeric-arg-coercion-law.md`): bare `lua_tonumber`, nil → 0.0, no raise.
|
||||
lua.create_function(|lua, (this, r, g, b): (Table, Value, Value, Value)| {
|
||||
let (r, g, b) = (
|
||||
crate::script::object::as_f64(&r),
|
||||
crate::script::object::as_f64(&g),
|
||||
crate::script::object::as_f64(&b),
|
||||
);
|
||||
// Store through the client's quantize, then fire with what the widget now *holds* —
|
||||
// the round-tripped values, identical to the next GetColorRGB, not the raw arguments.
|
||||
let (qr, qg, qb) = with_colorselect(lua, &this, |s| {
|
||||
|
||||
@@ -453,7 +453,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
m.set(
|
||||
"SetShadowColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
// Shape C on r, g, b (`Font:SetShadowColor 0x79f730`, `2=C 3=C 4=C 5=B`, wow-re
|
||||
// `numeric-arg-coercion-law.md`) — bare `lua_tonumber`, no gate, never raises.
|
||||
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|
||||
let (r, g, b) = (
|
||||
crate::script::object::as_f32(&r),
|
||||
crate::script::object::as_f32(&g),
|
||||
crate::script::object::as_f32(&b),
|
||||
);
|
||||
edit(lua, &this, |fo| {
|
||||
let offset = fo.shadow.map_or([0.0, 0.0], |s| s.offset);
|
||||
fo.shadow = Some(FontShadow {
|
||||
|
||||
@@ -358,7 +358,15 @@ pub(super) fn install(
|
||||
m.set(
|
||||
"SetShadowColor",
|
||||
lua.create_function(
|
||||
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
// Shape C on r, g, b (`FontString:SetShadowColor 0x79dd40`, `2=C 3=C 4=C 5=B`) — the
|
||||
// same law this module's header already states for its `SetTextColor` sibling, which
|
||||
// 1973 closed there and not here.
|
||||
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|
||||
let (r, g, b) = (
|
||||
super::object::as_f32(&r),
|
||||
super::object::as_f32(&g),
|
||||
super::object::as_f32(&b),
|
||||
);
|
||||
let rh = resolve(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
|
||||
@@ -309,6 +309,27 @@ pub fn frame_kind_from_tag(s: &str) -> Option<FrameKind> {
|
||||
frame_kind_from_str(s)
|
||||
}
|
||||
|
||||
/// **The type registry lookup both doors share** (`0x6ee280`'s table `[0xcee9d8]`, its only
|
||||
/// reader) — the tag's [`FrameKind`] if a factory is registered under it right now, else `None`.
|
||||
///
|
||||
/// "Right now" is the WorldFrame's one-shot: the reference unlinks and releases that record the
|
||||
/// moment the first `<WorldFrame>` is instantiated (`0x6ee439`), so a second one — from any XML, or
|
||||
/// `CreateFrame("WorldFrame")` — takes the lookup's miss leg (decision 1984).
|
||||
///
|
||||
/// One function because the two doors disagree only in what a MISS does, never in what a miss is
|
||||
/// (decision 2191): the Lua binding raises ([`create_frame`]), the XML loader logs
|
||||
/// `"Unknown frame type: %s"` and skips the node (`crate::loader`), and wow-re's
|
||||
/// `taxiroute-widget-type.md`/`lootbutton-widget-type.md` verify both legs off the same `0x6ee280`.
|
||||
pub(crate) fn registered_frame_kind(lua: &Lua, kind: &str) -> Option<FrameKind> {
|
||||
let frame_kind = frame_kind_from_str(kind)?;
|
||||
let one_shot_spent = frame_kind == FrameKind::WorldFrame
|
||||
&& lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.world_frame_made;
|
||||
(!one_shot_spent).then_some(frame_kind)
|
||||
}
|
||||
|
||||
fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
|
||||
Some(match enum_token(s).as_str() {
|
||||
"FRAME" => FrameKind::Frame,
|
||||
@@ -368,6 +389,17 @@ pub(super) fn as_f32(v: &Value) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`as_f32`]'s double-width sibling, for the shape-C positions whose store is `f64` — today only
|
||||
/// `ColorSelect:SetColorRGB`, whose channels go through a quantizer where a detour via `f32` could
|
||||
/// move a value across a rounding boundary (`colorselect`'s module doc).
|
||||
pub(super) fn as_f64(v: &Value) -> f64 {
|
||||
match v {
|
||||
Value::Number(n) => *n,
|
||||
Value::Integer(i) => *i as f64,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// install — build the method tables, metatables, CreateFrame, and registry roots
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -592,22 +624,11 @@ pub(super) fn create_frame(
|
||||
lua: &Lua,
|
||||
(kind, name, parent, inherits): (String, Option<Value>, Option<Value>, Option<Value>),
|
||||
) -> mlua::Result<Table> {
|
||||
let frame_kind = frame_kind_from_str(&kind)
|
||||
// The Lua door's miss RAISES: `0x7060b0` reaches `"CreateFrame: Unknown frame type '%s'"`
|
||||
// (`0x872fa8`) through `luaL_error 0x6f4940`, which never returns. The XML door's does not —
|
||||
// see [`registered_frame_kind`].
|
||||
let frame_kind = registered_frame_kind(lua, &kind)
|
||||
.ok_or_else(|| mlua::Error::runtime(format!("CreateFrame: unknown frame type '{kind}'")))?;
|
||||
// The WorldFrame's registry record is a ONE-SHOT: the reference unlinks and releases it the
|
||||
// moment the first `<WorldFrame>` is instantiated (`0x6ee439`), so a second one — from any
|
||||
// XML, or `CreateFrame("WorldFrame")` — takes the lookup's miss leg, `Unknown frame type`
|
||||
// (decision 1984). The loader reaches this through the same global, so it covers both.
|
||||
if frame_kind == FrameKind::WorldFrame
|
||||
&& lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.world_frame_made
|
||||
{
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"CreateFrame: unknown frame type '{kind}'"
|
||||
)));
|
||||
}
|
||||
// **`name` and `inherits` are `lua_tostring` positions, and a NUMBER is a string to it.**
|
||||
// `0x7060b0` reads both through `0x6f3690` with no type guard at all, so `CreateFrame("Frame",
|
||||
// 5)` names the frame `"5"` — a `Value::String`-only match drops it (wow-re
|
||||
|
||||
@@ -54,10 +54,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// known live case is the action bar's grid ring: stock `ActionButton_ShowGrid` passes the
|
||||
// fourth argument explicitly (`SetVertexColor(1.0, 1.0, 1.0, 0.5)`), so closing this cannot
|
||||
// silently change that file.
|
||||
//
|
||||
// **Shape C on r, g, b** (`Texture:SetVertexColor `0x79abd0``, `2=C 3=C 4=C 5=B`, wow-re
|
||||
// `numeric-arg-coercion-law.md`): a bare `lua_tonumber` with no `lua_isnumber` gate, so a nil,
|
||||
// a table or a string is **0.0** and the call never raises. Taking them as `f32` made mlua's
|
||||
// converter the gate instead — the 2176 class — and the stock
|
||||
// `QuestLogFrame.lua:337` idiom hands three nils (`titleButton.r/g/b` are only assigned in
|
||||
// `QuestLog_Update`) on any path that selects a quest-log entry before the window has painted.
|
||||
m.set(
|
||||
"SetVertexColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|
||||
let (r, g, b) = (as_f32(&r), as_f32(&g), as_f32(&b));
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
@@ -569,7 +577,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// corner in the renderer's screen winding.
|
||||
m.set(
|
||||
"SetTexCoord",
|
||||
lua.create_function(|lua, (this, rest): (Table, mlua::Variadic<f32>)| {
|
||||
// Every coordinate is **shape C** (`Texture:SetTexCoord 0x79beb0`, all positions C,
|
||||
// wow-re `numeric-arg-coercion-law.md`): bare `lua_tonumber`, nil/table/string → 0.0. Only
|
||||
// the ARITY raises (`0x79bf5d dec eax ; cmp eax,4 ; je ; cmp eax,8 ; je`), which is the
|
||||
// match below. `Variadic<f32>` made mlua the per-coordinate gate; it is not one.
|
||||
lua.create_function(|lua, (this, args): (Table, mlua::Variadic<Value>)| {
|
||||
let rest: Vec<f32> = args.iter().map(as_f32).collect();
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let coords = match rest.len() {
|
||||
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
|
||||
|
||||
@@ -211,10 +211,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
// **Shape C on r, g, b** (`StatusBar:SetStatusBarColor `0x78fc20``, `2=C 3=C 4=C 5=B`, wow-re
|
||||
// `numeric-arg-coercion-law.md`): a bare `lua_tonumber` with no `lua_isnumber` gate, so a nil,
|
||||
// a table or a string is **0.0** and the call never raises. Taking them as `f32` made mlua's
|
||||
// converter the gate instead — the 2176 class — and the stock
|
||||
// `QuestLogFrame.lua:337` idiom hands three nils (`titleButton.r/g/b` are only assigned in
|
||||
// `QuestLog_Update`) on any path that selects a quest-log entry before the window has painted.
|
||||
m.set(
|
||||
"SetStatusBarColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|
||||
let (r, g, b) = (
|
||||
crate::script::object::as_f32(&r),
|
||||
crate::script::object::as_f32(&g),
|
||||
crate::script::object::as_f32(&b),
|
||||
);
|
||||
let id = ensure_bar(lua, &this, None)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let rh = *model.id_to_region.get(&id).expect("bar region id");
|
||||
|
||||
@@ -30,6 +30,7 @@ mod modelframe;
|
||||
mod movable;
|
||||
mod name_targets;
|
||||
mod nameplate;
|
||||
mod numeric_shape_gate;
|
||||
mod object_model;
|
||||
mod reference_surface;
|
||||
mod region_map;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! **Shape C, closed as a CLASS**: every numeric position wow-re's
|
||||
//! `system/ui/scratch/numeric-arg-coercion-law.md` records as shape C must take a `nil` — or a
|
||||
//! table, or a string — as **0.0** and complete, because the reference reads it with a bare
|
||||
//! `lua_tonumber 0x6f3620` and no `lua_isnumber` gate at all.
|
||||
//!
|
||||
//! The law's own framing is why this is one test and not six: *"which shape a given argument takes
|
||||
//! is per binding, not a global law"* — it censused all 408 widget-registrar positions (110 gated,
|
||||
//! 64 ungated) precisely so a re-implementation could be checked against a table rather than an
|
||||
//! instinct. Our half of that table had drifted: 1973 closed `SetTextColor` and left every sibling
|
||||
//! strict, so mlua's converter went on being the gate for five more years' worth of call sites.
|
||||
//!
|
||||
//! The live symptom that forced the audit: stock `QuestLogFrame.lua:337` does
|
||||
//! `QuestLogSkillHighlight:SetVertexColor(titleButton.r, titleButton.g, titleButton.b)`, and those
|
||||
//! three fields are only ever assigned in `QuestLog_Update` — so **any** path that selects a
|
||||
//! quest-log entry before the window has painted hands `SetVertexColor` three nils. Questie's
|
||||
//! `syncQuestWatch` (`QuestieTracker.lua:1160`) is exactly such a path: it calls
|
||||
//! `QuestLog_SetSelection` first. On the reference that is a no-op that stores black; for us it
|
||||
//! raised, and because the raise happened inside Questie's own event-queue drain — which nils an
|
||||
//! entry only *after* its handler returns — the queue jammed at that entry permanently. Every
|
||||
//! later entry (`SYNCLOG`, `DRAWNOTES`, `TRACKER`) was stranded for the rest of the session, which
|
||||
//! is why the minimap button's right-click could turn the quest icons OFF (a direct call) but
|
||||
//! never back ON (a `DRAWNOTES` enqueue).
|
||||
//!
|
||||
//! **Shape B is not shape C and is not tested here.** The alpha slot of every colour setter is B —
|
||||
//! a pre-staged default, `1.0f` at `0x778220` — so its absence is a *default*, not a zero, and
|
||||
//! `SetVertexColor`'s own B-slot carries a separate, deliberately-open divergence (1782).
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Each row: the Lua that must not raise, and what a reader must say afterwards.
|
||||
#[test]
|
||||
fn every_shape_c_colour_position_takes_nil_as_zero_and_never_raises() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
F = CreateFrame("Frame", "F")
|
||||
T = F:CreateTexture(nil, "ARTWORK")
|
||||
FS = F:CreateFontString(nil, "ARTWORK")
|
||||
SB = CreateFrame("StatusBar", "SB")
|
||||
CS = CreateFrame("ColorSelect", "CS")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `Texture:SetVertexColor 0x79abd0` — `2=C 3=C 4=C`. The QuestLogFrame.lua:337 shape verbatim.
|
||||
s.run("T:SetVertexColor(nil, nil, nil)")
|
||||
.expect("SetVertexColor(nil,nil,nil) is three bare lua_tonumbers, not a raise");
|
||||
s.run(
|
||||
r#"
|
||||
local r, g, b = T:GetVertexColor()
|
||||
assert(r == 0 and g == 0 and b == 0, "a nil channel stores 0.0, it does not keep the old one")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A table and a string are the same 0.0 — `lua_tonumber` tests nothing.
|
||||
s.run("T:SetVertexColor({}, \"abc\", true)")
|
||||
.expect("no tag is rejected at a shape-C position");
|
||||
|
||||
// `Texture:SetTexCoord 0x79beb0` — every coordinate C; only the ARITY raises.
|
||||
s.run("T:SetTexCoord(nil, nil, nil, nil)")
|
||||
.expect("four nil coordinates are four zeroes");
|
||||
s.run("T:SetTexCoord(0, 1, 0, 1, 0, 1, 0, 1)")
|
||||
.expect("the 8-corner form still takes numbers");
|
||||
s.run("T:SetTexCoord(1, 2, 3)")
|
||||
.expect_err("but 3 args is neither 4 nor 8, and the arity DOES raise (0x79bf5d)");
|
||||
|
||||
// `FontString:SetShadowColor 0x79dd40` and `StatusBar:SetStatusBarColor 0x78fc20`.
|
||||
s.run("FS:SetShadowColor(nil, nil, nil)")
|
||||
.expect("FontString:SetShadowColor is shape C on r/g/b");
|
||||
s.run("SB:SetStatusBarColor(nil, nil, nil)")
|
||||
.expect("StatusBar:SetStatusBarColor is shape C on r/g/b");
|
||||
s.run(
|
||||
r#"
|
||||
local r, g, b = SB:GetStatusBarColor()
|
||||
assert(r == 0 and g == 0 and b == 0, "and it stored the zeroes")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `ColorSelect:SetColorRGB 0x78eae0` — C on all three, and it fires OnColorSelect either way.
|
||||
s.run("CS:SetColorRGB(nil, nil, nil)")
|
||||
.expect("ColorSelect:SetColorRGB is shape C on r/g/b");
|
||||
|
||||
// `Texture:SetGradient 0x79ae30` / `SetGradientAlpha 0x79b180` — already C before this pass;
|
||||
// pinned so they cannot regress into the family's old strictness.
|
||||
s.run("T:SetGradient(\"HORIZONTAL\", nil, nil, nil, nil, nil, nil)")
|
||||
.expect("SetGradient's six stops are all C");
|
||||
s.run("T:SetGradientAlpha(\"VERTICAL\", nil, nil, nil, nil, nil, nil, nil, nil)")
|
||||
.expect("SetGradientAlpha's eight are C/B");
|
||||
|
||||
// `FontString:SetTextColor 0x79d9c0` — closed by 1973; pinned here so the class stays closed.
|
||||
s.run("FS:SetTextColor(nil, nil, nil)")
|
||||
.expect("the sibling 1973 already fixed");
|
||||
}
|
||||
@@ -670,6 +670,88 @@ fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetAlpha(255)` on SetOwner is UNCONDITIONAL** — `0x52fff4`, the first thing the SetOwner
|
||||
/// core `0x52ffe0` does, before its five stores (wow-re `system/ui/ledger.tsv` row `0x52ffe0`,
|
||||
/// verified). Ours only ever restored the alpha one of OUR OWN fades had taken away, so a plate
|
||||
/// left dim by any other path — an addon's `SetAlpha`, an inherited parent alpha — stayed dim
|
||||
/// through every subsequent hover, where the reference stamps it back to full on each SetOwner.
|
||||
#[test]
|
||||
fn set_owner_stamps_full_alpha_even_with_no_fade_running() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
Owner = CreateFrame("Frame", "Owner")
|
||||
Tip = CreateFrame("GameTooltip", "Tip")
|
||||
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
|
||||
Tip:AddLine("Tough Jerky", 1, 1, 1)
|
||||
Tip:Show()
|
||||
-- No fade is running: this is an outside party dimming the plate.
|
||||
Tip:SetAlpha(0.3)
|
||||
assert(Tip:GetAlpha() < 0.31, "the dimming took")
|
||||
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
|
||||
assert(Tip:GetAlpha() > 0.99, "the next SetOwner stamps 255 back, fade or no fade")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **`Show` is an EXISTENCE GATE, not a plain show** — `0x530a80`, the CGameTooltip override of
|
||||
/// `vtbl+0x88` (the slot a Lua `:Show()` lands in). It shows only when BOTH the owner `+0x314`
|
||||
/// and the line count `+0x31c` are non-zero, and otherwise calls its own `vtbl+0x84`
|
||||
/// effective-hide `0x530a60` — the SetOwner core with a NULL owner, so the plate is hidden AND
|
||||
/// un-owned (wow-re `system/ui/ledger.tsv` row `0x530a80`, verified;
|
||||
/// `scratch/hover-hide-and-tooltip-owner-law.md` §4).
|
||||
///
|
||||
/// The symptom that found it: `Questie`'s tracker draws an EMPTY plate on hover — correctly
|
||||
/// sized and bordered, with no text. `QuestieTracker.lua`'s quest-button OnEnter has a dead zone
|
||||
/// between its two arms (an in-progress quest that has objectives AND whose objective text is in
|
||||
/// the Questie database matches neither), so it adds no line and then calls `Tooltip:Show()`
|
||||
/// unconditionally. The reference swallows that; we drew it — and, because `layout_tooltips`
|
||||
/// skips a zero-line plate instead of collapsing it, we drew it at the LAST hover's size, which
|
||||
/// is why it looked like a real tooltip with the text missing rather than a stub.
|
||||
#[test]
|
||||
fn show_with_no_lines_self_hides_and_un_owns() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Owner = CreateFrame("Button", "Owner")
|
||||
Owner:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
|
||||
Owner:SetWidth(40) Owner:SetHeight(40)
|
||||
Tip = CreateFrame("GameTooltip", "Tip")
|
||||
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
|
||||
Tip:AddLine("Tough Jerky", 1, 1, 1)
|
||||
Tip:Show()
|
||||
assert(Tip:IsShown(), "owner + 1 line: both halves of the gate pass")
|
||||
assert(Tip:IsOwned(Owner), "and the owner survives a real show")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Questie's exact shape: the re-hover's SetOwner clears the content, the handler's dead zone
|
||||
// adds nothing, and Show() runs anyway.
|
||||
s.run(
|
||||
r#"
|
||||
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
|
||||
assert(Tip:NumLines() == 0, "SetOwner cleared the last hover's lines")
|
||||
Tip:Show()
|
||||
assert(not Tip:IsShown(), "zero lines takes the self-hide leg of 0x530a80")
|
||||
assert(not Tip:IsOwned(Owner), "and 0x530a60 un-owns: it is the SetOwner core with NULL")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The other half of the gate, on its own: a line but no owner is equally not shown.
|
||||
s.run(
|
||||
r#"
|
||||
Tip:AddLine("Orphan", 1, 1, 1)
|
||||
Tip:Show()
|
||||
assert(not Tip:IsShown(), "lines without an owner is the same self-hide")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **A NON-STRING anchor argument must not raise** — and for a year it did, because the binding
|
||||
/// took arg 3 as `Option<String>` and let mlua's converter be the gate.
|
||||
///
|
||||
|
||||
@@ -219,6 +219,53 @@ fn worldmap_current_zone_and_player_feed() {
|
||||
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The **engine** moves the selection too, with no Lua in the loop — the reference's second
|
||||
/// writer of `[0x84506c]`/`[0x845070]`: `0x494780`'s `old == 0` side call to the resolver
|
||||
/// `0x4a6650`, which every exit of ends in the `SetMap` setter `0x4a67a0` (wow-re
|
||||
/// `system/ui/scratch/worldmap-selection-autosync.md`).
|
||||
///
|
||||
/// We only ever had the Lua writers, so until something opened the map we sat at the world
|
||||
/// level — and there `GetPlayerMapPosition` answers a world-SHEET uv, which every addon built on
|
||||
/// Astrolabe rescales as a zone uv. This pins that a fresh session can be on the player's own
|
||||
/// zone before a single line of FrameXML or addon Lua has asked for it.
|
||||
#[test]
|
||||
fn the_engine_can_select_a_zone_with_no_lua_call() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
|
||||
// A fresh VM is the world sheet, and nothing Lua-side has run.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
.unwrap(),
|
||||
(0, 0)
|
||||
);
|
||||
|
||||
s.sync_world_map_to_player_zone(1, 2);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
.unwrap(),
|
||||
(1, 2),
|
||||
"the engine's own SetMap is what Lua reads back"
|
||||
);
|
||||
// The WHOLE selection moved, not just the pair of globals: GetMapInfo names the zone sheet,
|
||||
// which is what `WorldMapFrame_Update` loads art from and what Astrolabe keys its scale on.
|
||||
assert!(
|
||||
s.eval::<Option<String>>("return GetMapInfo()")
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"a selected zone names its map file; the world level is the nil that mis-scales"
|
||||
);
|
||||
|
||||
// It clamps through the same tail as the Lua verbs — an out-of-range pair cannot corrupt
|
||||
// the selection (`0x4a67a0` range-checks the zone against the continent's child count).
|
||||
s.sync_world_map_to_player_zone(99, 99);
|
||||
let (c, _) = s
|
||||
.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
|
||||
.unwrap();
|
||||
assert!(c > 0, "clamped into the catalog, never past it");
|
||||
}
|
||||
|
||||
/// World-level ProcessMapClick picks the continent whose sheet-rect contains the click (the
|
||||
/// 0x4a7100 AABB walk — the real kernel rects are disjoint); continent-level clicks resolve
|
||||
/// through the 0x4a6ec0 zone grid; hover names ride the same cell law.
|
||||
|
||||
@@ -375,8 +375,10 @@ pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) {
|
||||
set_shown(lua, h, lines > 0);
|
||||
}
|
||||
|
||||
/// Cancel a running fade and restore full alpha (any fresh content or an explicit Show does
|
||||
/// this — re-hovering during the fade-out resurrects the tooltip at full strength).
|
||||
/// Cancel a running fade and restore full alpha (fresh CONTENT does this — re-hovering during
|
||||
/// the fade-out resurrects the tooltip at full strength). Conditional on a fade actually running,
|
||||
/// because an addon that lowered the plate's alpha itself and then added a line keeps its alpha:
|
||||
/// only the three sites in [`full_alpha`] are documented to stamp it back.
|
||||
fn cancel_fade(model: &mut Model, h: FrameHandle) {
|
||||
if let Ok(t) = tip_mut(model, h) {
|
||||
if t.fade_start.take().is_some() {
|
||||
@@ -385,6 +387,22 @@ fn cancel_fade(model: &mut Model, h: FrameHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetAlpha(255)` — **UNCONDITIONAL**, and the FIRST thing the SetOwner core `0x52ffe0` does
|
||||
/// (`0x52fff4`), before the five stores; `0x530a80`'s show arm does it too, and its self-hide arm
|
||||
/// reaches it through `0x530a60` → `0x52ffe0(0, 0, 0, 0)`. So all three of SetOwner, Show and the
|
||||
/// effective-hide leave the plate at full strength whether or not one of OUR fades was running
|
||||
/// (wow-re `system/ui/ledger.tsv` rows `0x52ffe0` / `0x530a80`, both verified, and
|
||||
/// `scratch/hover-hide-and-tooltip-owner-law.md` §4).
|
||||
///
|
||||
/// [`cancel_fade`] is not this: it only restores the alpha a fade of ours took away, so a plate
|
||||
/// left dim by any other path never recovered where the reference recovers on the next SetOwner.
|
||||
fn full_alpha(model: &mut Model, h: FrameHandle) {
|
||||
if let Ok(t) = tip_mut(model, h) {
|
||||
t.fade_start = None;
|
||||
}
|
||||
model.arena.set_alpha(h, 1.0);
|
||||
}
|
||||
|
||||
/// The engine's `GetTime` clock (the `__benilla_now` global [`super::UiScript::tick`] advances).
|
||||
fn now(lua: &Lua) -> f64 {
|
||||
lua.globals().get("__benilla_now").unwrap_or(0.0)
|
||||
@@ -622,7 +640,7 @@ pub(super) use verbs::install;
|
||||
pub(super) fn hide_tooltip(lua: &Lua, h: FrameHandle) {
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
cancel_fade(&mut model, h);
|
||||
full_alpha(&mut model, h);
|
||||
if let Ok(t) = tip_mut(&mut model, h) {
|
||||
t.owner = None;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::script::Model;
|
||||
use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD};
|
||||
|
||||
use super::{
|
||||
append_line, bool_arg, cancel_fade, clear_content, fire_cleared, hide_tooltip, now,
|
||||
append_line, bool_arg, clear_content, fire_cleared, full_alpha, hide_tooltip, now,
|
||||
parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell,
|
||||
REG_TOOLTIP_METHODS,
|
||||
};
|
||||
@@ -69,7 +69,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
cancel_fade(&mut model, h);
|
||||
full_alpha(&mut model, h);
|
||||
tip_mut(&mut model, h)?.owner = Some(owner_h);
|
||||
let owner_id = model.frame_id(owner_h);
|
||||
// The anchor mode is resolved ONCE, into the reference's own enum, and both
|
||||
@@ -458,15 +458,41 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// Show/Hide — shadow the shared pair (kind tables resolve first) to keep the tooltip's
|
||||
// state honest: Show cancels a fade at full alpha; Hide drops the owner + content and fires
|
||||
// OnTooltipCleared (the ref clears its money row through exactly that script).
|
||||
// Show is an EXISTENCE GATE, not a plain show. `0x530a80` — the CGameTooltip override of
|
||||
// `vtbl+0x88`, which is the slot a Lua `:Show()` lands in (the generic widget Show path
|
||||
// `0x7a3350` sets `[+0xd0]=1` then calls slot `+0x88`) — shows only when BOTH `+0x314` (the
|
||||
// owner) and `+0x31c` (the line count) are non-zero; otherwise it calls its own `vtbl+0x84`
|
||||
// effective-hide `0x530a60`, which is the SetOwner core with a NULL owner, so the plate is
|
||||
// hidden AND un-owned and OnTooltipCleared fires. Evaluated at Show time only — it is never a
|
||||
// visibility poll. (wow-re `system/ui/ledger.tsv` row `0x530a80`, verified, and
|
||||
// `scratch/hover-hide-and-tooltip-owner-law.md` §4.)
|
||||
//
|
||||
// Without the gate an addon that reaches `Show()` having added no lines leaves a VISIBLE
|
||||
// empty plate — and, because `layout_tooltips` skips a zero-line plate rather than collapsing
|
||||
// it, one still wearing the last hover's width and height. That is what `Questie`'s tracker
|
||||
// does: `QuestieTracker.lua`'s quest-button OnEnter has a dead zone between its two arms (an
|
||||
// in-progress quest whose objective text IS in its database adds nothing) and then calls
|
||||
// `Tooltip:Show()` unconditionally. On the reference `0x530a80` swallows it; we drew the
|
||||
// empty plate.
|
||||
//
|
||||
// `show_or_hide_empty` is the same law for the app-answered content asks, and deliberately
|
||||
// does NOT un-own (its re-enter repaint needs the owner). This path is the reference's own,
|
||||
// so it takes the reference's full effective-hide.
|
||||
m.set(
|
||||
"Show",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
{
|
||||
let live = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
cancel_fade(&mut model, h);
|
||||
full_alpha(&mut model, h);
|
||||
tip_mut(&mut model, h)
|
||||
.map(|t| t.owner.is_some() && t.num_lines > 0)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
match live {
|
||||
true => set_shown(lua, h, true),
|
||||
false => hide_tooltip(lua, h),
|
||||
}
|
||||
set_shown(lua, h, true);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -469,6 +469,21 @@ impl super::UiScript {
|
||||
self.model_ref().worldmap.selection
|
||||
}
|
||||
|
||||
/// Move the selection **from the engine**, with no Lua in the loop — the reference's
|
||||
/// `SetMap` setter `0x4a67a0` reached from the zone updater `0x494780` rather than from a
|
||||
/// binding, which is how a freshly-logged-in client already shows the player's own zone.
|
||||
///
|
||||
/// The reference has TWO writers of `[0x84506c]`/`[0x845070]`: the Lua verbs
|
||||
/// (`SetMapZoom`/`SetMapToCurrentZone`/`ProcessMapClick`), and this one. We only ever had the
|
||||
/// first, so until some addon opened the map we sat at the world level — and at the world
|
||||
/// level `GetPlayerMapPosition` answers a world-SHEET uv (`0x4a7360` step 2), which every
|
||||
/// addon that assumes a zone uv silently mis-scales. See [`crate::script::worldmap`]'s caller
|
||||
/// in `ui_world_map::feed_world_map` for the gate.
|
||||
pub fn sync_world_map_to_player_zone(&mut self, continent: u32, zone: u32) {
|
||||
let mut model = self.model_mut();
|
||||
select(&mut model, i64::from(continent), i64::from(zone));
|
||||
}
|
||||
|
||||
/// The **normalized position within the map art** at UI-space `(x, y)`, or `None` when the
|
||||
/// point isn't over it. Reproduces the reference's own click normalization verbatim —
|
||||
/// `WorldMapFrame.xml`'s `WorldMapButton_OnClick`: `u = (x − left)/width`,
|
||||
|
||||
Reference in New Issue
Block a user