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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user