- a comment that justifies code by an engine difference is a claim with an expiry date, and the tree had ~96 expired ones across `assets/ui`, `ui_script/` and `benilla-ui` — a hygiene pass, no behaviour change: dangling `Benilla*` names with zero definitions (`BenillaMoney_*` at thirteen sites after 1937/1962 converged the coin cluster onto the stock `SmallMoneyFrameTemplate`; `BenillaGossipRow_Resize`, `BenillaErrorsFrame_AddMessage`, the digit feed 1285 deleted, `BenillaMerchantItem_OnClick` and three more), "the shipped `assets/ui/<X>.xml`" for thirty-odd files the directory no longer holds — it holds nine — each site now naming the chain file that actually loads, `UiPanels.xml` in the present tense after 1988 took the panel manager and the dialog engine stock, and expired self-deprecation: SimpleHTML "a widget kind we do not have" (1337 built it), "benilla has no console" (1948 bound `ConsoleExec`), `Model`'s "seven verbs not published yet" and "`ClearFog` is a guess" (all 23 are published, the carve sits 500 lines below, `UNBUILT` has been `[&str; 0]` since 2027), `frame_flag_gate`'s doc describing four groups of accepted mouse divergences over a `KNOWN` list that is empty (the empty array and its enforcement stay), five comments saying key delivery is not gated on `keyboard_enabled` where `script::keyboard`'s walk filters on exactly that field (1319), `SetRaidTarget` called a verb this house lacks beside an assert that its intent IS queued (1820), two questlog tests deferring to a screenshot for `GetPoint`/`IsVisible` the regions already answer, `benilla.toc`'s "trade still needs four engine verbs" (all four bound, 1966) and its ping `Model`s "this engine draws no pixels for" (2013 renders `MinimapPing.mdx` itself), and `rig_anim`'s `set_paused` naming a booth caller it does not have; and the code the prose was propping up goes with it: `GameMenuFrame.xml`'s six `<OnLoad>` label setters, because `text=` on a `<Button>` is a global-string lookup at the loader's attribute step (0995, built 61 seconds before the file was written) — the buttons carry `text="LOGOUT"`/`"EXIT_GAME"`/`"MACROS"`/`"RETURN_TO_GAME"`/`"MAIN_MENU"` as the reference's own do; `ScriptLogFrame`'s five authored close-button lines, now `inherits="UIPanelCloseButton"` off the chain's `UIPanelTemplates.xml` seated 757 lines above it (1846); and the editbox's 22-line "gap" doc claiming only the self-acquire `autoFocus` path is built and its `false` default unverified — path 1 is 1686's `visibility_focus`, and the default is `true`, byte-read off `flags = 1` at `0x779a29`/`0x779a2e`, so the test named `autofocus_does_not_focus_on_show…` is renamed for what it actually pins (2044) - the `text=` divergence never existed: `resolve_text`'s doc had claimed since 0068 that falling back to the raw attribute on a GlobalStrings miss was "one deliberate divergence" from a reference that draws the empty string, and read at the byte it is the reference's own behaviour — `FrameScript_GetText 0x703bf0` does return a pre-seeded empty string (`0x882748`) when the global is not a string, but that return never reaches a label, because all three `text=` readers image-wide test it and substitute the raw attribute: `Button::LoadXML` at `0x778c07` (`0x778c31 mov eax,esi`), `CSimpleFontString::LoadXML` at `0x771006` byte-identically, and `0x7292a6`, which pushes `edi` on the empty arm; so the loader changes nothing, the two test comments that had called `PetLevelText`'s "Level level race class" placeholder and `CLOSE` our divergence now name the reference's arm (a real 1.12 client draws them too), and what IS ours is the extra warning on a key-shaped miss (0983 → 0991), a strict addition; beside it, `GetEffectiveScale`'s root factor is 1 not because "benilla has no `uiScale` CVar" — 1804 registered one, default 0.9 — but because the dial is applied at the raster seam, `768/uiScale` UI units of virtual-screen height rather than a scale on `UIParent`, so every coordinate the VM hands Lua is already in those units and the reference's `GetCursorPosition()/GetEffectiveScale()` transcriptions convert correctly with a root of 1 (2044) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
612 lines
27 KiB
Rust
612 lines
27 KiB
Rust
//! **The model-pane family's Lua surface** — `Model` and `PlayerModel`, read back through the API
|
||
//! that wrote them.
|
||
//!
|
||
//! The property under test throughout is that these widgets are *state the app renders*, not state
|
||
//! the engine interprets: every setter's value must come back out unchanged, and the places where
|
||
//! the widget does have an opinion (one yaw slot written by two verbs on two classes; content being
|
||
//! an either/or) must hold.
|
||
//!
|
||
//! Plus the one structural property, guarded as a whole block:
|
||
//! [`the_two_model_tables_are_the_references_own`] asserts our surfaces against the reference's
|
||
//! enumerated tables in **both** directions, so neither a missing verb nor an invented one can slip
|
||
//! past — and so `Model` can never re-acquire the three that are `PlayerModel`'s.
|
||
|
||
use super::common::script;
|
||
use crate::script::UiScript;
|
||
|
||
/// The whole scene, set and read back — plus the widget's three actual behaviours: `SetRotation`
|
||
/// and `SetFacing` are one slot, `SetModel` and `SetUnit` displace each other, and `ClearModel`
|
||
/// empties both.
|
||
#[test]
|
||
fn the_model_pane_holds_the_scene_it_was_given() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"m = CreateFrame("Model", "MPane", UIParent)"#)
|
||
.unwrap();
|
||
|
||
// A fresh pane: no content, unit SCALE (not zero — a model at scale 0 is invisible, which is
|
||
// why ModelState hand-writes its Default), no yaw, at the origin.
|
||
assert_eq!(
|
||
s.eval::<(Option<String>, f64, f64)>(
|
||
"return MPane:GetModel(), MPane:GetModelScale(), MPane:GetFacing()"
|
||
)
|
||
.unwrap(),
|
||
(None, 1.0, 0.0),
|
||
"a fresh pane has no model and unit scale"
|
||
);
|
||
|
||
// The path round-trips verbatim — the client's own path space, backslashes and `.mdx` intact.
|
||
// pfUI's autocast shine is exactly this call.
|
||
s.run(r#"MPane:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return MPane:GetModel()").unwrap(),
|
||
r"Interface\Buttons\UI-AutoCastButton.mdx"
|
||
);
|
||
|
||
// The yaw. `SetFacing` is the `Model` verb for it (`0x878948[4]`); `SetRotation` writes the
|
||
// same field but belongs to `PlayerModel` and is tested there.
|
||
s.run("MPane:SetFacing(-0.25)").unwrap();
|
||
assert_eq!(s.eval::<f64>("return MPane:GetFacing()").unwrap(), -0.25);
|
||
|
||
// Scale, camera, position — plain storage, read back through their own getters.
|
||
s.run("MPane:SetModelScale(0.4) MPane:SetCamera(2) MPane:SetPosition(0.1, -0.2, 3)")
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<f64>("return MPane:GetModelScale()").unwrap(),
|
||
0.4_f32 as f64
|
||
);
|
||
let (x, y, z): (f64, f64, f64) = s.eval("return MPane:GetPosition()").unwrap();
|
||
assert_eq!(
|
||
(x as f32, y as f32, z as f32),
|
||
(0.1, -0.2, 3.0),
|
||
"GetPosition returns the three numbers SetPosition took"
|
||
);
|
||
|
||
// `SetModel(nil)` is the documented clear and reaches the same place `ClearModel` does — not
|
||
// everything in the corpus calls the dedicated verb.
|
||
s.run("MPane:ClearModel()").unwrap();
|
||
assert_eq!(
|
||
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// **A `<PlayerModel>` is a `<Model>` plus exactly three verbs — and the inheritance runs one way.**
|
||
///
|
||
/// The client registers four model-pane types and each has its own Lua method table that never
|
||
/// repeats its base's; a derived pane reaches its base through the miss leg of `vtable+0x8`
|
||
/// (wow-re `ui/scratch/model-pane-method-tables.md` §3). So `PlayerModel`'s three-entry table
|
||
/// `0x84f1fc` must sit *over* `Model`'s 23-entry `0x878948`, and nothing may chain the other way:
|
||
/// `CSimpleModel`'s lookup `0x76f870` has no leg into `CGCharacterModelBase`'s `0x506260`.
|
||
///
|
||
/// This is the shape pfUI's unit frames need — `CreateFrame("PlayerModel", ...)` driven by
|
||
/// `SetUnit` + `SetCamera`, one verb from each table on the same frame.
|
||
#[test]
|
||
fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
pm = CreateFrame("PlayerModel", "PMPane", UIParent)
|
||
m = CreateFrame("Model", "MOnly", UIParent)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
// Its own three resolve on the PlayerModel...
|
||
for verb in ["SetUnit", "RefreshUnit", "SetRotation"] {
|
||
assert_eq!(
|
||
s.eval::<String>(&format!("return type(PMPane.{verb})"))
|
||
.unwrap(),
|
||
"function",
|
||
"PlayerModel must answer its own {verb}"
|
||
);
|
||
// ...and on the plain Model they are ABSENT. This is the assertion that would have caught
|
||
// three years of `SetUnit` published on the wrong class.
|
||
assert_eq!(
|
||
s.eval::<String>(&format!("return type(MOnly.{verb})"))
|
||
.unwrap(),
|
||
"nil",
|
||
"a plain Model must NOT answer {verb} — the chain runs derived -> base only"
|
||
);
|
||
}
|
||
|
||
// ...and the base's verbs resolve through the chain, unrepeated. pfUI's portrait line.
|
||
s.run(r#"PMPane:SetUnit("player") PMPane:SetCamera(0)"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<Option<String>>("return PMPane:GetModel()")
|
||
.unwrap(),
|
||
None,
|
||
"SetUnit displaces the model path — content is an either/or, not layers"
|
||
);
|
||
s.run(r#"PMPane:SetModel("Interface\\Buttons\\Other.mdx")"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return PMPane:GetModel()").unwrap(),
|
||
r"Interface\Buttons\Other.mdx",
|
||
"...and back the other way, the direction an addon reskinning a paper doll takes"
|
||
);
|
||
|
||
// `SetRotation` writes THE SAME yaw field `SetFacing` does — `0x505bb0`'s last instruction is
|
||
// `mov [esi+0x39c], eax`, and `+0x39c` is what `0x76dce0` writes. `GetFacing` reads either.
|
||
s.run("PMPane:SetRotation(1.5)").unwrap();
|
||
assert_eq!(s.eval::<f64>("return PMPane:GetFacing()").unwrap(), 1.5);
|
||
s.run("PMPane:SetFacing(-0.25)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<f64>("return PMPane:GetFacing()").unwrap(),
|
||
-0.25,
|
||
"one slot: SetFacing overwrites what SetRotation wrote"
|
||
);
|
||
|
||
// RefreshUnit is a live no-op: the pane stores the unit TOKEN and resolves it at render, so
|
||
// there is no cached appearance to invalidate. It must still exist — the reference's own
|
||
// DressUp/PaperDoll frames call it, so an addon hooking them will too.
|
||
assert!(s.run("PMPane:RefreshUnit()").is_ok());
|
||
|
||
// ClearModel — a `Model` verb reached through the chain — empties BOTH content slots.
|
||
s.run(r#"PMPane:SetUnit("player") PMPane:ClearModel()"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<Option<String>>("return PMPane:GetModel()")
|
||
.unwrap(),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// **`SetModel(nil)` raises; `ClearModel()` clears. They are not the same verb.**
|
||
///
|
||
/// This binding asserted the opposite until 2026-08-30 — "the documented clear, and how the corpus
|
||
/// writes 'no model'" — which was invented, not read. `0x76d950` is shape A (decision 1717's
|
||
/// taxonomy): `lua_isstring` gates the argument and `Usage: %s:SetModel("file")` is raised on
|
||
/// anything that is not a string or a number. `ClearModel 0x76db20` is a separate table entry.
|
||
///
|
||
/// The same gate on `ReplaceIconTexture 0x76ed70`, whose *whole* observable behaviour it is: the
|
||
/// swap itself lands on a `CM2Model` we do not have, and with no CM2Model the reference drops the
|
||
/// call and never replays it.
|
||
///
|
||
/// A NUMBER is accepted by both — `lua_isstring` takes tags 3|4 and the client renders it to
|
||
/// decimal text — which is the half a `Value::String`-only match would get wrong.
|
||
#[test]
|
||
fn the_string_setters_gate_their_argument_and_a_number_is_a_string() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"g = CreateFrame("Model", "GateM", UIParent)"#)
|
||
.unwrap();
|
||
|
||
s.run(r#"GateM:SetModel("Interface\\Buttons\\A.mdx")"#)
|
||
.unwrap();
|
||
for bad in ["nil", "{}", "true", "print", ""] {
|
||
assert!(
|
||
s.run(&format!("GateM:SetModel({bad})")).is_err(),
|
||
"SetModel({bad}) must raise — it is not the clear"
|
||
);
|
||
}
|
||
// ...and none of those raises disturbed the path that was already set.
|
||
assert_eq!(
|
||
s.eval::<String>("return GateM:GetModel()").unwrap(),
|
||
r"Interface\Buttons\A.mdx",
|
||
"a raised setter leaves the pane alone"
|
||
);
|
||
// A number IS a string to `lua_isstring`, and the client renders it to decimal text.
|
||
s.run("GateM:SetModel(42)").unwrap();
|
||
assert_eq!(s.eval::<String>("return GateM:GetModel()").unwrap(), "42");
|
||
|
||
// ClearModel is the clear, takes no argument, and empties the pane.
|
||
s.run("GateM:ClearModel()").unwrap();
|
||
assert_eq!(
|
||
s.eval::<Option<String>>("return GateM:GetModel()").unwrap(),
|
||
None
|
||
);
|
||
|
||
// `ReplaceIconTexture` — same gate, and that gate is all of it. A good argument is accepted
|
||
// and changes NOTHING observable: the swap targets the CM2Model's type-14 textures, and a
|
||
// pane with no CM2Model drops the call (the reference's own `[widget+0x318] == 0` leg).
|
||
for bad in ["nil", "{}", "true", ""] {
|
||
assert!(
|
||
s.run(&format!("GateM:ReplaceIconTexture({bad})")).is_err(),
|
||
"ReplaceIconTexture({bad}) must raise"
|
||
);
|
||
}
|
||
s.run(r#"GateM:SetModel("Interface\\Buttons\\A.mdx")"#)
|
||
.unwrap();
|
||
s.run(r#"GateM:ReplaceIconTexture("Interface\\Icons\\INV_Misc_QuestionMark")"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return GateM:GetModel()").unwrap(),
|
||
r"Interface\Buttons\A.mdx",
|
||
"it is a MATERIAL swap, not a content setter — the pane's model is untouched"
|
||
);
|
||
|
||
// `AdvanceTime` takes nothing, returns nothing, and does nothing — verified, not stubbed.
|
||
assert_eq!(
|
||
s.eval::<usize>("return table.getn({ GateM:AdvanceTime() })")
|
||
.unwrap(),
|
||
0,
|
||
"AdvanceTime pushes no return value"
|
||
);
|
||
}
|
||
|
||
/// **The whole block, both directions** — decision 1718's rule applied to the two model tables.
|
||
///
|
||
/// The lists below are the reference's OWN enumerations, transcribed entry-for-entry from
|
||
/// wow-re's `ui/scratch/model-pane-method-tables.md` §2.1 and §2.2 (each table's count fixed twice:
|
||
/// the registering `mov edx, imm32`, and the dword at `base + 8*count` being the start of the
|
||
/// string pool). They are **not** the names anyone noticed were missing — that is exactly the
|
||
/// mistake 1718 records, and it was re-made inside the test written to enforce it.
|
||
///
|
||
/// Guarding both directions is the point:
|
||
///
|
||
/// - **no gap** — every reference name we claim to publish must resolve;
|
||
/// - **no superset** — a name the reference does not have on a table must not resolve there
|
||
/// (1189: a name we have and the reference lacks routes an addon down a path the real client
|
||
/// never takes);
|
||
/// - **the `UNBUILT` set must stay absent** — it is empty since 2027, so "unbuilt" cannot
|
||
/// quietly become "stubbed" without this test being edited to say so.
|
||
#[test]
|
||
fn the_two_model_tables_are_the_references_own() {
|
||
/// `Model` — `CSimpleModel`, table `0x878948`, 23 entries, in table order.
|
||
const MODEL_23: [&str; 23] = [
|
||
"SetModel",
|
||
"GetModel",
|
||
"ClearModel",
|
||
"SetPosition",
|
||
"SetFacing",
|
||
"SetModelScale",
|
||
"SetSequence",
|
||
"SetSequenceTime",
|
||
"SetCamera",
|
||
"SetLight",
|
||
"GetLight",
|
||
"GetPosition",
|
||
"GetFacing",
|
||
"GetModelScale",
|
||
"AdvanceTime",
|
||
"ReplaceIconTexture",
|
||
"SetFogColor",
|
||
"GetFogColor",
|
||
"SetFogNear",
|
||
"GetFogNear",
|
||
"SetFogFar",
|
||
"GetFogFar",
|
||
"ClearFog",
|
||
];
|
||
/// `PlayerModel` — `CGCharacterModelBase`, table `0x84f1fc`, 3 entries, in table order.
|
||
const PLAYERMODEL_3: [&str; 3] = ["SetUnit", "RefreshUnit", "SetRotation"];
|
||
/// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4).
|
||
/// **Empty since decision 2027** — the last five were the fog near/far set, held back only
|
||
/// because `ClearFog`'s effect on the colour/near/far triple was uncarved; the render law
|
||
/// (§5.4) reads it as `76f5c5 and [edi+0x3a4],-2`, bit 0 alone, so the guess is gone and the
|
||
/// verbs are real. Kept as an empty array rather than deleted: the wall below is the thing
|
||
/// that notices when a name arrives or leaves.
|
||
const UNBUILT: [&str; 0] = [];
|
||
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
CreateFrame("Model", "GuardM", UIParent)
|
||
CreateFrame("PlayerModel", "GuardPM", UIParent)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let is_fn = |s: &UiScript, frame: &str, name: &str| {
|
||
s.eval::<String>(&format!("return type({frame}.{name})"))
|
||
.unwrap()
|
||
== "function"
|
||
};
|
||
|
||
for name in MODEL_23 {
|
||
let want = !UNBUILT.contains(&name);
|
||
// A `Model` verb resolves on BOTH panes — on the PlayerModel through the chain.
|
||
for frame in ["GuardM", "GuardPM"] {
|
||
assert_eq!(
|
||
is_fn(&s, frame, name),
|
||
want,
|
||
"{frame}.{name}: table 0x878948 has it; built = {want}"
|
||
);
|
||
}
|
||
}
|
||
for name in PLAYERMODEL_3 {
|
||
assert!(
|
||
is_fn(&s, "GuardPM", name),
|
||
"GuardPM.{name}: table 0x84f1fc entry, all three are built"
|
||
);
|
||
assert!(
|
||
!is_fn(&s, "GuardM", name),
|
||
"GuardM.{name}: 0x84f1fc is NOT reachable from CSimpleModel's lookup"
|
||
);
|
||
}
|
||
// The two names a `strings` scan of the model band would tempt anyone into, which do not exist
|
||
// in 5875 in ANY form (substring scan of the mapped image returns 0, positive control 27 hits
|
||
// for `Creature`). They are later-expansion verbs; publishing one is decision 1189's error.
|
||
for name in ["SetCreature", "SetCustomRace"] {
|
||
for frame in ["GuardM", "GuardPM"] {
|
||
assert!(
|
||
!is_fn(&s, frame, name),
|
||
"{frame}.{name} does not exist in 1.12.1.5875"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **`SetSequence` and `SetSequenceTime` are one arm** (`0x7121a0`): each interrupts what plays
|
||
/// and anchors the new sequence's cursor — at 0, or at the caller's `ms` — on the pane's own
|
||
/// clock. The clock law itself (advance, wrap, completion, the two handlers) is
|
||
/// `script::tests::model_clock`; this is the binding-level shape, with no file facts known, which
|
||
/// is the state the reference's queued replay covers.
|
||
#[test]
|
||
fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
m = CreateFrame("Model", "MSeq", UIParent)
|
||
m:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")
|
||
m:SetSequence(0)
|
||
m:SetSequenceTime(0, 250)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
// 1.12 has no `GetSequence`, so the arm is read off the model the way `simplehtml`'s tests
|
||
// read their blocks — through the arena, because there is no Lua getter to read it through.
|
||
let armed = |s: &UiScript| {
|
||
let lua = s.lua();
|
||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||
let fh = model.arena.lookup("MSeq").expect("MSeq frame");
|
||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||
crate::widget::KindState::Model(m) => {
|
||
(m.sequence, m.armed.map(|a| (a.anim_id, a.anchor_ms)))
|
||
}
|
||
_ => panic!("MSeq is not a Model"),
|
||
}
|
||
};
|
||
// The scrub anchors the cursor 250 ms in: `anchor = clock − ms` at clock 0.
|
||
assert_eq!(armed(&s), (0, Some((0, -250))));
|
||
|
||
s.run("MSeq:SetSequence(3)").unwrap();
|
||
assert_eq!(
|
||
armed(&s),
|
||
(3, Some((3, 0))),
|
||
"a new sequence starts at its own 0 — the old anchor is not carried across"
|
||
);
|
||
|
||
// ...and ClearModel releases the instance, arm included.
|
||
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
|
||
.unwrap();
|
||
assert_eq!(armed(&s).1, None);
|
||
}
|
||
|
||
/// `SetLight` writes the widget's embedded `CGLight` by the reference's own argument walk
|
||
/// (`0x76e1e0`, render law §5.3), and `GetLight` reads it back at arity **7 | 10 | 13**.
|
||
///
|
||
/// This test is the law, not a round trip: the tuple used to be stored verbatim because nobody
|
||
/// had carved the binding, and "what went in comes out" is the one thing the reference does NOT
|
||
/// do — the intensities are folded into the colours, the direction is normalised, and both of
|
||
/// §5.3's traps change what a call means.
|
||
#[test]
|
||
fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"m = CreateFrame("Model", "MLight", UIParent)"#)
|
||
.unwrap();
|
||
|
||
// A fresh pane: the ctor's white, switched off. Both colour blocks are `> 0`, so both come
|
||
// back as `1.0, r, g, b` — arity 13 before anything has ever been set.
|
||
let fresh: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||
assert_eq!(
|
||
fresh,
|
||
vec![0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||
"the <Model> ctor: disabled, omni, at the origin, white ambient and diffuse"
|
||
);
|
||
|
||
// The commented-out GlueXML line, which is the corpus's only example of the shape.
|
||
s.run("MLight:SetLight(1, 0, 0, -0.707, -0.707, 0.7, 1, 1, 1, 0.8, 1, 1, 0.8)")
|
||
.unwrap();
|
||
let l: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||
assert_eq!(l.len(), 13);
|
||
assert_eq!((l[0], l[1]), (1.0, 0.0), "enabled, directional");
|
||
// The direction is NORMALISED on write (`0x71b6a0`), so `(0, -0.707, -0.707)` comes back as
|
||
// a unit vector — the one place a naive round trip would disagree.
|
||
let dir = [l[2], l[3], l[4]];
|
||
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
|
||
assert!((len - 1.0).abs() < 1e-5, "normalised on write: {dir:?}");
|
||
// The intensity is FOLDED into the colour: ambient `1,1,1` at `0.7` is `(0.7, 0.7, 0.7)`,
|
||
// and the reported intensity is a constant 1.0.
|
||
assert_eq!(l[5], 1.0);
|
||
for c in &l[6..9] {
|
||
assert!((c - 0.7).abs() < 1e-5, "ambient folded: {c}");
|
||
}
|
||
assert_eq!(l[9], 1.0);
|
||
for (c, want) in l[10..13].iter().zip([0.8, 0.8, 0.64]) {
|
||
assert!((c - want).abs() < 1e-5, "diffuse folded: {c} vs {want}");
|
||
}
|
||
|
||
// TRAP 1: `SetLight(0, …)` returns before the local is copied, so it changes NOTHING — it is
|
||
// not a way to switch a light off, and a `<PlayerModel>`'s built-in one cannot be killed.
|
||
s.run("MLight:SetLight(0, 1, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1)")
|
||
.unwrap();
|
||
let after: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||
assert_eq!(after, l, "SetLight(0, …) is a no-op, not a disable");
|
||
|
||
// TRAP 2: an ambient intensity of 0 makes the walk skip its colour triple WITHOUT advancing
|
||
// the cursor, so the second intensity is read at index 8 — the seven-argument form. Here
|
||
// `1, 1, 1` are the ambient rgb the walk never reaches, and `0.5` is index 8 = the diffuse
|
||
// intensity against a white colour.
|
||
s.run("MLight:SetLight(1, 1, 0, 0, 0, 0, 0.5)").unwrap();
|
||
let t2: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||
assert_eq!(t2.len(), 10, "a zero ambient block collapses the arity");
|
||
assert_eq!(t2[5], 0.0, "ambient (0,0,0) reports as a lone 0");
|
||
assert_eq!(t2[6], 1.0);
|
||
for c in &t2[7..10] {
|
||
assert!((c - 0.5).abs() < 1e-5, "diffuse read at index 8: {c}");
|
||
}
|
||
|
||
// A non-number where the binding requires one raises rather than coercing.
|
||
assert!(s.run(r#"MLight:SetLight(1, 0, 0, 0, 0)"#).is_err());
|
||
assert!(s.run(r#"MLight:SetLight("x")"#).is_err());
|
||
|
||
// **Fog is NOT the same shape, and this used to assert the opposite.** It read "unset fog
|
||
// returns NOTHING rather than three zeros — a pane with no fog and a pane fogged to black are
|
||
// different states", which is a good argument about a model the client does not have: there is
|
||
// no unset state. The fog colour is one packed `0xAARRGGBB` dword whose ctor writes
|
||
// `0xffffffff`, so a fresh pane reads **four** values, `1, 1, 1, 1` (decision 1845).
|
||
assert_eq!(
|
||
s.eval::<usize>("return table.getn({ MLight:GetFogColor() })")
|
||
.unwrap(),
|
||
4
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, f64, f64)>("return MLight:GetFogColor()")
|
||
.unwrap(),
|
||
(1.0, 1.0, 1.0, 1.0),
|
||
"never set is white and opaque, not four zeros"
|
||
);
|
||
|
||
// Three arguments set alpha to **1.0**, not 0 — the fifth is guarded with that default where
|
||
// r/g/b are read unconditionally. The round trip is LOSSY by 8 bits a channel, because the
|
||
// store is that packed dword: 0.1 does not survive, 0.2 does.
|
||
s.run("MLight:SetFogColor(0.1, 0.2, 0.3)").unwrap();
|
||
let (r, g, b, a): (f64, f64, f64, f64) = s.eval("return MLight:GetFogColor()").unwrap();
|
||
assert_eq!(a, 1.0, "the omitted alpha defaults to 1.0");
|
||
for (got, want) in [(r, 0.1), (g, 0.2), (b, 0.3)] {
|
||
assert!(
|
||
(got - want).abs() <= 1.0 / 255.0,
|
||
"within one 8-bit step of {want}, got {got}"
|
||
);
|
||
}
|
||
|
||
// …and the alpha really is the fifth argument, on the same clamp as the rest.
|
||
s.run("MLight:SetFogColor(1, 1, 1, 0)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<f64>("local _, _, _, a = MLight:GetFogColor() return a")
|
||
.unwrap(),
|
||
0.0
|
||
);
|
||
}
|
||
|
||
/// `SetCamera(n)` selects by **raw table index**, is bounds-checked against the file's camera
|
||
/// count, defers until the file's facts land, and gates the paint until the question is settled —
|
||
/// the four halves of `0x76cec0`/`0x76ce80`/`0x76ce00`/`76d5f0` (decision 2027).
|
||
///
|
||
/// The whole point of the state is which render leg a pane takes: an installed camera is the
|
||
/// perspective leg, the NULL camera is the orthographic one, and an index past the count is a
|
||
/// NULL camera rather than an error or a clamp to the last record.
|
||
#[test]
|
||
fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||
use super::model_clock::pane;
|
||
use crate::widget::{ModelFileFacts, SequenceFacts};
|
||
|
||
let facts = |cameras: u32| ModelFileFacts {
|
||
sequences: vec![SequenceFacts {
|
||
anim_id: 0,
|
||
duration_ms: 1000,
|
||
looping: true,
|
||
}],
|
||
bbox: ([0.0; 3], [0.0; 3]),
|
||
cameras,
|
||
};
|
||
let state = |s: &UiScript, name: &str| {
|
||
let m = pane(s, name);
|
||
(m.camera_pending, m.camera)
|
||
};
|
||
const FILE: &str = r"Creature\Wolf\Wolf.mdx";
|
||
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"m = CreateFrame("Model", "MCam", UIParent) m:SetModel("Creature\\Wolf\\Wolf.mdx")"#)
|
||
.unwrap();
|
||
// The ctor's standing request for camera 0 (`0x76c910` writes `+0x320 = 0`), still pending
|
||
// because the file's facts have not arrived — so the pane is NOT on the paint list.
|
||
assert_eq!(state(&s, "MCam"), (Some(0), None));
|
||
assert!(s.visible_model_panes().is_empty(), "the draw gate holds");
|
||
|
||
// The facts land: the model-ready hook applies the pending index. Wolf has two cameras, so
|
||
// index 0 installs and the pane takes the perspective leg.
|
||
s.set_model_facts(FILE, facts(2));
|
||
assert_eq!(state(&s, "MCam"), (None, Some(0)));
|
||
assert_eq!(s.visible_model_panes().len(), 1, "the gate is settled");
|
||
|
||
// With the facts in hand `SetCamera` resolves immediately — raw index 1 is a real record.
|
||
s.run("MCam:SetCamera(1)").unwrap();
|
||
assert_eq!(state(&s, "MCam"), (None, Some(1)));
|
||
|
||
// An index past the count installs the NULL camera: the ORTHOGRAPHIC leg, and the pane keeps
|
||
// drawing. Not a clamp to the last record, not a raise.
|
||
s.run("MCam:SetCamera(7)").unwrap();
|
||
assert_eq!(state(&s, "MCam"), (None, None));
|
||
assert_eq!(s.visible_model_panes().len(), 1);
|
||
// A negative index lands in the same place (the reference compares unsigned).
|
||
s.run("MCam:SetCamera(1) MCam:SetCamera(-1)").unwrap();
|
||
assert_eq!(state(&s, "MCam"), (None, None));
|
||
|
||
// A file with no camera table at all: every index is out of range, so a plain <Model> on it
|
||
// is always the orthographic leg — which is every shipped in-game UI M2.
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"m = CreateFrame("Model", "MFlat", UIParent) m:SetModel("Interface\\Cooldown\\UI-Cooldown-Indicator.mdx")"#)
|
||
.unwrap();
|
||
s.set_model_facts(r"Interface\Cooldown\UI-Cooldown-Indicator.mdx", facts(0));
|
||
assert_eq!(state(&s, "MFlat"), (None, None));
|
||
|
||
// `SetCamera` before the file is known DEFERS, and the deferred index is what the facts
|
||
// apply — bounds-checked then, not at the call.
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"m = CreateFrame("Model", "MLate", UIParent) m:SetCamera(1) m:SetModel("Creature\\Wolf\\Wolf.mdx")"#)
|
||
.unwrap();
|
||
assert_eq!(state(&s, "MLate"), (Some(1), None));
|
||
assert!(s.visible_model_panes().is_empty());
|
||
s.set_model_facts(FILE, facts(2));
|
||
assert_eq!(state(&s, "MLate"), (None, Some(1)));
|
||
}
|
||
|
||
/// The fog block: `SetFogColor` **arms** it, `ClearFog` disarms **bit 0 alone**, and near/far are
|
||
/// stored raw by the Lua setters (render law §5.4, decision 2027). The five verbs held back as
|
||
/// uncarved since 1134 §4 are real now.
|
||
#[test]
|
||
fn the_fog_block_arms_on_colour_and_clears_only_its_bit() {
|
||
use super::model_clock::pane;
|
||
|
||
let fog = |s: &UiScript| {
|
||
let m = pane(s, "MFog");
|
||
(m.fog, m.fog_near, m.fog_far)
|
||
};
|
||
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"m = CreateFrame("Model", "MFog", UIParent)"#)
|
||
.unwrap();
|
||
// The ctor: fog OFF, near 0.0, far **1.0** (`76c950`) — not far 0, which would be a
|
||
// degenerate ramp the batch would refuse.
|
||
assert_eq!(fog(&s), (false, 0.0, 1.0));
|
||
assert_eq!(
|
||
s.eval::<(f64, f64)>("return MFog:GetFogNear(), MFog:GetFogFar()")
|
||
.unwrap(),
|
||
(0.0, 1.0)
|
||
);
|
||
|
||
// The glue's own call shape. `SetFogColor` is the arming verb; near/far are not.
|
||
s.run("MFog:SetFogNear(0) MFog:SetFogFar(153)").unwrap();
|
||
assert_eq!(fog(&s), (false, 0.0, 153.0), "near/far do not arm the fog");
|
||
s.run("MFog:SetFogColor(1.0, 0.61, 0.42)").unwrap();
|
||
assert!(fog(&s).0, "the colour arms it");
|
||
|
||
// `ClearFog` touches bit 0 and nothing else: the colour, the near and the far all survive, so
|
||
// a later SetFogColor re-arms the same ramp.
|
||
s.run("MFog:ClearFog()").unwrap();
|
||
assert_eq!(fog(&s), (false, 0.0, 153.0));
|
||
let (r, g, b, _): (f64, f64, f64, f64) = s.eval("return MFog:GetFogColor()").unwrap();
|
||
assert!(
|
||
(r - 1.0).abs() < 0.01 && (g - 0.61).abs() < 0.01 && (b - 0.42).abs() < 0.01,
|
||
"the colour survives a clear: {r}, {g}, {b}"
|
||
);
|
||
|
||
// The Lua setters store RAW — no clamp, no ordering check. Only the XML attribute path
|
||
// clamps at `>= 0`.
|
||
s.run("MFog:SetFogNear(-40) MFog:SetFogFar(-1)").unwrap();
|
||
assert_eq!(fog(&s), (false, -40.0, -1.0));
|
||
}
|