the minimap and world map go stock, and the chat loses its white plate

- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps  7.0 ms  17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:59:53 +07:00
co-authored by Claude Fable 5.1
parent edd6628329
commit 9bf64e1a6b
89 changed files with 6164 additions and 2107 deletions
@@ -0,0 +1,254 @@
//! The battleground **position** family (decision 1980; wow-re
//! `system/ui/scratch/worldmap-arrow-and-positions.md` §3): the six verbs the stock
//! `WorldMapFrame.lua` and `Blizzard_BattlefieldMinimap.lua` poll to place teammates and the
//! flag carrier on the map.
//!
//! The wire carries raw world floats for the teammates outside the requester's group; the
//! reference filters out itself, its four party slots and its raid roster, prefers the live
//! object's position over the packet's, and normalizes through the world-map projection under the
//! active queue slot's map — all app-side facts here, so the app pushes the finished list every
//! frame ([`BattlefieldPositionView`], already filtered and projected) and the VM owns the getters'
//! shapes: three values on every non-raising leg, `(0, 0, nil)` off the list.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::Model;
/// One teammate as the app resolved it: the map-normalized pair (`(0, 0)` = off the displayed
/// map) and the name, `None` while the name cache has not answered.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldPositionView {
pub uv: (f32, f32),
pub name: Option<String>,
}
/// The flag carrier as the app resolved it: the pair, and the token the local player's faction
/// selects (`"HordeFlag"` for an Alliance viewer, `"AllianceFlag"` for a Horde one, `None` for
/// neither).
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldFlagView {
pub uv: (f32, f32),
pub token: Option<String>,
}
impl super::UiScript {
/// Push the resolved positions, the carrier (or none) and the active map's icon scale.
pub fn set_battlefield_positions(
&mut self,
players: Vec<BattlefieldPositionView>,
flag: Option<BattlefieldFlagView>,
icon_scale: f32,
) {
let mut model = self.model_mut();
model.battlefield_positions = players;
model.battlefield_flag = flag;
model.battlefield_icon_scale = icon_scale;
}
/// `RequestBattlefieldPositions()` calls since the last drain — the app throttles to 5000 ms
/// and sends only with an active slot.
pub fn take_battlefield_position_requests(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().battlefield_position_requests)
}
}
fn three(lua: &Lua, uv: (f32, f32), third: Option<&str>) -> mlua::Result<MultiValue> {
Ok(MultiValue::from_vec(vec![
Value::Number(f64::from(uv.0)),
Value::Number(f64::from(uv.1)),
match third {
Some(s) => Value::String(lua.create_string(s)?),
None => Value::Nil,
},
]))
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// `GetNumBattlefieldPositions()` — the survivors of the filter, one number.
g.set(
"GetNumBattlefieldPositions",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.battlefield_positions.len() as i64)
})?,
)?;
// `GetBattlefieldPosition(index)` — the usage raise; 1-based over the survivors; three values
// on every leg, `(0, 0, nil)` off the list and for 0 or a negative.
g.set(
"GetBattlefieldPosition",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetBattlefieldPosition(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.battlefield_positions.get(i));
match row {
Some(r) => three(lua, r.uv, r.name.as_deref()),
None => three(lua, (0.0, 0.0), None),
}
})?,
)?;
// `GetNumBattlefieldFlagPositions()` — one carrier slot: 1 or 0.
g.set(
"GetNumBattlefieldFlagPositions",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.battlefield_flag.is_some()))
})?,
)?;
// `GetBattlefieldFlagPosition(index)` — the usage raise; selected only for index 1 with a
// carrier; three values always.
g.set(
"GetBattlefieldFlagPosition",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetBattlefieldFlagPosition(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
match model.battlefield_flag.as_ref().filter(|_| index == 1) {
Some(f) => three(lua, f.uv, f.token.as_deref()),
None => three(lua, (0.0, 0.0), None),
}
})?,
)?;
// `GetBattlefieldMapIconScale()` — the active map's `MinimapIconScale` (map 0's with no slot,
// 1.0 on a missing row), pushed by the app.
g.set(
"GetBattlefieldMapIconScale",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(f64::from(model.battlefield_icon_scale))
})?,
)?;
// `RequestBattlefieldPositions()` — counted; the app's 5000 ms throttle and slot gate decide.
g.set(
"RequestBattlefieldPositions",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.battlefield_position_requests += 1;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
#[test]
fn the_getters_answer_three_values_and_zero_off_the_list() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return GetNumBattlefieldPositions()")
.unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldPosition(1))")
.unwrap(),
3
);
s.set_battlefield_positions(
vec![
BattlefieldPositionView {
uv: (0.25, 0.5),
name: Some("Probe-Realm".into()),
},
BattlefieldPositionView {
uv: (0.75, 0.125),
name: None,
},
],
Some(BattlefieldFlagView {
uv: (0.375, 0.625),
token: Some("HordeFlag".into()),
}),
1.25,
);
assert_eq!(
s.eval::<i64>("return GetNumBattlefieldPositions()")
.unwrap(),
2
);
let got = s
.eval::<String>(
"local x, y, n = GetBattlefieldPosition(1) return x .. '|' .. y .. '|' .. n",
)
.unwrap();
assert_eq!(got, "0.25|0.5|Probe-Realm");
assert!(
s.eval::<bool>(
"local x, y, n = GetBattlefieldPosition(2) return x == 0.75 and n == nil"
)
.unwrap(),
"a name the cache has not answered is nil"
);
for i in ["0", "3", "-1", "'2.9'"] {
let got = s
.eval::<String>(&format!(
"local x, y, n = GetBattlefieldPosition({i}) return x .. '|' .. y .. '|' .. tostring(n)"
))
.unwrap();
let want = if i == "'2.9'" {
"0.75|0.125|nil"
} else {
"0|0|nil"
};
assert_eq!(got, want, "index {i}");
}
let err = s
.run("GetBattlefieldPosition(nil)")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: GetBattlefieldPosition(index)"),
"{err}"
);
assert_eq!(
s.eval::<i64>("return GetNumBattlefieldFlagPositions()")
.unwrap(),
1
);
let got = s
.eval::<String>(
"local x, y, t = GetBattlefieldFlagPosition(1) return x .. '|' .. y .. '|' .. t",
)
.unwrap();
assert_eq!(got, "0.375|0.625|HordeFlag");
assert!(s
.eval::<bool>(
"local x, y, t = GetBattlefieldFlagPosition(2) return x == 0 and t == nil"
)
.unwrap());
let err = s
.run("GetBattlefieldFlagPosition('q')")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: GetBattlefieldFlagPosition(index)"),
"{err}"
);
assert!(
(s.eval::<f64>("return GetBattlefieldMapIconScale()")
.unwrap()
- 1.25)
.abs()
< 1e-6
);
s.run("RequestBattlefieldPositions() RequestBattlefieldPositions()")
.unwrap();
assert_eq!(s.take_battlefield_position_requests(), 2);
assert_eq!(s.take_battlefield_position_requests(), 0);
}
}
@@ -0,0 +1,697 @@
//! The battleground **list and queue** family (decision 1974; wow-re
//! `system/ui/scratch/battlefield-verb-family.md` §2.1, §2.2, §3.1–3.6, §5.2): the fourteen
//! verbs the stock `BattlefieldFrame.lua` and `Minimap.xml` call, over the instance list and the
//! three queue slots the app pushes. The scoreboard half of the same TU is
//! [`super::battlefield_score`]; `AcceptBattlefieldPort` is [`super::dialog_verbs`]'s.
//!
//! ## What is the app's and what is the VM's
//!
//! The reference keeps three caches in `BattlefieldInfo.cpp`: the instance-id vector the
//! `SMSG_BATTLEFIELD_LIST` handler fills (§2.1), the fixed three-slot queue array
//! `SMSG_BATTLEFIELD_STATUS` writes (§2.2), and four scalars beside them — the battlemaster
//! guid, the listed map, the bracket-adjusted level pair, and the **selected instance id**. Every
//! verb below reads those and nothing else. The app owns the wire, the clock and the Map.dbc
//! resolves, so it pushes the list ([`BattlefieldListView`]) when a list lands and the slots
//! ([`BattlefieldQueueSlot`], with every clock-shaped value already in milliseconds) every frame;
//! the VM owns the selection (`[0xb6eba0]`), the index arithmetic and the getters' shapes.
//!
//! ## The selection is a value, not an index (§3.5)
//!
//! `SetSelectedBattlefield(n)` stores the **instance id** at list position `n`, and
//! `GetSelectedBattlefield()` scans the current list for that id — so a fresh list between the
//! two calls can move or drop the selection, which the stock window's `zoneIndex − 1 ==
//! GetSelectedBattlefield()` highlight then follows. A client that remembered an index would
//! diverge the moment a list reorders.
//!
//! ## Shapes (§3, §3.1)
//!
//! Every required index is shape A (`lua_isnumber`, so a numeric string passes; truncated toward
//! zero; a non-number raises the binding's own `Usage:`), 1-based with the reference's unsigned
//! `dec; cmp; jae` gate that rejects 0 and negatives in one branch. `GetBattlefieldStatus`
//! answers **five values on every leg** — `(nil, nil, 0, 0, 0)` off the three slots.
//! `GetBattlefieldInfo` answers nine or none. `GetBattlefieldInstanceInfo` raises with
//! `GetBattlefieldInfo`'s usage string — the shipped client's own mislabel, reproduced.
//! `CloseBattlefield` does nothing (`xor eax,eax; ret`).
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{bool_or_default, number_arg, predicate};
use super::Model;
/// One queue slot as the app pushes it — the reference's `0x20`-byte slot (§2.2) plus the map
/// name its `GetBattlefieldStatus` resolves off Map.dbc, and the three clock-shaped fields
/// already reduced against the app's clock.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct BattlefieldQueueSlot {
/// `+0x00` — the Map.dbc row id; `0` for a cleared slot. The name is resolved for that id too
/// (the reference looks map 0 up like any other row).
pub map_id: u32,
/// The localized map name, or `None` when the id has no row (a real `nil`).
pub map_name: Option<String>,
/// `+0x04` — `0` none, `1` queued, `2` confirm, `3` active; anything else answers `"error"`.
pub status: u32,
/// `+0x10` — the instance id.
pub instance_id: u32,
/// `+0x08`/`+0x0c` — the bracket-adjusted level pair.
pub min_level: u32,
pub max_level: u32,
/// `GetBattlefieldPortExpiration`: `deadline − now` in ms, 0 when unset or past.
pub port_expiration_ms: u32,
/// `GetBattlefieldEstimatedWaitTime`: the stored value, raw (no clock).
pub estimated_wait_ms: u32,
/// `GetBattlefieldTimeWaited`: `now − stamp` in ms, 0 when unset.
pub time_waited_ms: u32,
}
/// The Map.dbc half of `GetBattlefieldInfo` (§3.4), resolved by the app for the listed map.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldMapInfo {
/// The localized map name (value 1).
pub name: String,
/// The faction-side description (value 2) — `None` on the reference's `-1` faction leg, where
/// it pushes nothing at all and Lua reads a stack slot below the tuple; here that leg answers
/// `nil`, the one shape this binding does not reproduce (the leg is data-unreachable: every
/// playable race carries a faction bit).
pub description: Option<String>,
/// The row's raw `MinLevel`/`MaxLevel` columns (values 3 and 4 — the stock Lua names them
/// `minLevel, maxLevel`; the bracket-adjusted pair is values 8 and 9).
pub min_level: u32,
pub max_level: u32,
/// Values 5–7: `[row+0x40]` signed, `[row+0x44]` and `[row+0x48]` f32.
pub field_16: i32,
pub field_17: f32,
pub field_18: f32,
}
/// The instance list and its scalars as the app pushes them ([`super::UiScript::set_battlefield_list`]).
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldListView {
/// The instance ids in wire order (`[0xb6e868]`).
pub instances: Vec<u32>,
/// The bracket-adjusted level pair the list handler derived (`[0xb6eba8]`/`[0xb6ebac]`).
pub bracket_min: u32,
pub bracket_max: u32,
/// The listed map's row, `None` when the id resolves to no row (the second zero-values gate).
pub info: Option<BattlefieldMapInfo>,
/// The listed map row's group-queue flag (`[row+0xa0]`, §3.6) — `CanJoinBattlefieldAsGroup`.
pub group_queue: bool,
}
/// The reference's 1-based index gate: `dec eax; cmp eax,n; jae bail` — an unsigned compare, so
/// `0` and every negative fall out in the same branch as `> n`.
fn slot_index(index: i32, n: usize) -> Option<usize> {
usize::try_from(index)
.ok()?
.checked_sub(1)
.filter(|&i| i < n)
}
/// The local-player gate `GetBattlefieldInfo` and `GetBattlefieldInstanceInfo` answer zero values
/// behind (`0x468550` + `0x468460(ecx = 0x10)`: the player object, read at call time) — here the
/// unit model's `"player"` token, the same answer `UnitExists("player")` gives.
fn player_exists(model: &Model) -> bool {
model.unit("player").is_some_and(|u| u.exists)
}
/// `[slot+0x04]` → the status string, through the reference's four-entry jump table with its
/// `"error"` default (§3.2).
fn status_text(status: u32) -> &'static str {
match status {
0 => "none",
1 => "queued",
2 => "confirm",
3 => "active",
_ => "error",
}
}
impl super::UiScript {
/// Push the instance list. The selection (`[0xb6eba0]`) is left alone — a new list does not
/// clear it, it only decides whether `GetSelectedBattlefield` still finds it (§3.5).
pub fn set_battlefield_list(&mut self, list: BattlefieldListView) {
self.model_mut().battlefield_list = list;
}
/// The world-enter reset's half of this family (§8): the selection cleared with the list.
pub fn reset_battlefield_selection(&mut self) {
self.model_mut().battlefield_selected = 0;
}
/// Push the three queue slots (clock-shaped values already reduced) and the instance
/// expiration (`[0xb6ebb8]`, `deadline − now`, 0 when unset or past) — every frame, since
/// three of the getters move with the clock.
pub fn set_battlefield_queue(
&mut self,
slots: Vec<BattlefieldQueueSlot>,
instance_expiration_ms: u32,
) {
let mut model = self.model_mut();
model.battlefield_slots = slots;
model.battlefield_instance_expiration_ms = instance_expiration_ms;
}
/// `JoinBattlefield` calls since the last drain: `(instance id, 0 = first available; as a
/// group)`. The app adds the map, the cached battlemaster guid (which picks the opcode) and
/// the group-size refusal (§5.2).
pub fn take_battlefield_join_requests(&mut self) -> Vec<(u32, bool)> {
std::mem::take(&mut self.model_mut().battlefield_join_requests)
}
/// `ShowBattlefieldList` calls that passed their gates since the last drain: the queued
/// slot's map id, `CMSG_BATTLEFIELD_LIST`'s payload.
pub fn take_battlefield_list_requests(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().battlefield_list_requests)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// `GetNumBattlefields()` — 0 args, never raises: the instance count.
g.set(
"GetNumBattlefields",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.battlefield_list.instances.len() as i64)
})?,
)?;
// `GetBattlefieldInfo()` — 0 args, never raises; ZERO values behind either gate (no player, no
// map row), else nine (§3.4).
g.set(
"GetBattlefieldInfo",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let list = &model.battlefield_list;
let (Some(info), true) = (list.info.as_ref(), player_exists(&model)) else {
return Ok(MultiValue::new());
};
let description = match &info.description {
Some(d) => Value::String(lua.create_string(d)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&info.name)?),
description,
Value::Number(f64::from(info.min_level)),
Value::Number(f64::from(info.max_level)),
Value::Number(f64::from(info.field_16)),
Value::Number(f64::from(info.field_17)),
Value::Number(f64::from(info.field_18)),
Value::Number(f64::from(list.bracket_min)),
Value::Number(f64::from(list.bracket_max)),
]))
})?,
)?;
// `GetBattlefieldInstanceInfo(index)` — raises with GetBattlefieldInfo's usage string (the
// shipped mislabel, §3.1); no player or out of range → zero values; else the instance id,
// pushed SIGNED (`fild dword`).
g.set(
"GetBattlefieldInstanceInfo",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetBattlefieldInfo(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let list = &model.battlefield_list;
if !player_exists(&model) {
return Ok(MultiValue::new());
}
Ok(match slot_index(index, list.instances.len()) {
Some(i) => {
MultiValue::from_vec(vec![Value::Number(f64::from(list.instances[i] as i32))])
}
None => MultiValue::new(),
})
})?,
)?;
// `JoinBattlefield(index [, asGroup])` — arg 1 shape A, arg 2 the never-raising optional
// boolean reader; the instance is `index−1 < count ? list[index−1] : 0` (0 = first
// available, and what an out-of-range index — 0 included — degrades to), §5.2. The refusal
// leg and the opcode choice are the app's and invisible to Lua.
g.set(
"JoinBattlefield",
lua.create_function(|lua, (index, as_group): (Value, Value)| {
let index = number_arg(lua, index, "Usage: JoinBattlefield(index)")?;
let as_group = bool_or_default(Some(&as_group), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let instance = slot_index(index, model.battlefield_list.instances.len())
.map_or(0, |i| model.battlefield_list.instances[i]);
model.battlefield_join_requests.push((instance, as_group));
Ok(())
})?,
)?;
// `CloseBattlefield()` — `xor eax,eax; ret`: nothing, and the stock window calls it on hide.
g.set("CloseBattlefield", lua.create_function(|_, ()| Ok(()))?)?;
// `SetSelectedBattlefield(index)` — stores the instance id at that position, or 0 when out
// of range (§3.5).
g.set(
"SetSelectedBattlefield",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: SetSelectedBattlefield(index)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.battlefield_selected = slot_index(index, model.battlefield_list.instances.len())
.map_or(0, |i| model.battlefield_list.instances[i]);
Ok(())
})?,
)?;
// `GetSelectedBattlefield()` — the 1-based position of the stored id in the CURRENT list, or
// 0 on a miss (which is also what a stored 0 answers: the "first available" row).
g.set(
"GetSelectedBattlefield",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let selected = model.battlefield_selected;
Ok(model
.battlefield_list
.instances
.iter()
.position(|&id| id == selected)
.map_or(0, |i| i as i64 + 1))
})?,
)?;
// `GetBattlefieldStatus(index)` — five values on EVERY leg (§3.2): off 1..3 it is
// `(nil, nil, 0, 0, 0)`, never a raise and never zero values.
g.set(
"GetBattlefieldStatus",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetBattlefieldStatus(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slot) = slot_index(index, 3).and_then(|i| model.battlefield_slots.get(i))
else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Number(0.0),
Value::Number(0.0),
Value::Number(0.0),
]));
};
let name = match &slot.map_name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(status_text(slot.status))?),
name,
Value::Number(f64::from(slot.instance_id)),
Value::Number(f64::from(slot.min_level)),
Value::Number(f64::from(slot.max_level)),
]))
})?,
)?;
// The three per-slot clock-shaped getters (§3.3): shape A index, 0 off 1..3, one number
// always. The app reduces each against its clock; the values arrive in ms.
for (name, usage, read) in [
(
"GetBattlefieldPortExpiration",
"Usage: GetBattlefieldPortExpiration(index)",
(|s: &BattlefieldQueueSlot| s.port_expiration_ms) as fn(&BattlefieldQueueSlot) -> u32,
),
(
"GetBattlefieldEstimatedWaitTime",
"Usage: GetBattlefieldEstimatedWaitTime(index)",
|s: &BattlefieldQueueSlot| s.estimated_wait_ms,
),
(
"GetBattlefieldTimeWaited",
"Usage: GetBattlefieldTimeWaited(index)",
|s: &BattlefieldQueueSlot| s.time_waited_ms,
),
] {
g.set(
name,
lua.create_function(move |lua, index: Value| {
let index = number_arg(lua, index, usage)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(
slot_index(index, 3)
.and_then(|i| model.battlefield_slots.get(i))
.map_or(0, read),
))
})?,
)?;
}
// `GetBattlefieldInstanceExpiration()` — 0 args: `[0xb6ebb8] − now`, 0 when unset or past.
g.set(
"GetBattlefieldInstanceExpiration",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.battlefield_instance_expiration_ms))
})?,
)?;
// `ShowBattlefieldList(index)` — shape A; silent off 1..3, on an empty slot, and on any
// status but "queued"; else the slot's map goes out as `CMSG_BATTLEFIELD_LIST`. No event.
g.set(
"ShowBattlefieldList",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: ShowBattlefieldList(index)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let map_id = slot_index(index, 3)
.and_then(|i| model.battlefield_slots.get(i))
.filter(|s| s.map_id != 0 && s.status == 1)
.map(|s| s.map_id);
if let Some(map_id) = map_id {
model.battlefield_list_requests.push(map_id);
}
Ok(())
})?,
)?;
// `CanJoinBattlefieldAsGroup()` — `1` or nil off the listed map's `+0xa0` flag (§3.6). The
// join-time size check reads a different column and lives with the sender.
g.set(
"CanJoinBattlefieldAsGroup",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(predicate(model.battlefield_list.group_queue))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
/// A VM with a local player — the state every in-world call is made in.
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_unit(
"player",
Some(crate::script::UnitState {
exists: true,
name: Some("Probe".into()),
..Default::default()
}),
);
s
}
fn list(ids: &[u32]) -> BattlefieldListView {
BattlefieldListView {
instances: ids.to_vec(),
bracket_min: 20,
bracket_max: 29,
info: Some(BattlefieldMapInfo {
name: "Arathi Basin".into(),
description: Some("The Arathi Basin is …".into()),
min_level: 20,
max_level: 60,
field_16: -1,
field_17: 0.0,
field_18: 0.0,
}),
group_queue: true,
}
}
fn slot(map_id: u32, status: u32, instance: u32) -> BattlefieldQueueSlot {
BattlefieldQueueSlot {
map_id,
map_name: Some(format!("Map {map_id}")),
status,
instance_id: instance,
min_level: 20,
max_level: 29,
port_expiration_ms: 60_000,
estimated_wait_ms: 30_000,
time_waited_ms: 5_000,
}
}
/// The reference's 1-based gate: 0 and negatives fall out with the too-large ones.
#[test]
fn the_index_gate_is_unsigned_and_one_based() {
assert_eq!(slot_index(1, 3), Some(0));
assert_eq!(slot_index(3, 3), Some(2));
assert_eq!(slot_index(4, 3), None);
assert_eq!(slot_index(0, 3), None);
assert_eq!(slot_index(-1, 3), None);
assert_eq!(slot_index(1, 0), None);
}
/// Nine values past both gates, none behind either; the two raw row levels sit at 3 and 4
/// and the bracket pair at 8 and 9.
#[test]
fn get_battlefield_info_answers_nine_or_none() {
let mut s = vm();
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldInfo())")
.unwrap(),
0,
"no list at all: the map gate"
);
s.set_battlefield_list(list(&[7, 3]));
s.set_unit("player", None);
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldInfo())")
.unwrap(),
0,
"no player: the other gate, read at call time"
);
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldInstanceInfo(1))")
.unwrap(),
0,
"…and the instance verb's"
);
s = vm();
s.set_battlefield_list(list(&[7, 3]));
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldInfo())")
.unwrap(),
9
);
let got = s
.eval::<String>(
"local n, d, a, b, c, x, y, lo, hi = GetBattlefieldInfo() \
return n .. '|' .. a .. '|' .. b .. '|' .. c .. '|' .. lo .. '|' .. hi",
)
.unwrap();
assert_eq!(got, "Arathi Basin|20|60|-1|20|29");
}
/// The instance verb: the wrong usage string, the silent out-of-range leg, the signed push.
#[test]
fn get_battlefield_instance_info_raises_with_the_mislabel_and_bails_silently() {
let mut s = vm();
s.set_battlefield_list(list(&[7, 0xFFFF_FFFF]));
assert_eq!(
s.eval::<i64>("return GetBattlefieldInstanceInfo(1)")
.unwrap(),
7
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldInstanceInfo('2')")
.unwrap(),
-1,
"a numeric string passes; the id is pushed signed"
);
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldInstanceInfo(3))")
.unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return select('#', GetBattlefieldInstanceInfo(0))")
.unwrap(),
0
);
let err = s
.run("GetBattlefieldInstanceInfo(nil)")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: GetBattlefieldInfo(index)"),
"the shipped client's own mislabel: {err}"
);
assert_eq!(s.eval::<i64>("return GetNumBattlefields()").unwrap(), 2);
}
/// The selection round-trips by VALUE: a reordered list moves it, a shortened one drops it.
#[test]
fn the_selection_is_an_instance_id_not_a_position() {
let mut s = vm();
s.set_battlefield_list(list(&[7, 3, 11]));
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 0);
s.run("SetSelectedBattlefield(2)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 2);
s.set_battlefield_list(list(&[3, 7, 11]));
assert_eq!(
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
1,
"instance 3 moved to the front"
);
s.set_battlefield_list(list(&[7, 11]));
assert_eq!(
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
0,
"instance 3 left the list"
);
s.run("SetSelectedBattlefield(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 0);
s.run("SetSelectedBattlefield(9)").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
0,
"out of range stores 0"
);
let err = s.run("SetSelectedBattlefield({})").unwrap_err().to_string();
assert!(
err.contains("Usage: SetSelectedBattlefield(index)"),
"{err}"
);
}
/// `JoinBattlefield`: the instance at the position, 0 for "first available" and for any
/// out-of-range index, the optional group flag through the never-raising reader.
#[test]
fn join_battlefield_resolves_the_instance_and_the_group_flag() {
let mut s = vm();
s.set_battlefield_list(list(&[7, 3]));
s.run("JoinBattlefield(0) JoinBattlefield(2, 1) JoinBattlefield(5, true) JoinBattlefield(1, nil)")
.unwrap();
assert_eq!(
s.take_battlefield_join_requests(),
vec![(0, false), (3, true), (0, true), (7, false)]
);
assert!(s.take_battlefield_join_requests().is_empty(), "drained");
let err = s.run("JoinBattlefield()").unwrap_err().to_string();
assert!(err.contains("Usage: JoinBattlefield(index)"), "{err}");
s.run("CloseBattlefield()").unwrap();
}
/// Five values on every leg of the status verb; the four status strings and the default.
#[test]
fn get_battlefield_status_answers_five_values_on_every_leg() {
let mut s = UiScript::new().unwrap();
s.set_battlefield_queue(vec![slot(489, 1, 5), slot(0, 0, 0), slot(529, 9, 2)], 0);
let n = |s: &mut UiScript, i: &str| {
s.eval::<i64>(&format!("return select('#', GetBattlefieldStatus({i}))"))
.unwrap()
};
assert_eq!(n(&mut s, "1"), 5);
assert_eq!(n(&mut s, "0"), 5);
assert_eq!(n(&mut s, "4"), 5);
let got = s
.eval::<String>(
"local st, name, id, lo, hi = GetBattlefieldStatus(1) \
return st .. '|' .. name .. '|' .. id .. '|' .. lo .. '|' .. hi",
)
.unwrap();
assert_eq!(got, "queued|Map 489|5|20|29");
assert_eq!(
s.eval::<String>("return (GetBattlefieldStatus(2))")
.unwrap(),
"none"
);
assert_eq!(
s.eval::<String>("return (GetBattlefieldStatus(3))")
.unwrap(),
"error",
"a status past 3 takes the jump table's default"
);
let off = s
.eval::<String>(
"local st, name, id, lo, hi = GetBattlefieldStatus(4) \
return tostring(st) .. '|' .. tostring(name) .. '|' .. id .. lo .. hi",
)
.unwrap();
assert_eq!(off, "nil|nil|000");
let err = s.run("GetBattlefieldStatus('x')").unwrap_err().to_string();
assert!(err.contains("Usage: GetBattlefieldStatus(index)"), "{err}");
let mut empty = slot(0, 0, 0);
empty.map_name = None;
s.set_battlefield_queue(vec![empty], 0);
assert!(
s.eval::<bool>("local _, name = GetBattlefieldStatus(1) return name == nil")
.unwrap(),
"no row: a real nil"
);
}
/// The clock-shaped getters read the pushed ms and answer 0 off the slots; the instance
/// expiration is the singleton.
#[test]
fn the_time_getters_read_the_pushed_milliseconds() {
let mut s = UiScript::new().unwrap();
s.set_battlefield_queue(vec![slot(489, 1, 5)], 120_000);
assert_eq!(
s.eval::<i64>("return GetBattlefieldPortExpiration(1)")
.unwrap(),
60_000
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldEstimatedWaitTime(1)")
.unwrap(),
30_000
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldTimeWaited(1)").unwrap(),
5_000
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldTimeWaited(2)").unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldPortExpiration(0)")
.unwrap(),
0
);
assert_eq!(
s.eval::<i64>("return GetBattlefieldInstanceExpiration()")
.unwrap(),
120_000
);
for (call, usage) in [
(
"GetBattlefieldPortExpiration()",
"Usage: GetBattlefieldPortExpiration(index)",
),
(
"GetBattlefieldEstimatedWaitTime(nil)",
"Usage: GetBattlefieldEstimatedWaitTime(index)",
),
(
"GetBattlefieldTimeWaited({})",
"Usage: GetBattlefieldTimeWaited(index)",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(usage), "{call}: {err}");
}
}
/// `ShowBattlefieldList` sends only for a queued, non-empty slot; the group predicate is
/// `1` or nil.
#[test]
fn show_battlefield_list_gates_on_a_queued_slot() {
let mut s = UiScript::new().unwrap();
s.set_battlefield_queue(vec![slot(489, 1, 5), slot(529, 2, 1), slot(0, 1, 0)], 0);
s.run("ShowBattlefieldList(1) ShowBattlefieldList(2) ShowBattlefieldList(3) ShowBattlefieldList(4)")
.unwrap();
assert_eq!(s.take_battlefield_list_requests(), vec![489]);
let err = s.run("ShowBattlefieldList('q')").unwrap_err().to_string();
assert!(err.contains("Usage: ShowBattlefieldList(index)"), "{err}");
assert!(s
.eval::<bool>("return CanJoinBattlefieldAsGroup() == nil")
.unwrap());
s.set_battlefield_list(list(&[]));
assert_eq!(
s.eval::<i64>("return CanJoinBattlefieldAsGroup()").unwrap(),
1
);
}
}
@@ -10,6 +10,8 @@
//! | `GetAreaSpiritHealerTime()` | 0 → 1 number, whole seconds | reads the app's deadline |
//! | `AcceptBattlefieldPort(index, accept)` | 2 → 0 | raises on a non-number index, silent off 1..3; the optional-bool answer |
//! | `CancelMeetingStoneRequest()` | 0 → 0 | queued; the app applies the leadership gate |
//! | `IsInMeetingStoneQueue()` | 0 → the number `1` or `nil`, one value always | `[0xb72038] != 0` (wow-re `meeting-stone-status.md` §3.1, 1974) |
//! | `GetMeetingStoneStatusText()` | 0 → string or `nil` | the cached text `[0xb7203c]`, `nil` while empty (§3.2) |
//! | `CheckPetUntrainerDist()` | 0 → `1` or `nil` | the app's latch-and-range flag |
//! | `ConfirmPetUnlearn()` | 0 → 0 | counted; the app holds the latch and the money gate |
//!
@@ -63,6 +65,15 @@ impl super::UiScript {
pub fn take_meeting_stone_cancels(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().meeting_stone_cancels)
}
/// The meeting stone's two globals (1974): the queued area id (`[0xb72038]`, `0` = none) and
/// the cached status text (`[0xb7203c]`, `None` from process start to world enter and after
/// world leave). The app rebuilds the text; the VM only hands it back.
pub fn set_meeting_stone(&mut self, area: u32, status_text: Option<String>) {
let mut model = self.model_mut();
model.meeting_stone_area = area;
model.meeting_stone_status_text = status_text;
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -154,6 +165,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `IsInMeetingStoneQueue()` (`0x4ca570`): `mov eax,1` on BOTH legs — the number `1` when the
// queued area is non-zero, else nil; never `0` (truthy in Lua — it would pin the icon shown),
// never a boolean.
g.set(
"IsInMeetingStoneQueue",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(predicate(model.meeting_stone_area != 0))
})?,
)?;
// `GetMeetingStoneStatusText()` (`0x4ca5b0`): `lua_pushstring([0xb7203c])`, whose NULL leg
// tail-jumps to `lua_pushnil` — a string or nil, one value, never `""` for "nothing".
g.set(
"GetMeetingStoneStatusText",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match &model.meeting_stone_status_text {
Some(text) => Value::String(lua.create_string(text)?),
None => Value::Nil,
})
})?,
)?;
// §9: `1` or `nil` — never `0`. The range test (`d² <= INTERACT_DISTANCE²`, a NaN OUT) is the
// app's, on the latched trainer.
g.set(
@@ -263,6 +298,41 @@ mod tests {
);
}
/// The meeting stone pair: `1`/nil on the area, string/nil on the text — one value each.
#[test]
fn the_meeting_stone_pair_answers_one_or_nil_and_string_or_nil() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', IsInMeetingStoneQueue())")
.unwrap(),
1
);
assert!(s
.eval::<bool>("return IsInMeetingStoneQueue() == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetMeetingStoneStatusText() == nil")
.unwrap());
s.set_meeting_stone(1519, Some("Looking for more for Stormwind City".into()));
assert_eq!(s.eval::<i64>("return IsInMeetingStoneQueue()").unwrap(), 1);
assert_eq!(
s.eval::<String>("return GetMeetingStoneStatusText()")
.unwrap(),
"Looking for more for Stormwind City"
);
s.set_meeting_stone(0, Some("Unknown".into()));
assert!(
s.eval::<bool>("return IsInMeetingStoneQueue() == nil")
.unwrap(),
"area 0: not queued, whatever the text says"
);
assert_eq!(
s.eval::<String>("return GetMeetingStoneStatusText()")
.unwrap(),
"Unknown"
);
}
#[test]
fn the_pet_pair_counts_and_answers_one_or_nil() {
let mut s = UiScript::new().unwrap();
+4 -1
View File
@@ -131,8 +131,11 @@ impl UiScript {
// [`QuadContent::ModelPane`]). Both widget kinds share `KindState::Model`
// because the client's `CGCharacterModelBase` extends `CSimpleModel`, and
// both draw the same way here.
Some(crate::widget::KindState::Model(_)) => QuadContent::ModelPane {
Some(crate::widget::KindState::Model(m)) => QuadContent::ModelPane {
name: frame.and_then(|f| f.name.clone()),
model: m.path.clone(),
facing: m.facing,
model_scale: m.scale,
},
_ => QuadContent::Frame,
};
+11 -10
View File
@@ -176,22 +176,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
"GetPingPosition",
// The live ping's normalized offsets from the widget centre (fractions of the widget
// side, x right / y up — the `MINIMAP_PING` event's own arg2/arg3 space), recomputed by
// the app from the ping's world point every frame one is live. So a caller polling this
// while walking sees the value MOVE, which is the whole point: the ping is pinned to the
// world, not to the map.
// The ping's normalized offsets from the widget centre (fractions of the widget side,
// x right / y up — the `MINIMAP_PING` event's own arg2/arg3 space), recomputed by the app
// from the stored world point every frame. So a caller polling this while walking sees
// the value MOVE, which is the whole point: the ping is pinned to the world, not to the
// map.
//
// Returns **nil, nil** when no ping is live, rather than `(0, 0)`: zero is a real answer
// meaning "the ping is under your feet", and handing it back for "there is no ping" is a
// lie a caller cannot tell from the truth (1203).
// **Two numbers, always** (`0x4eefd0`, wow-re `minimap-ping-law.md`): the reference
// recomputes from statics nothing ever clears, and the stock `Minimap_OnUpdate` feeds the
// answer straight into `x * Minimap:GetWidth()` for the whole of its 5 s timer — a nil
// here is a Lua error on every frame of that window (1974).
lua.create_function(|lua, this: Table| {
with_minimap(lua, &this, |_| ())?;
let ping = lua
let (x, y) = lua
.app_data_ref::<Model>()
.expect("model app_data")
.minimap_ping;
Ok(ping.map_or((None, None), |(x, y)| (Some(x), Some(y))))
Ok((x, y))
})?,
)?;
lua.set_named_registry_value(REG_MINIMAP_METHODS, m)?;
+29 -1
View File
@@ -43,11 +43,15 @@ mod auction;
mod aura;
mod backdrop;
mod bank;
mod battlefield_positions;
mod battlefield_queue;
mod battlefield_score;
mod bind_confirm;
mod binder;
mod binding_abi;
mod dialog_verbs;
mod tutorial;
mod worldmap_arrow;
// The five camera views + FlipCameraYaw — the reference's `UIUtil\Camera.cpp` Lua surface.
mod button;
mod camera_view;
@@ -84,6 +88,7 @@ mod gossip;
mod guild;
mod handler_prof;
mod screenshot;
mod tabard;
pub use handler_prof::HandlerRow;
mod inspect;
mod item_stats;
@@ -162,6 +167,8 @@ pub use auction::{
pub use aura::{AuraState, TrackingState};
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
pub use battlefield_positions::{BattlefieldFlagView, BattlefieldPositionView};
pub use battlefield_queue::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot};
pub use battlefield_score::{BattlefieldScoreRow, BattlefieldScores, BattlefieldStatColumn};
pub use bind_confirm::PendingEquipAnswer;
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
@@ -199,6 +206,7 @@ pub use petition::{
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
PETITION_TYPE_PETITION,
};
pub use worldmap_arrow::{ARROW_FOOTPRINT_PX, ARROW_MODEL};
pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
@@ -611,6 +619,10 @@ impl UiScript {
talent::install(&lua)?;
dialog_verbs::install(&lua)?;
battlefield_score::install(&lua)?;
battlefield_queue::install(&lua)?;
battlefield_positions::install(&lua)?;
tutorial::install(&lua)?;
worldmap_arrow::install(&lua)?;
shapeshift::install(&lua)?;
pet::install(&lua)?;
gossip::install(&lua)?;
@@ -625,6 +637,7 @@ impl UiScript {
trade::install(&lua)?;
inspect::install(&lua)?;
dressup::install(&lua)?;
tabard::install(&lua)?;
tradeskill::install(&lua)?;
craft::install(&lua)?;
reputation::install(&lua)?;
@@ -853,7 +866,7 @@ impl UiScript {
///
/// One field, not one per widget: there is exactly one ping (decision 1596), and the old
/// per-widget push walked the whole ~3k-frame arena every frame a ping was live.
pub fn set_minimap_ping(&mut self, ping: Option<(f32, f32)>) {
pub fn set_minimap_ping(&mut self, ping: (f32, f32)) {
self.model_mut().minimap_ping = ping;
}
@@ -1050,6 +1063,20 @@ impl UiScript {
.any(|(_, f)| f.name.as_deref() == Some(name) && f.effective_visible)
}
/// The effective alpha of the frame with this global name while it is effectively visible,
/// `None` when it is hidden or no live frame carries the name. The read side of a frame whose
/// pixels a host draws for the engine: the stock `MiniMapPing` is a `<Model>` this engine
/// renders nothing for, so the app's ping sprite follows the frame's own show/hide and alpha
/// — the lifetime the reference's `Minimap.lua` owns — instead of keeping a clock of its own
/// (1974). A linear scan, like [`Self::frame_visible`].
pub fn frame_effective_alpha(&self, name: &str) -> Option<f32> {
self.model_ref()
.arena
.iter_frames()
.find(|(_, f)| f.name.as_deref() == Some(name) && f.effective_visible)
.map(|(_, f)| f.effective_alpha)
}
/// Resolve every frame's rect: sync each frame's effective scale from the arena into its layout
/// input, run the [`crate::layout`] graph (screen root as the external base), and cache the
/// resolved rects. `GetWidth`/`GetHeight`/`extract` read this cache.
@@ -1419,6 +1446,7 @@ impl UiScript {
crate::widget::FrameKind::Model => "Model",
crate::widget::FrameKind::PlayerModel => "PlayerModel",
crate::widget::FrameKind::DressUpModel => "DressUpModel",
crate::widget::FrameKind::TabardModel => "TabardModel",
crate::widget::FrameKind::MessageFrame => "MessageFrame",
crate::widget::FrameKind::ScrollingMessageFrame => "ScrollingMessageFrame",
crate::widget::FrameKind::ColorSelect => "ColorSelect",
+56 -8
View File
@@ -824,8 +824,37 @@ pub(crate) struct Model {
pub(crate) battlefield_score_requests: u32,
/// `LeaveBattlefield()` calls that passed the "ended" gate since the last drain.
pub(crate) battlefield_leave_requests: u32,
/// The battleground instance list and its scalars (1974), as the app last pushed them.
pub(crate) battlefield_list: super::battlefield_queue::BattlefieldListView,
/// `[0xb6eba0]` — the selected instance id (a VALUE; `SetSelectedBattlefield` writes it).
pub(crate) battlefield_selected: u32,
/// The three queue slots (1974), pushed each frame with their clock-shaped values reduced.
pub(crate) battlefield_slots: Vec<super::battlefield_queue::BattlefieldQueueSlot>,
/// `GetBattlefieldInstanceExpiration()`'s answer, pushed each frame.
pub(crate) battlefield_instance_expiration_ms: u32,
/// `JoinBattlefield` calls since the last drain: `(instance id, as group)`.
pub(crate) battlefield_join_requests: Vec<(u32, bool)>,
/// `ShowBattlefieldList` calls that passed their gates: the queued slot's map id.
pub(crate) battlefield_list_requests: Vec<u32>,
/// The battleground teammates' map positions as the app resolved them (1980), the flag
/// carrier, the active map's icon scale, and `RequestBattlefieldPositions()` calls.
pub(crate) battlefield_positions: Vec<super::battlefield_positions::BattlefieldPositionView>,
pub(crate) battlefield_flag: Option<super::battlefield_positions::BattlefieldFlagView>,
pub(crate) battlefield_icon_scale: f32,
pub(crate) battlefield_position_requests: u32,
/// `CancelMeetingStoneRequest()` calls since the last drain — the app gates on leadership.
pub(crate) meeting_stone_cancels: u32,
/// The meeting stone's queued area (`[0xb72038]`) and cached status text (`[0xb7203c]`), as the
/// app last pushed them (1974).
pub(crate) meeting_stone_area: u32,
pub(crate) meeting_stone_status_text: Option<String>,
/// The tutorial system's acknowledged bank, as the app last pushed it (1976); `None` before
/// `SMSG_TUTORIAL_FLAGS` — `TutorialsEnabled()`'s only input.
pub(crate) tutorial_bank: Option<Vec<u8>>,
/// `FlagTutorial(n)` calls since the last drain (0-based, in range).
pub(crate) tutorial_flag_requests: Vec<u32>,
pub(crate) tutorial_clears: u32,
pub(crate) tutorial_resets: u32,
/// The stance/shapeshift bar's form list (bar order) the app pushes — the
/// `GetNumShapeshiftForms`/`GetShapeshiftFormInfo` family reads it ([`super::shapeshift`]).
@@ -1508,13 +1537,16 @@ pub(crate) struct Model {
/// the app's minimap geometry lives in; the app applies the 0582 seam scale on the way in,
/// and getting that wrong is decision 1596's first root cause).
pub(crate) minimap_ping_request: Option<(f32, f32)>,
/// The live minimap ping's **normalized** offsets from the widget centre (fractions of the
/// widget side, x right / y up — the `MINIMAP_PING` event's arg2/arg3 space), republished
/// by the app every frame a ping is live and cleared when the ping ends. Behind
/// `Minimap:GetPingPosition()`. There is exactly one ping, so it lives here rather than on
/// each Minimap widget's [`KindState`](crate::widget::KindState) — one write, one read,
/// no arena walk (decision 1596).
pub(crate) minimap_ping: Option<(f32, f32)>,
/// The minimap ping's **normalized** offsets from the widget centre (fractions of the widget
/// side, x right / y up — the `MINIMAP_PING` event's arg2/arg3 space), republished by the app
/// every frame from the stored world point against the player's live position. Behind
/// `Minimap:GetPingPosition()`, which answers **two numbers always** — the reference
/// recomputes them per call from a pair of statics nothing ever clears (wow-re
/// `minimap-ping-law.md`), and the stock `Minimap_OnUpdate` multiplies the answer without a
/// nil test for the whole of its 5 s timer (1974; 1596's nil answer stood until then). There
/// is exactly one ping, so it lives here rather than on each Minimap widget's
/// [`KindState`](crate::widget::KindState) — one write, one read, no arena walk.
pub(crate) minimap_ping: (f32, f32),
/// The realm this session is on, behind `GetRealmName()` (decision 1195). `""` until the app
/// pushes one — the glue screen's own answer, and never `nil`, because the corpus idiom is
/// `db[GetRealmName()] = …` at file scope and a nil index errors one call deeper.
@@ -1796,7 +1828,23 @@ impl Model {
battlefield_run_time_ms: 0,
battlefield_score_requests: 0,
battlefield_leave_requests: 0,
battlefield_list: Default::default(),
battlefield_selected: 0,
battlefield_slots: Vec::new(),
battlefield_instance_expiration_ms: 0,
battlefield_join_requests: Vec::new(),
battlefield_list_requests: Vec::new(),
battlefield_positions: Vec::new(),
battlefield_flag: None,
battlefield_icon_scale: 1.0,
battlefield_position_requests: 0,
meeting_stone_cancels: 0,
meeting_stone_area: 0,
meeting_stone_status_text: None,
tutorial_bank: None,
tutorial_flag_requests: Vec::new(),
tutorial_clears: 0,
tutorial_resets: 0,
shapeshift_forms: Vec::new(),
shapeshift_casts: Vec::new(),
pet_bar: super::pet::PetBarState::default(),
@@ -1954,7 +2002,7 @@ impl Model {
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),
minimap_ping_request: None,
minimap_ping: None,
minimap_ping: (0.0, 0.0),
chat_sends: Vec::new(),
addon_sends: Vec::new(),
played_time_asks: 0,
+10 -7
View File
@@ -16,18 +16,17 @@
//! CSimpleFrame 0x778590
//! └─ CSimpleModel 0x76f870 table 0x878948 (23) <Model> ← built here
//! └─ CGCharacterModelBase 0x506260 table 0x84f1fc (3) <PlayerModel> ← built here
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← not built
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← not built
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← `dressup` (1969)
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← `tabard` (1977)
//! ```
//!
//! Our `__index` dispatcher already walks a *slice* of registry keys per kind, so the chain is
//! `&[REG_PLAYERMODEL_METHODS, REG_MODEL_METHODS]` and neither table duplicates the other. The
//! direction is derived → base **only**: a plain `<Model>` does not acquire `SetUnit`.
//!
//! `DressUpModel` (`Undress`/`Dress`/`TryOn`) and `TabardModel` (10 tabard verbs) are deliberately
//! **not built**: zero callers in the corpus, and our dress-up window already models the intents
//! host-side (`super::dressup`), so wiring them is a design change to that subsystem rather than a
//! missing verb. Named, not stubbed (decision 1134 §4).
//! `DressUpModel` (`Undress`/`Dress`/`TryOn`) is `super::dressup` (1969) and `TabardModel` (the ten
//! tabard verbs) `super::tabard` (1977) — each built the day its stock window came onto the chain,
//! as 1134 §4 said they would be.
//!
//! ## Why this surface, in this order
//!
@@ -93,7 +92,11 @@ pub(super) const REG_PLAYERMODEL_METHODS: &str = "__benilla_playermodel_methods"
/// Run `f` over a frame's Model state under one short write borrow. Errors if `this` is not a live
/// Model (unreachable through the kind dispatcher, but the method table is a plain Lua value — a
/// caller can fish it out and misapply it).
fn with_model<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut ModelState) -> T) -> mlua::Result<T> {
pub(super) fn with_model<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ModelState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
+101 -2
View File
@@ -16,7 +16,7 @@
use std::ffi::c_void;
use mlua::{LightUserData, Lua, Table, Value};
use mlua::{LightUserData, Lua, ObjectLike, Table, Value};
use super::binding_abi::optional_string;
use super::{Model, REG_FRAME_META, REG_FRAME_METHODS, REG_SCRIPTS, REG_WRAPPERS};
@@ -235,6 +235,7 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
"MODEL" => FrameKind::Model,
"PLAYERMODEL" => FrameKind::PlayerModel,
"DRESSUPMODEL" => FrameKind::DressUpModel,
"TABARDMODEL" => FrameKind::TabardModel,
"MESSAGEFRAME" => FrameKind::MessageFrame,
"SCROLLINGMESSAGEFRAME" => FrameKind::ScrollingMessageFrame,
"COLORSELECT" => FrameKind::ColorSelect,
@@ -318,6 +319,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetupFullscreenScale(frame) — the registered binding `0x48c270` the stock WorldMapFrame.xml,
// CinematicFrame.xml and UIOptionsFrame.xml call from OnShow (decision 1980; wow-re
// `system/ui/scratch/setup-fullscreen-scale.md`, VERIFIED at the bytes): the frame's scale
// becomes `min(0.75 · a, 1.0)` for the CONFIGURED aspect `a` — the `gxResolution` width over
// height (the `widescreen` CVar's default of 1 selects it; at 0 the aspect is 4:3 and the
// scale 1) — and nothing else is written: no anchor, size or position, and `UIParent`'s
// scale never enters. It is the same `CSimpleFrame::SetScale` the Lua method calls. The
// window's aspect is the configured one here (the UI-unit rect keeps the pixel ratio); NaN
// takes the 1.0 leg as the reference's `fcomp` does. The three raises are the binding's own.
lua.globals().set(
"SetupFullscreenScale",
lua.create_function(|lua, frame: Value| {
let Value::Table(frame) = frame else {
return Err(mlua::Error::runtime("Usage: SetupFullscreenScale(frame)"));
};
if decode_id(&frame).is_err() {
return Err(mlua::Error::runtime(
"SetupFullscreenScale(): Couldn't find 'this' in frame object",
));
}
if frame_handle_of(lua, &frame).is_err() {
return Err(mlua::Error::runtime(
"SetupFullscreenScale(): Wrong object type, expected frame",
));
}
let scale = {
let model = lua.app_data_ref::<Model>().expect("model");
fullscreen_scale(model.screen.width() / model.screen.height())
};
frame.call_method::<()>("SetScale", scale)
})?,
)?;
// GetCursorPosition() → x, y — the last cursor position the host fed (`mouse_move`/
// `mouse_button`), UI units y-up like every other coordinate read. The real client scales by
// the UI scale; ours is the constant 1 (`GetEffectiveScale`), so the ref's `/scale` dance is
@@ -408,6 +442,13 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
super::modelframe::REG_PLAYERMODEL_METHODS,
super::modelframe::REG_MODEL_METHODS,
],
// Ten of its own (`0x84ee40`), then PlayerModel's three, then Model's 23 — the same
// derived → base probe as its sibling (1977).
Some(FrameKind::TabardModel) => &[
super::tabard::REG_TABARDMODEL_METHODS,
super::modelframe::REG_PLAYERMODEL_METHODS,
super::modelframe::REG_MODEL_METHODS,
],
Some(FrameKind::Minimap) => &[super::minimap::REG_MINIMAP_METHODS],
Some(FrameKind::Cooldown) => &[super::cooldown::REG_COOLDOWN_METHODS],
Some(FrameKind::GameTooltip) => &[super::tooltip::REG_TOOLTIP_METHODS],
@@ -431,7 +472,7 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
/// `virtual="true"`, or of a shape that does not fit the kind asked for — is still a warning plus a
/// working frame, because the registry lookup itself succeeded and that is the only thing the miss
/// branch tests.
fn create_frame(
pub(super) fn create_frame(
lua: &Lua,
(kind, name, parent, inherits): (String, Option<Value>, Option<Value>, Option<Value>),
) -> mlua::Result<Table> {
@@ -576,3 +617,61 @@ fn install_frame_methods(lua: &Lua) -> mlua::Result<()> {
lua.set_named_registry_value(REG_FRAME_METHODS, m)?;
Ok(())
}
/// `0x48c270`'s law: `g = 0.75 · aspect`, and the scale is `g` below 1.0, else 1.0 — NaN lands on
/// the 1.0 leg (the `fcomp` compare fails every ordered test).
pub(crate) fn fullscreen_scale(aspect: f32) -> f32 {
let g = 0.75 * aspect;
if g < 1.0 {
g
} else {
1.0
}
}
#[cfg(test)]
mod fullscreen_scale_tests {
use super::fullscreen_scale;
use crate::script::UiScript;
#[test]
fn the_scale_is_three_quarters_of_the_aspect_capped_at_one() {
assert_eq!(fullscreen_scale(4.0 / 3.0), 1.0);
assert_eq!(fullscreen_scale(16.0 / 9.0), 1.0);
assert!((fullscreen_scale(5.0 / 4.0) - 0.9375).abs() < 1e-6);
assert_eq!(fullscreen_scale(f32::NAN), 1.0);
}
#[test]
fn the_verb_scales_the_frame_and_raises_its_three_strings() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1280.0, 1024.0);
s.run(r#"f = CreateFrame("Frame", "FS") SetupFullscreenScale(f)"#)
.unwrap();
assert!((s.eval::<f64>("return f:GetScale()").unwrap() - 0.9375).abs() < 1e-6);
s.set_screen_size(1600.0, 900.0);
s.run("SetupFullscreenScale(f)").unwrap();
assert_eq!(s.eval::<f64>("return f:GetScale()").unwrap(), 1.0);
for (call, needle) in [
(
"SetupFullscreenScale()",
"Usage: SetupFullscreenScale(frame)",
),
(
"SetupFullscreenScale(7)",
"Usage: SetupFullscreenScale(frame)",
),
(
"SetupFullscreenScale({})",
"Couldn't find 'this' in frame object",
),
(
"SetupFullscreenScale(f:CreateTexture())",
"Wrong object type, expected frame",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(needle), "{call}: {err}");
}
}
}
@@ -131,10 +131,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
FrameKind::ScrollFrame => &["ScrollFrame", "Frame", "Region"],
FrameKind::Model => &["Model", "Frame", "Region"],
// 4 deep: `PlayerModel` derives from `Model`, and `DressUpModel` (1969) and
// `TabardModel` (unbuilt) derive from it in turn, for the roster's maximum depth of 5
// `TabardModel` (1977) derive from it in turn, for the roster's maximum depth of 5
// (wow-re `ui/scratch/widget-type-identity.md` §6).
FrameKind::PlayerModel => &["PlayerModel", "Model", "Frame", "Region"],
FrameKind::DressUpModel => &["DressUpModel", "PlayerModel", "Model", "Frame", "Region"],
FrameKind::TabardModel => &["TabardModel", "PlayerModel", "Model", "Frame", "Region"],
FrameKind::MessageFrame => &["MessageFrame", "Frame", "Region"],
FrameKind::ScrollingMessageFrame => &["ScrollingMessageFrame", "Frame", "Region"],
FrameKind::ColorSelect => &["ColorSelect", "Frame", "Region"],
+56
View File
@@ -0,0 +1,56 @@
//! The guild tabard designer's engine surface (decision 1977): the `TabardModel` kind's own method
//! table (`0x84ee40`, ten verbs — filled from wow-re `system/ui/scratch/tabard-designer.md`) and
//! the window's two globals.
//!
//! `GetTabardCreationCost()` is a **hard-coded constant**: `0x6d6de0` is `mov eax,0x186a0; ret` —
//! 100 000 copper, ten gold, no `.data` cell and no server input (wow-re
//! `system/ui/scratch/petition-charter-api.md`, the adjacent-family paragraph; VERIFIED there).
use mlua::Lua;
/// Registry key of the TabardModel method table — probed before PlayerModel's and Model's
/// (`object.rs`'s kind chain).
pub(super) const REG_TABARDMODEL_METHODS: &str = "__benilla_tabardmodel_methods";
/// `0x6d6de0`: the designer's price, in copper.
pub const TABARD_CREATION_COST: u32 = 100_000;
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
lua.set_named_registry_value(REG_TABARDMODEL_METHODS, m)?;
let g = lua.globals();
// `GetTabardCreationCost()` — 0 args, one number, the constant.
g.set(
"GetTabardCreationCost",
lua.create_function(|_, ()| Ok(i64::from(TABARD_CREATION_COST)))?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
#[test]
fn the_creation_cost_is_ten_gold_and_a_tabard_model_is_its_own_kind() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return GetTabardCreationCost()").unwrap(),
100_000
);
s.run(r#"t = CreateFrame("TabardModel", "TM")"#).unwrap();
assert_eq!(
s.eval::<String>("return t:GetObjectType()").unwrap(),
"TabardModel"
);
assert!(s
.eval::<bool>("return t:IsObjectType('PlayerModel') and t:IsObjectType('Model')")
.unwrap());
assert!(
s.eval::<bool>("return t.SetUnit ~= nil and t.SetRotation ~= nil")
.unwrap(),
"the inherited pane verbs"
);
}
}
@@ -293,3 +293,27 @@ fn set_mask_texture_is_state_and_empty_restores_the_default() {
// There is no getter in 1.12, and we do not invent one (decision 1189).
assert!(s.eval::<bool>("return m.GetMaskTexture == nil").unwrap());
}
/// `frame_effective_alpha` — the host-side read behind a frame whose pixels the app draws (the
/// stock `MiniMapPing` model, 1974): `None` while hidden, or hidden through a parent; the
/// effective alpha while effectively visible.
#[test]
fn frame_effective_alpha_reads_the_shown_frames_alpha() {
let s = UiScript::new().unwrap();
s.run(
r#"p = CreateFrame("Frame", "PingParent")
f = CreateFrame("Model", "PingModel", p) f:SetAlpha(0.5) f:Hide()"#,
)
.unwrap();
assert_eq!(s.frame_effective_alpha("PingModel"), None, "hidden");
assert_eq!(s.frame_effective_alpha("NoSuchFrame"), None);
s.run("f:Show()").unwrap();
let a = s.frame_effective_alpha("PingModel").expect("shown");
assert!((a - 0.5).abs() < 1e-6, "the frame's alpha: {a}");
s.run("p:Hide()").unwrap();
assert_eq!(
s.frame_effective_alpha("PingModel"),
None,
"hidden through the parent"
);
}
@@ -163,6 +163,7 @@ fn worldmap_current_zone_and_player_feed() {
1.5,
None,
vec![Some((0.1, 0.2)), None],
Vec::new(),
);
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(
@@ -192,7 +193,7 @@ fn worldmap_current_zone_and_player_feed() {
}
// No feed → the world sheet (never an error).
s.set_world_map_feed(None, None, 0.0, None, Vec::new());
s.set_world_map_feed(None, None, 0.0, None, Vec::new(), Vec::new());
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
}
+160
View File
@@ -0,0 +1,160 @@
//! The tutorial system's four Lua verbs (decision 1976; wow-re
//! `system/ui/scratch/tutorial-flags.md` §6): the registrar table at `.data 0x846b04` —
//! `TutorialsEnabled`, `FlagTutorial`, `ClearTutorials`, `ResetTutorials`.
//!
//! The banks are the app's (they are wire state, and the fire-once trigger law lives beside the
//! sites that trigger): the app pushes the **acknowledged** bank's bytes here when they change,
//! which is all `TutorialsEnabled` reads, and drains the three writes.
//!
//! | verb | shape |
//! |---|---|
//! | `TutorialsEnabled()` | one value on both legs: the number `1` if ANY byte of the acknowledged bank is not `0xFF`, else `nil` — over the whole bank, not the 50 real ids; `nil` with no bank |
//! | `FlagTutorial(n)` | `n` must pass `lua_isnumber` else `Usage: FlagTutorial("tutorial")` (the quoted usage is the reference's own, and misleading — a number is required); truncated, `n − 1` must lie in `0..50` else a SILENT no-op; zero values |
//! | `ClearTutorials()` | zero values; every bit set in both banks and `CMSG_TUTORIAL_CLEAR` |
//! | `ResetTutorials()` | zero values; every bit cleared in both banks and `CMSG_TUTORIAL_RESET` |
use mlua::{Lua, Value};
use super::binding_abi::{number_arg, predicate};
use super::Model;
/// `FlagTutorial`'s clamp: `0 ≤ n − 1 < 0x32` — the fifty ids `GlobalStrings.lua` names.
const TUTORIAL_IDS: i32 = 0x32;
impl super::UiScript {
/// The acknowledged bank's bytes (`None` before `SMSG_TUTORIAL_FLAGS` lands) — what
/// `TutorialsEnabled()` scans.
pub fn set_tutorial_bank(&mut self, bank: Option<Vec<u8>>) {
self.model_mut().tutorial_bank = bank;
}
/// `FlagTutorial(n)` calls since the last drain: the **0-based** ids (`n − 1`), in range.
pub fn take_tutorial_flag_requests(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().tutorial_flag_requests)
}
/// `ClearTutorials()` calls since the last drain.
pub fn take_tutorial_clears(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().tutorial_clears)
}
/// `ResetTutorials()` calls since the last drain.
pub fn take_tutorial_resets(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().tutorial_resets)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// `TutorialsEnabled()` (`0x4b5960`): scans `[0xb711ec] * 4` bytes of bank B for one ≠ 0xFF;
// `1.0` or nil, one value; an unallocated bank skips the loop and answers nil.
g.set(
"TutorialsEnabled",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let enabled = model
.tutorial_bank
.as_ref()
.is_some_and(|b| b.iter().any(|&byte| byte != 0xFF));
Ok(predicate(enabled))
})?,
)?;
// `FlagTutorial(n)` (`0x4b59b0`): `lua_isnumber` else the Usage raise; `__ftol`; `dec`; `js`
// and `cmp 0x32 / jge` bail silently; else the acknowledge-and-send setter with the 0-based id.
g.set(
"FlagTutorial",
lua.create_function(|lua, n: Value| {
let n = number_arg(lua, n, "Usage: FlagTutorial(\"tutorial\")")?;
let id = n.wrapping_sub(1);
if !(0..TUTORIAL_IDS).contains(&id) {
return Ok(());
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.tutorial_flag_requests.push(id as u32);
Ok(())
})?,
)?;
g.set(
"ClearTutorials",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.tutorial_clears += 1;
Ok(())
})?,
)?;
g.set(
"ResetTutorials",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.tutorial_resets += 1;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// `1` or nil, one value, over the WHOLE bank — and nil with none.
#[test]
fn tutorials_enabled_scans_the_whole_acknowledged_bank() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', TutorialsEnabled())")
.unwrap(),
1
);
assert!(
s.eval::<bool>("return TutorialsEnabled() == nil").unwrap(),
"no bank"
);
s.set_tutorial_bank(Some(vec![0xFF; 32]));
assert!(
s.eval::<bool>("return TutorialsEnabled() == nil").unwrap(),
"every bit acknowledged: disabled"
);
let mut bank = vec![0xFF; 32];
bank[31] = 0x7F; // a bit past the fifty real ids
s.set_tutorial_bank(Some(bank));
assert_eq!(
s.eval::<i64>("return TutorialsEnabled()").unwrap(),
1,
"the scan covers the whole bank, not the 50 ids"
);
s.set_tutorial_bank(Some(vec![0; 32]));
assert_eq!(s.eval::<i64>("return TutorialsEnabled()").unwrap(), 1);
}
/// `FlagTutorial`: the number gate's raise, `n − 1`, the silent out-of-range legs, truncation.
#[test]
fn flag_tutorial_takes_a_one_based_number_and_bails_silently_off_the_fifty() {
let mut s = UiScript::new().unwrap();
s.run("FlagTutorial(1) FlagTutorial(50) FlagTutorial('42') FlagTutorial(7.9) FlagTutorial(0) FlagTutorial(51) FlagTutorial(-3)")
.unwrap();
assert_eq!(
s.take_tutorial_flag_requests(),
vec![0, 49, 41, 6],
"0-based, truncated toward zero, 0/51/−3 dropped"
);
assert!(s.take_tutorial_flag_requests().is_empty(), "drained");
for call in ["FlagTutorial()", "FlagTutorial(nil)", "FlagTutorial({})"] {
let err = s.run(call).unwrap_err().to_string();
assert!(
err.contains("Usage: FlagTutorial(\"tutorial\")"),
"{call}: {err}"
);
}
s.run("ClearTutorials() ClearTutorials() ResetTutorials()")
.unwrap();
assert_eq!(s.take_tutorial_clears(), 2);
assert_eq!(s.take_tutorial_resets(), 1);
assert_eq!(s.take_tutorial_clears(), 0);
}
}
+9
View File
@@ -115,6 +115,15 @@ pub enum QuadContent {
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
name: Option<String>,
/// The `SetModel` path the pane holds (`None` for a unit pane or an empty one). Decision
/// 1980: the world-map arrow is an anonymous `Model` child whose file is the minimap arrow
/// (`crate::script::ARROW_MODEL`) — the one path the app draws as a sprite, so the seam
/// carries it with the facing and the pane's `SetModelScale`.
model: Option<String>,
/// `SetFacing`'s radians (0 default).
facing: f32,
/// `SetModelScale`'s factor (1 default).
model_scale: f32,
},
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
Texture {
+21 -9
View File
@@ -211,6 +211,11 @@ pub struct WorldMapState {
/// `GetPlayerMapPosition("player")` for the CURRENT selection — projected app-side each frame
/// (`map_proj`), `None`/(0,0) = off this map (the reference hides the blip).
pub player_uv: Option<(f32, f32)>,
/// The world map's arrow frame and the battlefield minimap's — the two per-session
/// singletons `CreateWorldMapArrowFrame`/`CreateMiniWorldMapArrowFrame` build, by wrapper id
/// (`worldmap_arrow`, 1980). `None` until the first create.
pub arrow_world: Option<u32>,
pub arrow_mini: Option<u32>,
/// `GetPlayerMapPosition("party1".."party4")` for the CURRENT selection, in `party_slots`
/// order — projected app-side through the same law as [`Self::player_uv`] (report B320).
/// A slot is `None` when there is no member there, when we hold no position for them, or when
@@ -219,6 +224,8 @@ pub struct WorldMapState {
///
/// Shorter than 4 whenever the party is: a missing index is a `None`.
pub party_uv: Vec<Option<(f32, f32)>>,
/// `raid1..raid40` in roster order — the stock map's raid arm (1980), fed like `party_uv`.
pub raid_uv: Vec<Option<(f32, f32)>>,
/// `GetPlayerFacing()` — wow orientation radians (0 = north, counterclockwise-positive). Our
/// transcribed frame spins its arrow texture with it (`SetRotation`); the reference rotates
/// an engine-rendered arrow model instead (0203 flags the stand-in).
@@ -317,6 +324,7 @@ impl super::UiScript {
player_facing: f32,
corpse_uv: Option<(f32, f32)>,
party_uv: Vec<Option<(f32, f32)>>,
raid_uv: Vec<Option<(f32, f32)>>,
) {
let mut model = self.model_mut();
model.worldmap.player_zone = player_zone;
@@ -324,6 +332,7 @@ impl super::UiScript {
model.worldmap.player_facing = player_facing;
model.worldmap.corpse_uv = corpse_uv;
model.worldmap.party_uv = party_uv;
model.worldmap.raid_uv = raid_uv;
}
/// The engine-owned selection `(continent, zone)` — the app reads it each frame to project
@@ -491,22 +500,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetPlayerMapPosition(unit) → x, y in [0,1] map UV; (0,0) = not on this map (hide the
// blip). `"player"` and `"party1".."party4"` resolve (report B320); `"raid1".."raid40"` do
// not yet — the reference's raid arm draws a 25-frame pool this map has no children for, and
// its party arm is the one a five-man actually takes.
// blip). `"player"`, `"party1".."party4"` (report B320) and `"raid1".."raid40"` (1980, the
// stock map's raid arm over its `WorldMapRaid1..40` pool) resolve; anything else is (0,0).
g.set(
"GetPlayerMapPosition",
lua.create_function(|lua, unit: String| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (x, y) = match unit.as_str() {
"player" => model.worldmap.player_uv.unwrap_or((0.0, 0.0)),
u => u
.strip_prefix("party")
let slot = |prefix: &str, list: &[Option<(f32, f32)>]| {
unit.strip_prefix(prefix)
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| *n >= 1)
.and_then(|n| model.worldmap.party_uv.get(n - 1).copied().flatten())
.unwrap_or((0.0, 0.0)),
.and_then(|n| list.get(n - 1).copied().flatten())
};
let (x, y) = match unit.as_str() {
"player" => model.worldmap.player_uv,
u if u.starts_with("party") => slot("party", &model.worldmap.party_uv),
u if u.starts_with("raid") => slot("raid", &model.worldmap.raid_uv),
_ => None,
}
.unwrap_or((0.0, 0.0));
Ok((f64::from(x), f64::from(y)))
})?,
)?;
@@ -0,0 +1,481 @@
//! The world map's **arrow frames** (decision 1980; wow-re
//! `system/ui/scratch/worldmap-arrow-and-positions.md` §2): the seven bindings the stock
//! `WorldMapFrame.lua` and `Blizzard_BattlefieldMinimap.lua` call to put the player's arrow on
//! the map.
//!
//! The reference keeps **two singletons per UI session** — the world map's and the battlefield
//! minimap's — each a stock `Model` widget (the same class `CreateFrame("Model")` makes) born as a
//! child of the frame the first `Create…` was given, loaded with `Interface\Minimap\MinimapArrow.mdx`,
//! shown, and never freed: every later `Create…` is a no-op even with a different parent. Its
//! rect is the model's bounding box (no authored size), its model is re-centred on that rect by
//! every `Update…`, and its facing is the camera-tracked object's — the player's. `Position…` is
//! literally `arrow:SetPoint(...)` with the generic binder's three error strings and two silent
//! legs; `Show…` is `arrow:Show()`/`Hide()` under the never-raising boolean coercion.
//!
//! Here the singleton is an anonymous `Model` frame this module creates through the same factory
//! `CreateFrame` uses, sized to the arrow's byte-measured footprint, and the app draws it: a
//! `Model` frame whose file is the minimap arrow extracts as [`QuadContent::ModelPane`] with the
//! path and the facing, and the app's model-pane arm paints the arrow sprite there
//! (`ui_script/extract`).
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
use super::binding_abi::bool_or_default;
use super::object::{create_frame, decode_id, frame_handle_of, frame_wrapper, point_from_str};
use super::Model;
/// The arrow's model — `0x8453c0`, and NOT `Rotating-MinimapArrow`.
pub const ARROW_MODEL: &str = "Interface\\Minimap\\MinimapArrow.mdx";
/// The arrow's footprint at the reference basis: `MinimapArrow.mdx`'s single quad at 1280 px per
/// model unit — 33.6 px on the 1024×768 sheet, the same number the minimap's player arrow is
/// drawn at (`minimap::blips::PLAYER_ARROW_QUAD_PX`). The world-map arrow's model scale is
/// `G48 · 5/3` (`= 1.0` at 4:3, `G48 = 1/√(aspect² + 1)`) and the mini's `G48 · 10/9`, so both
/// hold a constant apparent size as the window's shape changes.
pub const ARROW_FOOTPRINT_PX: f32 = 33.6;
/// Which of the two singletons a binding addresses.
#[derive(Clone, Copy)]
enum Arrow {
World,
Mini,
}
impl Arrow {
fn slot(self, model: &Model) -> Option<u32> {
match self {
Arrow::World => model.worldmap.arrow_world,
Arrow::Mini => model.worldmap.arrow_mini,
}
}
fn set_slot(self, model: &mut Model, id: u32) {
match self {
Arrow::World => model.worldmap.arrow_world = Some(id),
Arrow::Mini => model.worldmap.arrow_mini = Some(id),
}
}
/// The model-scale numerator: `5/3` for the world map's arrow, `10/9` for the mini's.
fn scale_over_g48(self) -> f32 {
match self {
Arrow::World => 5.0 / 3.0,
Arrow::Mini => 10.0 / 9.0,
}
}
fn usage_create(self) -> &'static str {
match self {
Arrow::World => "Usage: CreateWorldMapArrowFrame(parent)",
Arrow::Mini => "Usage: CreateMiniWorldMapArrowFrame(parent)",
}
}
fn no_this(self) -> &'static str {
match self {
Arrow::World => "CreateWorldMapArrowFrame(): Couldn't find 'this' in parent object",
Arrow::Mini => "CreateMiniWorldMapArrowFrame(): Couldn't find 'this' in parent object",
}
}
fn not_a_frame(self) -> &'static str {
match self {
Arrow::World => "CreateWorldMapArrowFrame(): Wrong object type, expected frame",
Arrow::Mini => "CreateMiniWorldMapArrowFrame(): Wrong object type, expected frame",
}
}
fn usage_position(self) -> &'static str {
match self {
Arrow::World => {
"Usage: PositionWorldMapArrowFrame(\"point\" \"frame\" [, relativePoint] [, offsetX, offsetY])"
}
Arrow::Mini => {
"Usage: PositionMiniWorldMapArrowFrame(\"point\" \"frame\" [, relativePoint] [, offsetX, offsetY])"
}
}
}
}
/// `[0x832a48]` — the Y half-extent of the reference's layout space, `1/√(A² + 1)` for the
/// screen's aspect `A`; `0.6` at 4:3.
fn g48(model: &Model) -> f32 {
let (w, h) = (model.screen.width(), model.screen.height());
let a = if h > 0.0 { w / h } else { 4.0 / 3.0 };
1.0 / (a * a + 1.0).sqrt()
}
/// The arrow's model scale for this screen, and the footprint that scale gives it.
fn arrow_scale(model: &Model, which: Arrow) -> f32 {
g48(model) * which.scale_over_g48()
}
/// `lua_isstring`: a string, or a number (which `lua_tostring` would print).
fn is_string(v: &Value) -> bool {
matches!(v, Value::String(_) | Value::Integer(_) | Value::Number(_))
}
fn as_string(v: &Value) -> mlua::Result<String> {
match v {
Value::String(s) => Ok(s.to_str()?.to_string()),
Value::Integer(i) => Ok(i.to_string()),
Value::Number(n) => Ok(super::binding_abi::lua_number_text(*n)),
_ => Ok(String::new()),
}
}
/// `lua_isnumber`: a number, or a string that parses as one.
fn as_number(lua: &Lua, v: &Value) -> Option<f64> {
lua.coerce_number(v.clone()).ok().flatten()
}
fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
let Value::Table(t) = parent else {
return Err(mlua::Error::runtime(which.usage_create()));
};
if decode_id(&t).is_err() {
return Err(mlua::Error::runtime(which.no_this()));
}
if frame_handle_of(lua, &t).is_err() {
return Err(mlua::Error::runtime(which.not_a_frame()));
}
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if which.slot(&model).is_some() {
return Ok(()); // the singleton exists: the parent is not even read (§2.1)
}
}
let wrapper = create_frame(
lua,
("Model".to_string(), None, Some(Value::Table(t)), None),
)?;
let id = decode_id(&wrapper)?;
let (scale, side) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let scale = arrow_scale(&model, which);
(scale, ARROW_FOOTPRINT_PX * scale)
};
// The widget's rect is the resident model's bounding box (§2.2); the footprint at this scale.
wrapper.call_method::<()>("SetWidth", side)?;
wrapper.call_method::<()>("SetHeight", side)?;
super::modelframe::with_model(lua, &wrapper, |m| {
m.path = Some(ARROW_MODEL.to_string());
m.scale = scale;
m.position = (side * 0.5, side * 0.5, 0.0);
})?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
which.set_slot(&mut model, id);
Ok(())
}
/// The singleton's wrapper, or `None` while it does not exist.
fn arrow_wrapper(lua: &Lua, which: Arrow) -> mlua::Result<Option<Table>> {
let id = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
which.slot(&model)
};
id.map(|id| frame_wrapper(lua, id)).transpose()
}
/// `UpdateWorldMapArrowFrames()` (§2.3): for each existing arrow, re-centre the model on its
/// rect and copy the camera-tracked object's facing — the player's, which the app pushes with
/// the world-map feed — into its rotation about +Z. No player → neither facing moves.
fn update(lua: &Lua) -> mlua::Result<()> {
for which in [Arrow::World, Arrow::Mini] {
let Some(wrapper) = arrow_wrapper(lua, which)? else {
continue;
};
let (facing, tracked) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
(
model.worldmap.player_facing,
model.worldmap.player_uv.is_some(),
)
};
let w: f32 = wrapper.call_method("GetWidth", ())?;
let h: f32 = wrapper.call_method("GetHeight", ())?;
super::modelframe::with_model(lua, &wrapper, |m| {
m.position = (w * 0.5, h * 0.5, 0.0);
if tracked {
m.facing = facing;
}
})?;
}
Ok(())
}
/// `Position…("point", "frame" [, relativePoint] [, offsetX, offsetY])` (§2.4): silent with no
/// arrow; args 1 and 2 must be strings (a number is one) else the usage raise; the point through
/// the same table `SetPoint` reads, else `Unknown frame point`; the frame by name — `$parent`
/// expanded against the arrow's parent, then `_G` — else `Couldn't find frame named '%s'`, and
/// the arrow itself `Error: %s is anchored to itself`; a non-string third argument means
/// `relativePoint = point` and zero offsets with no error; offsets only when both are numbers.
fn position(lua: &Lua, which: Arrow, args: [Value; 5]) -> mlua::Result<()> {
let Some(arrow) = arrow_wrapper(lua, which)? else {
return Ok(());
};
let [point, frame, rel, x, y] = args;
if !is_string(&point) || !is_string(&frame) {
return Err(mlua::Error::runtime(which.usage_position()));
}
let point_name = as_string(&point)?;
if point_from_str(&point_name).is_none() {
return Err(mlua::Error::runtime("Unknown frame point"));
}
let frame_name = as_string(&frame)?;
let resolved = if let Some(rest) = frame_name.strip_prefix("$parent") {
// The first NAMED ancestor of the arrow — its parent frame, or that frame's own parent.
let parent_name: Option<String> = arrow
.call_method::<Option<Table>>("GetParent", ())?
.and_then(|p| {
p.call_method::<Option<String>>("GetName", ())
.ok()
.flatten()
});
parent_name.map(|p| format!("{p}{rest}"))
} else {
Some(frame_name.clone())
};
let target: Option<Table> = resolved
.as_deref()
.and_then(|n| lua.globals().get::<Option<Table>>(n).ok().flatten())
.filter(|t| frame_handle_of(lua, t).is_ok());
let Some(target) = target else {
return Err(mlua::Error::runtime(format!(
"Couldn't find frame named '{}'",
resolved.unwrap_or(frame_name)
)));
};
if decode_id(&target)? == decode_id(&arrow)? {
return Err(mlua::Error::runtime(format!(
"Error: {} is anchored to itself",
resolved.unwrap_or(frame_name)
)));
}
let (rel_point, ox, oy) = if is_string(&rel) {
let r = as_string(&rel)?;
if point_from_str(&r).is_none() {
return Err(mlua::Error::runtime("Unknown frame point"));
}
match (as_number(lua, &x), as_number(lua, &y)) {
(Some(x), Some(y)) => (r, x, y),
_ => (r, 0.0, 0.0),
}
} else {
(point_name.clone(), 0.0, 0.0)
};
arrow.call_method::<()>(
"SetPoint",
(point_name, Value::Table(target), rel_point, ox, oy),
)
}
/// `Show…([shown])` (§2.5): silent with no arrow; the never-raising boolean coercion with a
/// default of true (absent → show, `nil` → hide).
fn show(lua: &Lua, which: Arrow, args: MultiValue) -> mlua::Result<()> {
let Some(arrow) = arrow_wrapper(lua, which)? else {
return Ok(());
};
// An ABSENT argument is not nil at the C API (`LUA_TNONE`, which the binding's nil test does
// not match), so the bare call shows; an explicit nil hides; anything else is the boolean
// coercion with its true default.
let on = match args.front() {
None => true,
Some(Value::Nil) => false,
Some(v) => bool_or_default(Some(v), true),
};
arrow.call_method::<()>(if on { "Show" } else { "Hide" }, ())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
for (which, create_name, position_name, show_name) in [
(
Arrow::World,
"CreateWorldMapArrowFrame",
"PositionWorldMapArrowFrame",
"ShowWorldMapArrowFrame",
),
(
Arrow::Mini,
"CreateMiniWorldMapArrowFrame",
"PositionMiniWorldMapArrowFrame",
"ShowMiniWorldMapArrowFrame",
),
] {
g.set(
create_name,
lua.create_function(move |lua, parent: Value| create(lua, which, parent))?,
)?;
g.set(
position_name,
lua.create_function(
move |lua, (p, f, r, x, y): (Value, Value, Value, Value, Value)| {
position(lua, which, [p, f, r, x, y])
},
)?,
)?;
g.set(
show_name,
lua.create_function(move |lua, args: MultiValue| show(lua, which, args))?,
)?;
}
g.set(
"UpdateWorldMapArrowFrames",
lua.create_function(|lua, ()| update(lua))?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"WM = CreateFrame("Frame", "WM") WM:SetSize(1024, 768) WM:SetPoint("CENTER")
Detail = CreateFrame("Frame", "WorldMapDetailFrame", WM)
Detail:SetSize(1002, 668) Detail:SetPoint("CENTER")"#,
)
.unwrap();
s
}
/// The three raise strings, then the singleton: an anonymous Model child of the parent,
/// loaded with the arrow, born shown, sized to its footprint; a second create a no-op.
#[test]
fn create_validates_its_parent_and_makes_one_arrow_per_session() {
let s = vm();
for (call, want) in [
(
"CreateWorldMapArrowFrame(5)",
"Usage: CreateWorldMapArrowFrame(parent)",
),
(
"CreateWorldMapArrowFrame({})",
"CreateWorldMapArrowFrame(): Couldn't find 'this' in parent object",
),
(
"CreateWorldMapArrowFrame(WM:CreateTexture())",
"CreateWorldMapArrowFrame(): Wrong object type, expected frame",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(want), "{call}: {err}");
}
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
let before = s
.eval::<i64>("return table.getn({WM:GetChildren()})")
.unwrap();
s.run("CreateWorldMapArrowFrame(Detail)").unwrap();
let after = s
.eval::<i64>("return table.getn({WM:GetChildren()})")
.unwrap();
assert_eq!(
before, after,
"a second create is a no-op, whatever the parent"
);
let (kind, shown, w) = s
.eval::<(String, bool, f32)>(
"local a = ({WM:GetChildren()})[2] \
return a:GetObjectType(), a:IsShown(), a:GetWidth()",
)
.unwrap();
assert_eq!(kind, "Model");
assert!(shown, "born shown");
assert!(
(w - ARROW_FOOTPRINT_PX).abs() < 1e-3,
"the footprint at 4:3: {w}"
);
assert!(
s.eval::<bool>("return select('#', CreateWorldMapArrowFrame(WM)) == 0")
.unwrap(),
"zero values"
);
}
/// `Position…` anchors the arrow like SetPoint, with the reference's three strings and its
/// two silent legs; `Show…` follows the boolean coercion; `Update…` copies the facing.
#[test]
fn position_show_and_update_drive_the_arrow() {
let mut s = vm();
// No arrow yet: every one of the three is silent, whatever the arguments.
s.run("PositionWorldMapArrowFrame(1, 2) ShowWorldMapArrowFrame('x') UpdateWorldMapArrowFrames()")
.unwrap();
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
s.run("arrow = ({WM:GetChildren()})[2]").unwrap();
for (call, want) in [
(
"PositionWorldMapArrowFrame(nil, 'WorldMapDetailFrame')",
"Usage: PositionWorldMapArrowFrame",
),
(
"PositionWorldMapArrowFrame('NOWHERE', 'WorldMapDetailFrame')",
"Unknown frame point",
),
(
"PositionWorldMapArrowFrame('CENTER', 'NoSuchFrame')",
"Couldn't find frame named 'NoSuchFrame'",
),
(
"PositionWorldMapArrowFrame('CENTER', 'WorldMapDetailFrame', 'BOGUS', 1, 2)",
"Unknown frame point",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(want), "{call}: {err}");
}
s.run("PositionWorldMapArrowFrame('CENTER', 'WorldMapDetailFrame', 'TOPLEFT', 100, -50)")
.unwrap();
s.resolve();
let (cx, cy, dl, dt) = s
.eval::<(f32, f32, f32, f32)>(
"local cx, cy = arrow:GetCenter() \
return cx, cy, WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()",
)
.unwrap();
assert!(
(cx - (dl + 100.0)).abs() < 0.01 && (cy - (dt - 50.0)).abs() < 0.01,
"{cx} {cy}"
);
// A non-string third argument: relativePoint = point, offsets ignored, no error.
s.run("PositionWorldMapArrowFrame('TOPLEFT', 'WorldMapDetailFrame', nil, 100, -50)")
.unwrap();
s.resolve();
let (l, t, dl, dt) = s
.eval::<(f32, f32, f32, f32)>(
"return arrow:GetLeft(), arrow:GetTop(), WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()",
)
.unwrap();
assert!((l - dl).abs() < 0.01 && (t - dt).abs() < 0.01, "{l} {t}");
// The name resolver expands $parent against the arrow's parent.
s.run("PositionWorldMapArrowFrame('CENTER', '$parent')")
.unwrap();
s.run("ShowWorldMapArrowFrame(nil)").unwrap();
assert!(
!s.eval::<bool>("return arrow:IsShown()").unwrap(),
"nil hides"
);
s.run("ShowWorldMapArrowFrame()").unwrap();
assert!(
s.eval::<bool>("return arrow:IsShown()").unwrap(),
"absent shows"
);
s.run("ShowWorldMapArrowFrame('off')").unwrap();
assert!(!s.eval::<bool>("return arrow:IsShown()").unwrap());
s.run("ShowWorldMapArrowFrame(1)").unwrap();
assert!(s.eval::<bool>("return arrow:IsShown()").unwrap());
s.set_world_map_feed(None, Some((0.5, 0.5)), 1.25, None, Vec::new(), Vec::new());
s.run("UpdateWorldMapArrowFrames()").unwrap();
let facing = s.eval::<f32>("return arrow:GetFacing()").unwrap();
assert!(
(facing - 1.25).abs() < 1e-6,
"the tracked object's facing: {facing}"
);
s.set_world_map_feed(None, None, 2.0, None, Vec::new(), Vec::new());
s.run("UpdateWorldMapArrowFrames()").unwrap();
assert!(
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 1.25).abs() < 1e-6,
"no tracked object: the facing does not move"
);
}
}