Files
benilla-octo/crates/benilla-ui/src/script/tests/modelframe.rs
T
SamandClaude Fable 5.1 eadacac998 a hundred stale claims retire, and text= was never a divergence
- 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>
2026-09-06 19:39:30 +07:00

612 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **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));
}