the model pane takes the file's camera, and the who list sorts

- the `<Model>` widget's perspective leg: a pane whose file supplies the camera it selected renders through a camera of its own whose viewport is its own cell of the same tile atlas — the reference's structure, one target, a viewport per widget, depth cleared per widget — with the composite, packer and reaper untouched; the selection is RAW (`0x76cec0` reads the count off `MD20+0x124` and the record at `[model+0x3c4] + idx·0x84 + 0x80`, `cameraLookup` never consulted), so `M2Model::cameras` is the whole table in file order and `benilla-extract m2cam` prints it, reproducing wow-re's numbers to the digit; the camera is two fields because the reference has two — `+0x320` a PENDING index and `+0x31c` the installed one — and while a request is pending the pane paints nothing (`76d5f0`'s draw gate, now the paint list's own filter); an index past the count installs the NULL camera, which IS the orthographic leg every shipped in-game pane takes; the projection is the booths' byte-derived diagonal-FOV matrix at the pane's aspect, and the eye and target ride the model's root transform as `0x718960`'s publish does — so `SetFacing` cancels along with `SetPosition` and `SetModelScale`, a contradiction with a wow-re note that went out as a §5 round and came back agreeing: the up vector `0x7ac640` builds is model-space +Z at roll 0, the very axis the facing turns about, and the note's sentence is scoped to `<PlayerModel>`'s frozen camera; each perspective camera takes a render layer of its own from a pool of eight, the orthographic camera clears the atlas and every perspective one clears only depth; `SetLight` is a typed `CGLight` that folds each intensity into its colour, normalises a direction on write, quantizes to 8 bits, returns before the copy on `SetLight(0, …)` and reads the second intensity at index 8 on a short ambient block; the five fog verbs are built (`ClearFog` is `76f5c5 and [edi+0x3a4],-2`, bit 0 alone), so the `Model` UNBUILT list is empty for the first time since 1134; a pane that arms a light or fog takes a rig from a lazily-grown pool, and the tiles' material twins — never reaped since the cache was written — are; `WOW_PROBE_MODEL_CAMERA=1` reads the renderer's own camera back for the three cancellations, `pass=5 fail=0` (2027)
- the layout cache writes in the shutdown tail: an unlocked chat window's size and position did not survive `/logout` or `/reload` and `benilla-config/layout/` was never created (B353) — the lane was right (1.12.1 persists a chat window through `StartMoving`/`StartSizing` and the engine's userPlaced bit; no `SetChatWindowSaved*` verb exists in the 5875 FrameXML), but `UiLayoutPlugin` hung its flush on `OnExit(InWorld)`, which a `/reload` never leaves, its debounced writer was never registered in `Update` at all, and on the roots where the edge did fire the VM-reading saver raced `end_ui_session`'s VM replacement on the same unordered edge (measured: the placement moves with nothing but registration positions); the reference's UI shutdown `0x490bd0` has an ordered tail — `PLAYER_LOGOUT` (`0x490c2a`) → `layout-cache.txt` (`0x490c79`) → the flat saved file (`0x490c7e`) → the per-addon files → `AddOns.txt` — on the same five roots as SavedVariables, `/reload` included, so `save_now` joins `shutdown_ui_state` in that slot, one call site reached from every root, and its own edges are deleted; the debounce stays as the crash writer and only that, a deliberate divergence, and the chat-look flush next door is pinned `.before(end_ui_session)` (2029); the VM gets its font engine at the LOAD edge: the measurer was seated only from an `Update` system on the first frame the glyph atlas exists, but `ReloadUI()` mints a fresh VM and runs the in-game load in the same exclusive slot, so every `<OnLoad>` and every addon's file scope measured 0 through the whole reload (`LOAD MEASURE ZERO` live, 33.185 after) — `seat_text_measurer` is one construction site called from both edges; the options tabs fit once from `<OnShow>`, the `OptionsTab_FitWidth` OnUpdate poll and its `fitted` latch gone, because `GetStringWidth` answers in the region's own units and this window's 0.78 scale is applied in the window's own `<OnShow>` after every `<OnLoad>` — an OnLoad fit read 73.19 where the settled value is 76.47; the settled widths are unchanged to the last digit (76.467232 / 91.661896) and the one-frame 72/87 flash at every open goes; four comments that still called the text measure a frame late, and seven gossip-suite comments that put `UIPanelScrollFrameTemplate` in our `ScrollTemplates.xml` when it lives in the reference's `UIPanelTemplates.xml`, are corrected (2028)
- the `/who` sort is a seven-key chain, not "order by the clicked column" (B365): the reference keeps seven `{key, dir}` slots at `0xc2817c`, seeded zone → level → class → group → name → race → guild; `SortWho` (`0x5ad890`) promotes the clicked key to the front and flips its direction ONLY when it was already there — Name, Name reverses, Name, Level, Name restores what the first click left — and the comparator `0x5ada00` walks the chain as tie-breakers, most-recently-used first, through an ASCII fold (`A`–`Z` by `+0x20`, not a Unicode one), the three DBC arms comparing the localized name out of `ChrClasses`/`ChrRaces`/`AreaTable` where ours had sorted by raw id, and an unresolved row TIES (its miss jumps to `0x5adbb2`, the chain loop's own `inc esi`); the click fires `WHO_LIST_UPDATE` synchronously through `SignalEvent` at `0x5ad9ed`, so the binding promotes and sorts the snapshot and the handler repaints before the OnClick returns, where ours redrew a tick later — a redraw one click stale reads exactly like a header that does nothing; the chain is process state (initialised once from `0x401666`), so it survives a logout; the rider from the same carve: `SMSG_WHO` has two exits, and with `SetWhoToUI` clear more than three rows go to the frame with nothing printed and three or fewer to chat (`0x5adfcc jl`, `eax = 3`) where we printed every unclaimed answer; and wow-re's §11 round corrected the first build before it landed — the chat lines come out in WIRE order with the total line LAST, because `0x5ae0a1` composes each line inside the parse loop and the `qsort` at `0x5ae0e2` sits past its back-edge (2030)
- two lighting reports, one frame and one bit each: the surfacing hitch (B354) is a one-frame stale atmosphere — `update_time_lighting` is late by construction, waiting on the wire drain, the weather tick and the submersion verdict, and five `Update` systems that read the `WowLighting` it resolves declared no ordering, so the executor handed each of them last frame's value, deterministically and invisibly while the atmosphere drifts a minute at a time; at a submersion crossing it jumps, on the very frame the sky and cloud domes un-hide, so the first dry frame painted `LightParams` 27's underwater sky over a dry world (the clip's blue frame, (58, 137, 173) against the settled (53, 58, 34)) and `tick_clouds`' surfacing full-rebuild ran at the underwater cloud density, 0.0, rebuilding an empty field that crept back over 0.4 s — the fix for the pop-in handing the symptom straight back; `LightingConsumeSet` names the read side of `LightingResolveSet`, tied in one place, and four consumers join it, the FFX haze and warp are ordered after the verdict they had the same hole against, and two tests enforce it — zero ambiguities between the two sets, and a source scan over `benilla-world` that fails any `WowLighting` reader not in the set; `emit_minimap` is left stale knowingly because joining cost a 178th item through the world API wall; `WOW_FORCE_SUB=<frames>` holds the eye verdict submerged and releases it, so the crossing reproduces server-less (2032); the black furniture below a boat's deck (B373): `MeshTag` bits 6..=13 are the exterior payload's ground-shade byte and bits 6..=18 the interior payload's SH-probe slot, and `entity_shade` guarded the overlap by asking whether a part was on the interior classifier's Bake law — one of the TWO populations holding a probe slot; the other is a lit interior MODD prop, which a WMO-display GameObject parents under the net entity so it sails with the deck, putting it inside the light node's descendant walk — so every frame the boat's shade byte was written over each prop's slot, `(slot & 0x1f00) | byte`: a neighbouring prop's light where that row was live, a zeroed row — solid black — where it was not; the guard now asks the payload question (`probe_payload`) at both write sites, the tag's bits and the marker derived from one binding; measured live at Ratchet, 749 of 749 resident interior prop parts renamed before, 0 of 640 after, and both ledger leads refuted at the bytes — the MODD colours are healthy (`floor112` raises even `#010101` to diffuse 112) and the fold is rigid-motion invariant since every 1.12 transport authors zero MOLR lights; a prop's mouseover identity now names its lane, words and probe slot, and `WOW_PICK` prints `model_flags.z` (2031); the sea-crossing probe can now measure the seam a report names — an explicit `WOW_PROBE_DOCK` goes first, boards only once the body stands at the dock, carries a map id, takes only a candidate within boarding distance, and drops onto the deck rather than into the hull, because a taxi node's `z` is the WATERLINE for the sea ferries while their decks stand ~16 yd above it — and the seam re-anchor returns the cycle it moved to or `None`, so a healthy crossing no longer logs the server's 9.7 h uptime as a skew inside a 350 s cycle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:37:40 +07:00
co-authored by Claude Fable 5.1
parent 7735447d32
commit 4040166138
61 changed files with 4183 additions and 386 deletions
+24 -21
View File
@@ -474,19 +474,22 @@
OptionsFrame:SetScale(k)
end
-- The era tab width law (MinimalTab.lua l.7): label width + 40. Era measures at OnLoad;
-- our measure round-trip answers a frame late, so OnUpdate polls until the width is real
-- (the chat-header converge idiom), then latches. Deliberate divergence: the era
-- measures with the MEDIUM font it is about to swap away (an accident of its code
-- order, ~6 units of extra padding); we measure the small font the label actually
-- wears.
-- The era tab width law (MinimalTab.lua l.7): label width + 40, measured ONCE per show —
-- the 1.12 CharacterFrameTabButtonTemplate's own seat (its <OnShow> calls
-- PanelTemplates_TabResize; decision 1993), and it has to be a show rather than the era's
-- OnLoad for a reason that outlives the measure: GetStringWidth answers in the REGION'S
-- OWN units, so its number depends on the owner's effective scale (glyph advances snap to
-- whole physical pixels, so the same label is not proportionally the same width at two
-- scales) — and this window's scale is set by OptionsFrame_UpdateScale in its own <OnShow>,
-- after every OnLoad has already run. Fired from the tabs' <OnShow>, which the engine
-- runs pre-order behind the window's own, the scale is the drawn one. No poll and no latch:
-- the engine's font measurer answers inside the Lua call that asked (benilla-ui
-- script/measure.rs), and a re-show re-fits at whatever scale the window then wears.
-- Deliberate divergence: the era measures with the MEDIUM font it is about to swap away
-- (an accident of its code order, ~6 units of extra padding); we measure the small font
-- the label actually wears.
function OptionsTab_FitWidth(tab)
if tab.fitted then return end
local w = getglobal(tab:GetName() .. "Text"):GetStringWidth()
if w and w > 0 then
tab:SetWidth(w + 40)
tab.fitted = true
end
tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40)
end
-- Each row's OnLoad: remember the key + title, paint the label, size the wash plate —
@@ -686,9 +689,10 @@
return bottom
end
-- Size the body to that, from the scroll frame's OnUpdate. Frame-late by nature (rects
-- resolve after Lua runs, so a reflow answers a frame later — the OptionsTab_FitWidth
-- idiom), and write-gated: SetWidth/SetHeight invalidate layout, so an every-frame write
-- Size the body to that, from the scroll frame's OnUpdate. Frame-late by nature, and for
-- a reason that has nothing to do with the text measure: RECTS resolve after Lua runs, so
-- a pass that reads GetHeight() to reflow answers off the previous resolve however it is
-- driven. Write-gated: SetWidth/SetHeight invalidate layout, so an every-frame write
-- would fight the resolver. UpdateScrollChildRect re-drives the bar through
-- OnScrollRangeChanged, which is what shows and hides it (and its trough).
function OptionsScroll_Fit()
@@ -2098,15 +2102,14 @@
Tab1 BOTTOMLEFT → Categories TOPLEFT (6,-3)) — the file-folder read, no dead
band above (0981; 0978 floated them at the era's TOPLEFT (32,-27) over a
24-unit bare strip, the director's gap report). Era width/text law kept
(width text+40, selected white small text at BOTTOM+6 vs gold at BOTTOM+4),
(width text+40 measured once per show, selected white small text at BOTTOM+6
vs gold at BOTTOM+4),
authored STATIC: Game is the only tab with anything behind it, so it wears
the selected state permanently and AddOns the unselected one (still dead to
clicks; the mixin port comes with an addon list). The PLATE is the 0978
mapping, kept: one UI-Character-{Active,InActive}Tab piece, bottom-anchored
at the era plate height (26/23), flipped vertically via TexCoords (1.12 only
ships bottom-tab plates; the flip turns them top-tab). The era's width law is
text + 40, measured at OnLoad — our measure round-trip answers a frame late,
so OnUpdate polls until the width is real, the chat-header idiom. -->
ships bottom-tab plates; the flip turns them top-tab). -->
<Button name="$parentGameTab">
<Size><AbsDimension x="72" y="37"/></Size>
<Anchors>
@@ -2131,7 +2134,7 @@
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<Scripts>
<OnUpdate>OptionsTab_FitWidth(this)</OnUpdate>
<OnShow>OptionsTab_FitWidth(self)</OnShow>
</Scripts>
</Button>
<Button name="$parentAddOnsTab">
@@ -2161,7 +2164,7 @@
GOLD (not disabled grey) — the director's capture is the call. -->
<Scripts>
<OnLoad>self:Disable()</OnLoad>
<OnUpdate>OptionsTab_FitWidth(this)</OnUpdate>
<OnShow>OptionsTab_FitWidth(self)</OnShow>
</Scripts>
</Button>
+4 -2
View File
@@ -1073,8 +1073,10 @@ pub(super) fn seed_ui_fixture(
};
// The dropdown list open (0992, re-seated onto Camera Following Style by 1649), same
// posture as the page fixtures: real CVar set, the live open-select-toggle path. The
// list's width settles from its OnUpdate a frame later (the kit's WIDTH SETTLE law) —
// inside the capture's settle frames.
// list's width lands inside the click that opens it — the stock kit's
// `UIDropDownMenu_Refresh` sizes every button from `normalText:GetWidth() + 60`
// (`UIDropDownMenu.lua` l.395-422) and the engine's measurer answers that getter in
// the call that asked, so there is no settle to wait out.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowControls:Click(); \
+2
View File
@@ -105,6 +105,7 @@ mod probe_goquest;
mod probe_guard_poi;
mod probe_mail;
mod probe_melee;
mod probe_model_camera;
mod probe_partner;
mod probe_rig;
mod probe_service;
@@ -133,6 +134,7 @@ pub(crate) use probe_goquest::ProbeGoQuestPlugin;
pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
pub(crate) use probe_model_camera::ProbeModelCameraPlugin;
pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_service::ProbeServicePlugin;
+8 -1
View File
@@ -140,10 +140,17 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
let e = &m.extension;
let c = m.base.base_color.to_srgba();
format!(
"wmo {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
"wmo {:.0} int {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
sidn {:.3},{:.3},{:.3} win {:.0} sunsel {:.2} bias {:.0} uv {:.4},{:.4} \
clutter {:.1},{:.1},{:.1},{:.1} base {:.3},{:.3},{:.3},{:.3} {:?}",
e.model_flags.x,
// `int` is `model_flags.z` — the LIGHT-LANE flag, and it was the one row of the packed
// uniforms this line did not print. Without `is_wmo` it selects the interior-prop lane,
// i.e. "this batch is lit by the SH probe its MeshTag names" rather than by the sky; with
// it, the WMO interior batch-class lanes `class` then splits. A hit that reads
// `wmo 0 int 1` is asking about a probe slot, and the `slot` reading of the tag beside it
// is the one that matters — which is the whole diagnosis of B373.
e.model_flags.z,
e.model_flags.y,
e.tint.w,
e.tint.x,
@@ -57,9 +57,17 @@ enum Phase {
}
/// Yards above the boat's sampled origin the GM drop aims: high enough to clear the deck
/// wherever the model origin sits (waterline or deck), low enough that the post-teleport settle
/// hold (6 s) + the free fall land well inside a ~20 s dock window.
const DROP_HEIGHT: f32 = 10.0;
/// wherever the model origin sits, low enough that the post-teleport settle hold (6 s) + the free
/// fall land well inside the dock window.
///
/// **10 was not high enough, and the failure was silent.** A taxi path's node `z` is the
/// *waterline* for the sea ferries — every node of path 241 carries `z = 0` — while their decks
/// stand ~16 yd above it, so the drop materialised inside the hull and the probe reported
/// "boarding missed the window" forever without ever printing a `transport: board`. The zeppelins
/// hid it: their nodes sit at deck height (71.08), where 10 yd clears fine. Anything on the boat
/// counts as boarding — `owning_transport` walks up to the `Transport` root — so overshooting a
/// cabin roof is harmless and undershooting is not.
const DROP_HEIGHT: f32 = 25.0;
/// Seconds after the deck drop before conceding the boarding failed (settle 6 s + fall + attach,
/// with slack) and re-arming for the next docked window.
const BOARD_DEADLINE: f64 = 15.0;
@@ -70,25 +78,38 @@ const BOARD_DEADLINE: f64 = 15.0;
/// the server has already put in visibility range, so a probe body parked anywhere else waited
/// forever, printing nothing. That made the instrument unrunnable from a cold login, which is the
/// only way an unattended session ever starts it.
const BOOTSTRAP_DOCK: [f32; 3] = [-14297.2, 531.0, 8.8];
/// `WOW_PROBE_DOCK=x,y,z` — go to *this* dock, unconditionally, before waiting for anything.
const BOOTSTRAP_DOCK: [f32; 4] = [-14297.2, 531.0, 8.8, 0.0];
/// `WOW_PROBE_DOCK=x,y,z[,map]` — go to *this* dock, before waiting for anything. `map` defaults
/// to 0 and **must be sent**: `.go xyz` without one teleports within the map you are *currently*
/// on, so a probe that has already crossed to Kalimdor and is then aimed at a Booty Bay dock lands
/// at Azeroth's coordinates on Kalimdor — off that map's tile grid, where nothing streams and the
/// loading cover can never clear. This probe did exactly that and hung, which is the bug it was
/// written to chase, arrived at from the other end.
///
/// Without it the probe rides whichever cross-continent ferry the login happens to be standing
/// next to, which is not a choice at all: the 1.12 fleet's seams are not interchangeable (one
/// path crosses mid-cycle, another crosses at the cycle wrap), so "it worked" on the ferry that
/// answered says nothing about the one a report names. Overriding the destination is how a
/// specific seam gets measured.
fn dock_override() -> Option<[f32; 3]> {
fn dock_override() -> Option<[f32; 4]> {
let raw = std::env::var("WOW_PROBE_DOCK").ok()?;
let mut it = raw.split(',').map(|p| p.trim().parse::<f32>());
match (it.next(), it.next(), it.next(), it.next()) {
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), None) => Some([x, y, z]),
match (it.next(), it.next(), it.next(), it.next(), it.next()) {
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), None, _) => Some([x, y, z, 0.0]),
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), Some(Ok(m)), None) => Some([x, y, z, m]),
_ => {
warn!("WOW_PROBE_DOCK={raw:?} is not `x,y,z` — ignored");
warn!("WOW_PROBE_DOCK={raw:?} is not `x,y,z` or `x,y,z,map` — ignored");
None
}
}
}
/// How near [`dock_override`]'s named dock the body must stand before the probe will board
/// anything — wide enough to cover a pier and the ferry moored along it, tight enough that the
/// login spot on another continent can never satisfy it.
const DOCK_ARRIVED_YD: f32 = 200.0;
/// How near the body a docked ferry must be before the probe will drop onto it — see the scan.
/// Generous enough for a long pier, far short of the next dock on the same continent.
const BOARDABLE_YD: f32 = 400.0;
/// Seconds the probe watches for a cross-continent transport before sending itself to
/// [`BOOTSTRAP_DOCK`] — long enough for a login's object stream to deliver one if we are already
/// somewhere it sails from.
@@ -118,21 +139,46 @@ fn crossing_probe(
let adrift = !transports
.iter()
.any(|(_, t, _)| t.touches_map(0) && t.touches_map(1));
if (forced.is_some() || adrift) && !probe.bootstrapped && now > BOOTSTRAP_AFTER {
// An explicit `WOW_PROBE_DOCK` goes FIRST and waits for nothing: the whole point of
// naming a dock is to ride *that* ferry, and the login spot very often has a different
// one already in range — which the scan below would board within the grace period,
// silently measuring the wrong seam. (It did exactly that: a run aimed at Booty Bay
// rode the Undercity zeppelin instead.) Only the "no ferry anywhere" fallback waits,
// because that one is guessing and should let the object stream finish first.
// **Having SENT the hop is not having ARRIVED.** `.go` is a server round trip; the
// scan below runs again on the very next frame, when the body is still standing where
// it logged in and the old surroundings' ferry is still in range — so it boards that
// one and measures the wrong seam, which is precisely the failure the override exists
// to prevent. (Aimed at Booty Bay, it rode the Menethil–Theramore ferry.) So while a
// dock is named, nothing is boarded until the body is actually standing at it.
if let Some(dock) = forced {
let here = benilla_assets::coords::bevy_to_wow(player.pos);
let (dx, dy) = (here[0] - dock[0], here[1] - dock[1]);
if (dx * dx + dy * dy).sqrt() > DOCK_ARRIVED_YD {
if probe.bootstrapped {
return; // hop sent, still in flight
}
} else {
probe.bootstrapped = true; // arrived; fall through to the scan
}
}
let hop_now = forced.is_some() || (adrift && now > BOOTSTRAP_AFTER);
if hop_now && !probe.bootstrapped {
probe.bootstrapped = true;
let [x, y, z] = forced.unwrap_or(BOOTSTRAP_DOCK);
let [x, y, z, m] = forced.unwrap_or(BOOTSTRAP_DOCK);
info!(
"PROBE crossing: going to the dock ({x:.1}, {y:.1}, {z:.1}) — {}",
"PROBE crossing: going to the dock ({x:.1}, {y:.1}, {z:.1}) on map {m} — {}",
if forced.is_some() {
"WOW_PROBE_DOCK"
} else {
"no cross-continent transport in range"
}
);
// The map id is not optional — see [`dock_override`].
let _ = net.0.send(ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} "),
text: format!(".go xyz {x} {y} {z} {}", m as i32),
});
return;
}
@@ -150,6 +196,17 @@ fn crossing_probe(
if sample.map != map || sample.moving {
continue;
}
// **And it has to be a ferry we are actually standing next to.** A cross-continent
// `.go` leaves the departed surroundings' objects in the entity list until the
// server gets round to removing them, so the scan can otherwise pick a boat
// thousands of yards away and `.go` onto its deck — undoing the hop that just
// placed us, and measuring whichever seam that boat happens to cross. Aimed at
// Booty Bay, this probe boarded the Tirisfal zeppelin twice that way.
let here = benilla_assets::coords::bevy_to_wow(player.pos);
let (dx, dy) = (here[0] - sample.pos[0], here[1] - sample.pos[1]);
if (dx * dx + dy * dy).sqrt() > BOARDABLE_YD {
continue;
}
let [x, y, z] = sample.pos;
info!(
"PROBE crossing: boat {:#x} docked on map {map} at ({x:.1}, {y:.1}, {z:.1}) \
@@ -0,0 +1,357 @@
//! `WOW_PROBE_MODEL_CAMERA=1` — the live probe for the `<Model>` widget's **perspective leg**
//! (decision 2027): a plain `CreateFrame("Model")` pointed at a camera-bearing file, framed by the
//! file's own camera through a camera of its own into its cell of the tile atlas.
//!
//! ## What it asks, and why it is a probe rather than a test
//!
//! The unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV
//! matrix against wow-re's worked numbers, and the three cancellations. What they cannot reach is
//! the half that only exists at run time: does the engine resolve the camera at all, does the
//! renderer find the record, does a camera get spawned, aimed, and pointed at a real cell of a
//! real atlas. So this probe drives the **whole live pipeline** from Lua and reads the result off
//! the entities the renderer actually built.
//!
//! The measurement is numeric, not visual (method.md's rule: a capture can confirm an existence
//! fact, it cannot measure one). Each leg projects three model-space probe points through the
//! camera and the root the renderer placed this frame, and compares the resulting NDC against the
//! baseline's:
//!
//! - `scale` — `SetModelScale(3)` must not move a pixel. The authored eye is carried through the
//! model's root transform, so the camera scales with the model.
//! - `position` — `SetPosition(0.4, −0.3, 0.9)` must not move a pixel, for the same reason.
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**. That one is the finding this
//! work commissioned (wow-re `modelframe-facing-cancels.md`): the up vector `0x7ac640` builds is
//! model-space `+Z` at `roll = 0`, which is the axis the facing turns about, so eye, target,
//! geometry and up all turn together. `modelframe-render-law.md` §2's "only `SetFacing` shows"
//! is scoped to `<PlayerModel>`'s frozen camera.
//! - `ortho` — the control that must NOT hold: `SetCamera(9)` is past the file's camera count, so
//! the widget installs the NULL camera and falls to the orthographic leg, where a facing is a
//! plain roll in the screen plane. A probe whose "identical" legs all passed because nothing was
//! drawing would fail here.
//!
//! Three screenshots ride along — the baseline, the scaled/offset one and the turned one — as the
//! existence evidence and for the director's own eye. They are the whole window, so they are not
//! compared: the world behind the pane moves.
//!
//! ## The run recipe
//! ```text
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). `WOW_TILE_TRACE=1`
//! alongside it prints the leg, the record, the eye and the matrix per pane per frame.
use bevy::camera::visibility::RenderLayers;
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::net::SelfPlayer;
use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
/// The file the probe frames. `Creature\Wolf\Wolf.m2` carries **two** cameras
/// (`benilla-extract m2cam`: index 0 type 0 at eye `(1.6927, 0.7238, 0.8837)` fov `0.95002`,
/// index 1 type 1 at eye `(5.5556, 0, 1.8056)` fov `0.67620`), so it exercises the raw-index
/// selection AND has an index past the count for the orthographic control.
const FILE: &str = r"Creature\\Wolf\\Wolf.mdx";
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is wow-re's
/// worked example (`camera-law.md` §12.1: `θ = 0.287938 · fov`).
const PANE_W: f32 = 318.0;
const PANE_H: f32 = 224.0;
/// Frames to let a change settle before the numbers are read: the extract republishes the request
/// on the next conversion and the renderer aims the camera the frame after that.
const SETTLE_FRAMES: u32 = 8;
/// NDC agreement the cancellation legs must hold to. Generous against f32 round-off through two
/// 4×4 composes; a leg that failed to cancel misses by tens of percent, not by a thousandth.
const NDC_EPS: f32 = 1.0e-3;
/// The Lua that builds the pane. Anonymous is the corpus shape (pfUI's autocast shine), but the
/// probe needs a handle on it, so it is global.
const BUILD: &str = r#"
ProbeModelCam = CreateFrame("Model", "ProbeModelCam", UIParent)
ProbeModelCam:SetWidth(318)
ProbeModelCam:SetHeight(224)
ProbeModelCam:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
ProbeModelCam:SetModel("Creature\\Wolf\\Wolf.mdx")
ProbeModelCam:SetCamera(0)
ProbeModelCam:SetLight(1, 0, 0, -0.707, -0.707, 0.7, 1.0, 1.0, 1.0, 0.8, 1.0, 1.0, 0.8)
ProbeModelCam:Show()
"#;
pub(crate) struct ProbeModelCameraPlugin;
impl Plugin for ProbeModelCameraPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ModelCameraProbe>()
.add_systems(Update, model_camera_probe);
}
}
/// One leg's reading: the NDC of the three probe points, and the label to report it under.
#[derive(Clone, Copy, Default)]
struct Reading([Vec2; 3]);
#[derive(Resource, Default)]
struct ModelCameraProbe {
phase: Phase,
baseline: Option<Reading>,
passes: u32,
fails: u32,
skips: u32,
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Start,
/// Waiting for the pane's tile to be packed and its camera aimed.
Await {
since: f64,
},
/// A change is in flight; read it in `at`.
Settle {
frames: u32,
leg: Leg,
},
Done,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Leg {
Baseline,
Scale,
Position,
Facing,
Ortho,
}
impl Leg {
/// The Lua that puts the pane INTO this leg — run when the leg is entered, before its reading.
fn setup(self) -> &'static str {
match self {
Leg::Baseline => "",
Leg::Scale => "ProbeModelCam:SetModelScale(3)",
Leg::Position => {
"ProbeModelCam:SetModelScale(1) ProbeModelCam:SetPosition(0.4, -0.3, 0.9)"
}
Leg::Facing => "ProbeModelCam:SetPosition(0, 0, 0) ProbeModelCam:SetFacing(1.0)",
// The control: index 9 is past Wolf's two cameras, so the widget installs the NULL
// camera and the pane drops to the orthographic leg. Its perspective camera goes
// inactive, which is what the reading detects.
Leg::Ortho => "ProbeModelCam:SetCamera(9)",
}
}
fn next(self) -> Option<Leg> {
match self {
Leg::Baseline => Some(Leg::Scale),
Leg::Scale => Some(Leg::Position),
Leg::Position => Some(Leg::Facing),
Leg::Facing => Some(Leg::Ortho),
Leg::Ortho => None,
}
}
}
/// The three model-space points the legs are compared on — a wolf-sized spread, in Bevy space
/// (the frame the tile root and the camera both work in).
fn probe_points() -> [Vec3; 3] {
[
benilla_assets::coords::wow_to_bevy([0.0, 0.0, 0.8]),
benilla_assets::coords::wow_to_bevy([0.6, -0.3, 0.4]),
benilla_assets::coords::wow_to_bevy([-0.4, 0.5, 1.1]),
]
}
/// Project the three points through whatever the renderer built this frame — the ACTIVE
/// perspective camera and the tile root under its layer. `None` when no perspective camera is
/// aimed (which is the orthographic leg, and the control's expected answer).
fn read_leg(
cams: &Query<
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
With<TilePerspectiveCamera>,
>,
roots: &Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
) -> Option<Reading> {
let (_, cam_tf, proj, layers) = cams.iter().find(|(c, _, _, _)| c.is_active)?;
// The pane's root is the one on the camera's OWN layer — a perspective camera sees exactly one
// tile by construction, and the interface has other tiles up (the minimap ping, the map arrow)
// whose roots would otherwise be picked at random.
let (root, _) = roots.iter().find(|(_, l)| *l == layers)?;
let clip = proj.get_clip_from_view() * cam_tf.to_matrix().inverse() * root.to_matrix();
let mut out = [Vec2::ZERO; 3];
for (o, p) in out.iter_mut().zip(probe_points()) {
let c = clip * p.extend(1.0);
*o = Vec2::new(c.x / c.w, c.y / c.w);
}
Some(Reading(out))
}
fn shoot(commands: &mut Commands, name: &str) {
// Every file we write goes through `local_state` — never into the WoW install, never a
// platform config dir (the contract's one-folder rule).
let Some(path) = crate::local_state::home().map(|d| d.join(format!("{name}.png"))) else {
warn!("PROBE_MODEL_CAMERA: no local-state folder — skipping the {name} shot");
return;
};
info!("PROBE_MODEL_CAMERA: shot -> {}", path.display());
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(path));
}
#[allow(clippy::too_many_arguments)]
fn model_camera_probe(
mut commands: Commands,
time: ProbeClock,
mut probe: ResMut<ModelCameraProbe>,
script: Option<NonSendMut<UiScript>>,
tiles: Res<UiModelTiles>,
self_player: Query<(), With<SelfPlayer>>,
cams: Query<
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
With<TilePerspectiveCamera>,
>,
roots: Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let Some(script) = script else { return };
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Start => {
info!("PROBE_MODEL_CAMERA: building a {PANE_W}x{PANE_H} <Model> on {FILE}, camera 0");
if let Err(e) = script.run(BUILD) {
error!("PROBE_MODEL_CAMERA: FAIL building the pane: {e}");
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
probe.phase = Phase::Await { since: now };
}
Phase::Await { since } => {
if !tiles.cells.is_empty() && read_leg(&cams, &roots).is_some() {
let cell = tiles.cells.values().next().copied();
info!("PROBE_MODEL_CAMERA: tile up, cell {cell:?} — the perspective leg is live");
probe.passes += 1;
probe.phase = Phase::Settle {
frames: 0,
leg: Leg::Baseline,
};
} else if now - since > 30.0 {
// The asset never landed, or the pane never reached the paint list. An
// environmental miss, not a wrong answer.
error!(
"PROBE_MODEL_CAMERA: SKIP — no tile after 30 s (cells={}, camera aimed={})",
tiles.cells.len(),
read_leg(&cams, &roots).is_some()
);
probe.skips += 1;
probe.phase = Phase::Done;
}
}
Phase::Settle { frames, leg } => {
if frames < SETTLE_FRAMES {
probe.phase = Phase::Settle {
frames: frames + 1,
leg,
};
return;
}
let reading = read_leg(&cams, &roots);
match (leg, reading) {
(Leg::Baseline, Some(r)) => {
info!("PROBE_MODEL_CAMERA: baseline ndc {:?}", r.0);
probe.baseline = Some(r);
shoot(&mut commands, "probe-model-camera-a-baseline");
}
(Leg::Ortho, None) => {
info!(
"PROBE_MODEL_CAMERA: ortho PASS — SetCamera(9) is past the file's two \
cameras, so the NULL camera installed and the perspective camera parked"
);
probe.passes += 1;
}
(Leg::Ortho, Some(_)) => {
error!(
"PROBE_MODEL_CAMERA: ortho FAIL — an out-of-range SetCamera left the \
perspective leg live; the bounds check is not being applied"
);
probe.fails += 1;
}
(_, None) => {
error!("PROBE_MODEL_CAMERA: {leg:?} FAIL — the perspective camera went away");
probe.fails += 1;
}
(_, Some(r)) => {
let base = probe.baseline.unwrap_or_default();
let worst = base
.0
.iter()
.zip(r.0)
.map(|(a, b)| (a.x - b.x).abs().max((a.y - b.y).abs()))
.fold(0.0f32, f32::max);
if worst <= NDC_EPS {
info!(
"PROBE_MODEL_CAMERA: {leg:?} PASS — cancels, worst NDC delta {worst:.6}"
);
probe.passes += 1;
} else {
error!(
"PROBE_MODEL_CAMERA: {leg:?} FAIL — worst NDC delta {worst:.6} > \
{NDC_EPS}; base {:?} got {:?}",
base.0, r.0
);
probe.fails += 1;
}
if leg == Leg::Scale {
shoot(&mut commands, "probe-model-camera-b-scale-and-position");
}
if leg == Leg::Facing {
shoot(&mut commands, "probe-model-camera-c-facing");
}
}
}
match leg.next() {
Some(next) => {
if let Err(e) = script.run(next.setup()) {
error!("PROBE_MODEL_CAMERA: FAIL setting up {next:?}: {e}");
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
probe.phase = Phase::Settle {
frames: 0,
leg: next,
};
}
None => probe.phase = Phase::Done,
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_MODEL_CAMERA: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
exit.write(AppExit::Success);
// The polite `AppExit` plus a hard backstop, the harness's standard pair
// (`ProbeExitPlugin::fire_probe_exit`): a net or winit teardown hang must not leave a
// zombie client holding the probe account.
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_MODEL_CAMERA: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
+8
View File
@@ -121,6 +121,7 @@ impl Plugin for DevProbesPlugin {
"WOW_PROBE_CHEST",
"WOW_PROBE_CLAM",
"WOW_PROBE_VENDOR_SWAP",
"WOW_PROBE_MODEL_CAMERA",
"WOW_RIG",
"WOW_LIVE_FPS",
// A screenshot burst and a pick burst are wall-clock schedules too, and on an
@@ -349,6 +350,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_VENDOR_SWAP").is_ok() {
app.add_plugins(crate::capture::ProbeVendorSwapPlugin);
}
// The `<Model>` perspective-leg live probe: `WOW_PROBE_MODEL_CAMERA=1` builds a plain
// pane on a camera-bearing file and reads the renderer's own camera and root back to
// check the three cancellations against an orthographic control (decision 2027's
// instrument; see `capture::ProbeModelCameraPlugin`, whose module doc is the recipe).
if std::env::var("WOW_PROBE_MODEL_CAMERA").is_ok() {
app.add_plugins(crate::capture::ProbeModelCameraPlugin);
}
// The GM trouble-ticket live probe: `WOW_PROBE_GMTICKET=1` drives the whole five-opcode
// ticket wire through the live VM's own bindings — queue status, clean slate, file, edit,
// abandon — and prints the row the server must have stored so the operator can check the
+20 -3
View File
@@ -60,8 +60,9 @@ use benilla_world::lighting::{PropProbeSlot, PropProbes};
use benilla_world::model_render::ShadeSel;
use benilla_world::particles;
use benilla_world::terrain_stream::{
build_collider_task, fold_interior_probe, m2_anim_bound, m2_fade, placement_collider_data,
point_light, spawn_model_entities, PendingCollider, PropLobeLight, SpawnedModel,
build_collider_task, fold_interior_probe, hex_word, m2_anim_bound, m2_fade,
placement_collider_data, point_light, spawn_model_entities, PendingCollider, PropLobeLight,
SpawnedModel,
};
use super::{GameObjects, ModelHandle, VisualAttached};
@@ -271,7 +272,23 @@ pub(super) fn spawn_wmo_gameobject_props(
.map(|p| p.path().to_string_lossy().into_owned())
.unwrap_or_default(),
id: 0,
detail: "WMO gameobject prop".into(),
// The prop's LANE, the way a terrain-placed prop's identity names it
// (`PropLight::inspector_label`): a prop that looks wrong is almost always on the
// wrong lane or reading the wrong probe, and neither is visible from the model
// path. This lane's props were the ones the entity shade writer renamed out from
// under (B373), and a hover said nothing at all.
detail: match (&prop.interior, interior_slot) {
(Some(lane), Some(slot)) => format!(
"WMO gameobject prop · interior amb {} dif {} · {} MOLR · probe slot {slot}",
hex_word(lane.ambient),
hex_word(lane.diffuse),
lane.lights.len(),
),
(Some(_), None) => {
"WMO gameobject prop · interior, NO PROBE SLOT (table full — sky-lit)".into()
}
(None, _) => "WMO gameobject prop · sky-lit".into(),
},
});
let SpawnedModel {
entities: ents,
+5
View File
@@ -1071,6 +1071,11 @@ impl Plugin for MinimapPlugin {
probe_minimap_widget
.in_set(UiQuadAppend)
.before(emit_minimap),
// Deliberately NOT on the lighting resolve's read side, though it reads
// `WowLighting` (decision 2032): joining `LightingConsumeSet` would put a
// 178th engine item through the world API wall, and what it buys is one
// frame of the right day-night tint on a 140 px map — invisible even on a
// submersion crossing, which is the one moment that value jumps.
emit_minimap.in_set(UiQuadAppend),
// After the emit that fills it: the composite camera draws what THIS frame's
// interior branch asked for, so the target the blit quad samples is never a
+4 -2
View File
@@ -255,8 +255,10 @@ pub(super) fn disconnected(
*duel = crate::ui_duel::DuelState::default();
// The friend/ignore lists and the last `/who` are session state too (decision 0668): the
// server re-pushes both lists at the next login, and a stale ignore list would silence the
// wrong guids after a reconnect renumbers nothing but re-streams everything.
*social = crate::ui_social::SocialState::default();
// wrong guids after a reconnect renumbers nothing but re-streams everything. The `/who` sort
// chain is the one thing that survives — it is per-PROCESS in the reference, not per-login
// (decision 2030), which is why this is a `clear_session` and not a `default()`.
social.clear_session();
// The guild session is login-scoped the same way (decision 1257) — and more strictly, because
// the next login may be a *different character*, whose guild id, rank, rights and roster share
// nothing with this one's. The identity cache goes too: it is keyed by guild id, so it would
+2 -2
View File
@@ -199,7 +199,7 @@ pub(super) fn glue_zoom_floor(fov: f32) -> f32 {
/// write — it would stomp ours. `update` is a deliberate no-op, and that is faithful: the client's
/// bake emits the *same* matrix at 1152×648 and at 1280×800 (1543).
#[derive(Debug, Clone)]
pub(super) struct WowPortraitProjection {
pub(crate) struct WowPortraitProjection {
/// The M2 record's fov (radians) — a *diagonal* angle in the client's convention, NOT fovy.
pub(super) fov: f32,
pub(super) near: f32,
@@ -525,7 +525,7 @@ pub(super) fn pane_camera(a: &PortraitAnchors) -> benilla_assets::PortraitCamera
/// The falsifier, if anyone is tempted to propagate the portrait's `1.0` here anyway: the viewport
/// comes from that same rect, so projection and viewport aspects agree by construction, and a
/// transplanted `1.0` would render a sphere **1.42× wider than tall** in the 318×224 pet pane.
pub(super) fn pane_projection(
pub(crate) fn pane_projection(
cam: &benilla_assets::PortraitCamera,
aspect: f32,
) -> WowPortraitProjection {
+17 -7
View File
@@ -32,7 +32,7 @@ use super::framing::{
use super::{
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
BoothBillboardSpec, BoothCam, BoothEffects, BoothInstance, BoothLight, BoothMotion, BoothPart,
BoothRider, BoothTwins, Booths, PortraitImages, PortraitSource, GLUE_LAYER,
BoothRider, BoothTwins, Booths, PortraitImages, PortraitSource, VariantLane, GLUE_LAYER,
};
/// The glue booth slot token (its key in [`super::PortraitImages`] / [`Booths`]).
@@ -1378,10 +1378,14 @@ pub(super) fn sync_glue_booth(
// The glue screens keep the old fallback deliberately: the create/select scene has
// its own lifecycle (no map-scope teardown under it) and no parts-key retry to
// ride, so waiting here would leave the pane empty instead of merely mislit.
(Some(buf), Some(s)) => {
super::material_variant(&mut s.variants, buf, material, materials, true)
.unwrap_or_else(|| material.clone())
}
(Some(buf), Some(s)) => super::material_variant(
&mut s.variants,
buf,
material,
materials,
VariantLane::RigUnfogged,
)
.unwrap_or_else(|| material.clone()),
_ => booth_light.studio.variant(material, materials),
}
};
@@ -1652,8 +1656,14 @@ pub(super) fn sync_glue_pet(
AssetId<WowModelMaterial>,
Handle<WowModelMaterial>,
>| {
super::material_variant(variants, &light, material, &mut materials, true)
.unwrap_or_else(|| material.clone())
super::material_variant(
variants,
&light,
material,
&mut materials,
VariantLane::RigUnfogged,
)
.unwrap_or_else(|| material.clone())
};
let booth_parts: Vec<BoothPart> = pet
.parts
+34 -12
View File
@@ -53,7 +53,13 @@ impl BoothRig {
}
return world.clone();
};
match material_variant(&mut self.variants, &buffer, world, materials, false) {
match material_variant(
&mut self.variants,
&buffer,
world,
materials,
VariantLane::World,
) {
Some(twin) => twin,
None => {
self.unready = true;
@@ -106,16 +112,30 @@ pub(super) fn reap_dead_variants(
}
}
/// Which lane a [`material_variant`] twin draws on — the two axes the twin can differ from its
/// world source on, as one closed choice rather than a bool nobody can read at the call site.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum VariantLane {
/// The world's own shade lane, the batch's authored fog policy kept — the portrait booths,
/// which render a model exactly as the world would but under a frozen light.
World,
/// The scene's authored M2 light rig ([`benilla_world::model_render::ShadeSel::Rig`] — the
/// probe-slot SH eval + the buffer's point table, decisions 0429/0435) **with fog forced
/// OFF**: the glue CHARACTER model takes no fog in the reference (its fill callback stages
/// none, its collector's fog stays zeroed — wow-re `glue-model-lighting.md` §5), and so does
/// a `<Model>` pane that never armed any.
RigUnfogged,
/// The rig lane with the batch's **authored** fog policy kept — a `<Model>` pane whose Lua
/// armed fog (decision 2027). The per-material UNFOGGED bit (`0x02`) then does its own work,
/// which is the reference's own per-batch fork: a fogged pane still draws its UNFOGGED
/// materials unfogged (render law §5.6). The glue background scene takes this lane too.
RigFogged,
}
/// The twin of a world-built material against `buffer`, cached in `variants` — same
/// texture/blend/flags, only the light storage swapped. [`BoothRig::variant`] is this against one of
/// the two fixed booth buffers; the create scene passes its own authored-rig buffer with `rig` set,
/// which additionally flips the twin onto the [`benilla_world::model_render::ShadeSel::Rig`] lane (the
/// probe-slot SH eval + the buffer's point table — the scene's authored M2 light rig, decision
/// 0429/0435) instead of the world sun/intensity lane the material was built for — and forces the
/// twin's fog OFF: the glue CHARACTER model takes no fog in the reference (its fill callback
/// stages none, its collector fog stays zeroed — wow-re `glue-model-lighting.md §5`; the
/// background scene model, built by `sync_create_scene` with its own fog policy, keeps the
/// `CharModelFogInfo` fog).
/// texture/blend/flags, only the light storage swapped, plus whatever [`VariantLane`] asks for.
/// [`BoothRig::variant`] is this on [`VariantLane::World`] against one of the two fixed booth
/// buffers; the create scene and the UI model tiles pass their own buffers on a rig lane.
///
/// **`None` means the source material is not resident yet** — there is no twin to hand back, and
/// the world material is NOT an acceptable substitute in a booth (wrong light buffer; see
@@ -125,7 +145,7 @@ pub(crate) fn material_variant(
buffer: &bevy::render::render_resource::Buffer,
world: &Handle<WowModelMaterial>,
materials: &mut Assets<WowModelMaterial>,
rig: bool,
lane: VariantLane,
) -> Option<Handle<WowModelMaterial>> {
if let Some(twin) = variants.get(&world.id()) {
return Some(twin.clone());
@@ -136,8 +156,10 @@ pub(crate) fn material_variant(
let mat = materials.get(world)?;
let mut twin = mat.clone();
twin.extension.light_buf = buffer.clone();
if rig {
if lane != VariantLane::World {
twin.extension.sun_scale.x = benilla_world::model_render::ShadeSel::Rig.selector();
}
if lane == VariantLane::RigUnfogged {
// Force fog OFF while preserving every pipeline marker `specialize` keys on (bits 0-3
// AND the 0528 multiply markers, bits 7-8) — the mask is owned by `model_render`, next
// to the packer. A hand-rolled `as u8 & 0x0f` here once dropped the multiply markers
+19 -3
View File
@@ -78,7 +78,9 @@ use crate::target::Selection;
use benilla_assets::materials::WowModelMaterial;
mod framing;
pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
pub(crate) use framing::{
attachment_point, head_anchor, pane_projection, PortraitAnchors, WowPortraitProjection,
};
use framing::{body_frame, frame, PORTRAIT_FOV};
mod booth;
/// The translucency twins ride the *preview* shapes as well as the booth ones — one type for both
@@ -97,7 +99,7 @@ pub(crate) use glue_booth::{
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
};
mod light;
pub(crate) use light::material_variant;
pub(crate) use light::{material_variant, VariantLane};
use light::{model_pane_light, studio_light, BoothLight};
mod test_bake;
@@ -230,6 +232,19 @@ pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
/// The UI model tiles' layer (`crate::ui_models`, decision 2008): every `<Model>` widget's M2
/// renders into one atlas through one camera on this layer.
pub(crate) const UI_MODELS_LAYER: usize = MINIMAP_COMPOSITE_LAYER + 1;
/// The base of the **perspective model panes'** layer block (decision 2027). A `<Model>` framed
/// by its file's own camera cannot share the tile atlas's one orthographic camera — it needs a
/// camera of its own, rendering into its own cell of the same atlas through a viewport — and one
/// camera per pane means one layer per pane, or every perspective camera would draw every other
/// pane's model over its cell. The block runs `BASE + i` for `i < UI_MODEL_CAM_LAYERS` and sits at
/// the TOP of the ladder, so it can be widened without colliding with anything above it.
pub(crate) const UI_MODEL_CAM_LAYER_BASE: usize = UI_MODELS_LAYER + 1;
/// How many perspective model panes can draw at once — the size of the layer block above and of
/// the camera pool beside it. Each is a full render pass into the atlas, and a UI showing eight
/// authored-camera 3-D scenes at once is already far past anything the reference's interface or
/// the addon corpus does; a ninth pane holds its cell and draws nothing, the same degrade the
/// atlas already makes for a tile that does not fit.
pub(crate) const UI_MODEL_CAM_LAYERS: usize = 8;
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
// and the emitter→camera match, and the failure above was silent in both.
@@ -247,7 +262,8 @@ const _: () = assert!(
&& DRESSUP_LAYER > GLUE_LAYER
&& WARM_BOOTH_LAYER > DRESSUP_LAYER
&& MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER
&& UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER,
&& UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER
&& UI_MODEL_CAM_LAYER_BASE > UI_MODELS_LAYER,
"booth render layers must be distinct — see GLUE_LAYER"
);
const _: () = assert!(
+31 -12
View File
@@ -205,15 +205,23 @@ impl Transport {
}
/// Re-anchor this transport's clock so its cycle sits at the head of `map_id`'s leg, and
/// return the sample there. `None` if the drive has no leg on that map (a lift never does).
/// return the sample there **plus the cycle instant it was moved to, if it moved at all**.
/// `None` if the drive has no leg on that map (a lift never does).
///
/// The anchor is stored as `progress_ms` + the instant it was taken, so seeking is simply
/// "restamp both": the clock runs on from the new cycle position exactly as it would have.
///
/// **The second element must be how the caller decides whether anything happened.** The stored
/// `progress_ms` is the server's own uptime-scale path progress until a seek replaces it —
/// unbounded, and nothing like a cycle position — so "did it change?" cannot be answered by
/// comparing it against a `% period` cycle value. Doing exactly that made the healthy
/// already-agreed crossing print `our path clock was 157334 ms behind` with a target of
/// 35081741 ms, which is 9.7 hours: the server's uptime, not a position in a 350 s loop.
pub(crate) fn reseek_to_map(
&self,
anchor: &mut TransportAnchor,
map_id: u32,
) -> Option<TransportSample> {
) -> Option<(TransportSample, Option<u32>)> {
let Drive::Taxi(t) = &self.drive else {
return None;
};
@@ -224,12 +232,12 @@ impl Transport {
// Only a clock that actually disagrees with the server gets corrected.
let here = t.sample(cycle);
if here.map == map_id {
return Some(here);
return Some((here, None));
}
let target = t.first_cycle_on_map(cycle, map_id)?;
anchor.progress_ms = target;
anchor.at = Instant::now();
Some(t.sample(target))
Some((t.sample(target), Some(target)))
}
/// Whether any leg of this transport's cycle lies on `map_id` — the cross-map worldport's
@@ -598,7 +606,7 @@ fn reseek_ridden_transport_at_worldport(
continue;
};
let before = transport.cycle_ms(&anchor);
let Some(sample) = transport.reseek_to_map(&mut anchor, w.map_id) else {
let Some((sample, moved_to)) = transport.reseek_to_map(&mut anchor, w.map_id) else {
warn!(
"transport {:#x}: rode a transfer to map {} its own path never visits — cannot \
re-anchor; the rider's pose will be composed through a stale one",
@@ -606,17 +614,16 @@ fn reseek_ridden_transport_at_worldport(
);
continue;
};
let after = anchor.progress_ms;
// Silent when we were already there (the healthy crossing — the two clocks agreed and the
// seek was a no-op). Loud when it moved, carrying **how far behind the server our own
// path clock was**: this is the only measurement of client-vs-server transport drift the
// client can take, and it is worth a line every time it is not zero.
// seek was a no-op, which `moved_to == None` is the ONLY sound way to detect). Loud when it
// moved, carrying **how far behind the server our own path clock was**: this is the only
// measurement of client-vs-server transport drift the client can take.
//
// Measured **forward around the cycle**, because a crossing is very often the cycle wrap
// (on taxi path 241 the Kalimdor legs are the *first* frames, so "cross to Kalimdor" IS
// the wrap): a plain `after - before` reports a 144 ms nudge as −356 140 ms and reads as
// the clock running half a cycle ahead, which is the opposite of what happened.
if before != after {
if let Some(after) = moved_to {
let period = transport.period_ms().max(1);
let behind =
(u64::from(after) + u64::from(period) - u64::from(before)) % u64::from(period);
@@ -934,10 +941,14 @@ mod tests {
progress_ms: on_azeroth,
at: Instant::now(),
};
let sample = transport
let (sample, moved_to) = transport
.reseek_to_map(&mut anchor, 1)
.expect("path 241 visits Kalimdor");
assert_eq!(sample.map, 1, "the re-anchored sample must be on Kalimdor");
assert!(
moved_to.is_some(),
"a clock on Azeroth's leg told it is on Kalimdor must report that it moved"
);
assert_eq!(
transport.sample(anchor.progress_ms, 0).map,
1,
@@ -951,7 +962,7 @@ mod tests {
progress_ms: on_kalimdor,
at: Instant::now(),
};
let sample = transport
let (sample, moved_to) = transport
.reseek_to_map(&mut anchor, 1)
.expect("path 241 visits Kalimdor");
assert_eq!(sample.map, 1);
@@ -959,6 +970,14 @@ mod tests {
anchor.progress_ms, on_kalimdor,
"an agreeing clock must not be touched"
);
// **And it must SAY it did nothing.** The stored anchor is the server's uptime-scale path
// progress until a seek replaces it, so a caller cannot infer "unchanged" by comparing it
// against a cycle value — it reported a 9.7-hour raw progress as a 157 s clock skew until
// this flag existed.
assert_eq!(
moved_to, None,
"a no-op seek must report that it did not move the clock"
);
// A map the path never visits has no answer — the caller warns rather than inventing one.
let mut anchor = TransportAnchor {
+11 -1
View File
@@ -587,7 +587,17 @@ pub(super) fn plugin(app: &mut App) {
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
save_on_session_end,
// **Explicitly before the session ender, because the edge is not an ordering.** This
// flush composes the file out of the DYING VM, and `end_ui_session` replaces that VM
// with a fresh boot one on the same edge. Two unconstrained systems there are placed
// by the executor, and that placement was measured (bevy 0.18, five systems on one
// `OnExit`) to move with nothing but their registration positions — a VM-reading
// saver ran after the exclusive ender in one arrangement and before it in another.
// Winning that race by luck is what B353 cost the layout cache one module over; the
// layout cache answered it by joining the reference's shutdown tail, which this file
// cannot do (its reference is the type-7 chat-cache saver `0x499a80`, not
// `0x490bd0`'s tail), so it says the order instead.
save_on_session_end.before(crate::ui_script::end_ui_session),
);
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
+85 -46
View File
@@ -40,16 +40,42 @@
//! docked windows share one level. It joins the file the day a window can be raised for good —
//! the honest-tree rule (1134 §4), the same one the chat-look file applies next door.
//!
//! ## The write posture — [`crate::ui_chat`]'s `settings`, verbatim
//! ## The write posture — the reference's shutdown tail, plus a debounce it has not got
//!
//! Dirty flag keyed to the VM ([`VmMemo`]), one quiet second, plus both session edges
//! (`OnExit(InWorld)` and `AppExit`). The VM key is the load-bearing part and it is one-way: the
//! geometry lives in the VM, so a plain `bool` surviving a VM replacement would let a save compose
//! the player's file out of a fresh tree that has no user-placed frame in it at all — i.e. wipe it.
//! A fresh VM starts undirty and cannot write until a drag writes.
//! **The reference writes this file in exactly one place**: step three of the UI shutdown's
//! ordered tail `0x490bd0` — `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` (`0x490c2a`) →
//! **`layout-cache.txt` (`0x490c79`)** → the flat saved file (`0x490c7e`) → the per-addon files →
//! `AddOns.txt` → the frame teardown. It rides the same five lifecycle roots as SavedVariables —
//! logout to the character screen, quit, disconnect, application exit and **`/reload`** — and
//! there is no autosave, no dirty bit and no per-frame write (wow-5875-re `system/ui/ui.md`;
//! `scratch/camera-settings-persistence.md` §4).
//!
//! A drag is a slider-shaped gesture, so the debounce matters for the same reason it does there:
//! a resize writes on every mouse-move, and the quiet second coalesces the lot into one file write.
//! Ours takes that slot: [`save_now`], called from [`crate::ui_script::shutdown_ui_state`]
//! between `PLAYER_LOGOUT` and the flat file. **That is the load-bearing part, and it is what
//! B353 was.** The tail is ONE function called from every root, which is the only way a
//! `/reload` gets a write at all: a reload never leaves `InWorld` — `run_pending_reload` calls
//! the shutdown and the rebuild back to back — so a saver hung off `OnExit(InWorld)` is invisible
//! to it, and an unlocked chat window came back on its authored anchors after every `/reload`
//! with `benilla-config/layout/` never created. The same edge is *also* not an ordering: two
//! unconstrained systems on one state edge are placed by the executor, and measured here (bevy
//! 0.18, five systems on one `OnExit`) the placement moves with nothing but the registration
//! positions — a saver that needs the dying VM ran *after* the exclusive session-ender in one
//! arrangement and before it in another. So this module's `OnExit` saver was reading a VM it was
//! racing for. The tail cannot race: it runs inside `end_ui_session`, ahead of the replacement,
//! by construction. That is the mistake [`crate::ui_script::shutdown_ui_state`]'s own doc says
//! the tail exists to prevent, made once more one module over.
//!
//! **The one thing we keep that the reference has not got is a debounced autosave** —
//! [`save_layout`], dirty flag keyed to the VM ([`VmMemo`]), one quiet second. The reference has
//! no crash-path write either (its exception filter reaches no writer) and we would rather not
//! lose a session's windows to one; a drag is a slider-shaped gesture, so the quiet second
//! coalesces a resize's per-mouse-move writes into one. It is a *second* writer of the same
//! file, never the only one: every orderly end goes through the tail.
//!
//! The VM key on the dirty flag is one-way and load-bearing: the geometry lives in the VM, so a
//! plain `bool` surviving a VM replacement would let a save compose the player's file out of a
//! fresh tree that has no user-placed frame in it at all — i.e. wipe it. A fresh VM starts
//! undirty and cannot write until a drag writes.
use std::path::PathBuf;
@@ -179,11 +205,15 @@ pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
/// Seat the player's saved geometry into the VM, once per character per VM.
///
/// `pub(crate)` so `ui_script::world_entry_tests` can run it against a real post-reload VM:
/// the file half of the loop is only worth anything if a window actually comes BACK, and
/// that is the half a test of `render`/`parse` alone cannot see.
///
/// Runs in `Update` under `InWorld`, which is *after* the UI tree is built and after decision
/// 0272's load-time `UIParent_ManageFramePositions()` bootstrap — both of which matter: the frames
/// have to exist to be looked up by name, and the managed pass has to have had its say first,
/// because from here on it skips these frames (`IsUserPlaced`, `UIParent.xml`).
fn load_layout(
pub(crate) fn load_layout(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut file: ResMut<LayoutFile>,
@@ -239,18 +269,15 @@ fn watch_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFil
file.last_change = Some(std::time::Instant::now());
}
/// Dirty + one quiet second (or the app exiting) → rewrite the file atomically.
fn save_layout(
script: Option<NonSendMut<UiScript>>,
mut file: ResMut<LayoutFile>,
mut exits: MessageReader<AppExit>,
) {
let exiting = exits.read().next().is_some();
/// Dirty + one quiet second → rewrite the file atomically. **The crash writer, and only that**
/// (see the module doc): every orderly end — logout, disconnect, quit, `/reload` — is the
/// shutdown tail's, so this never has to reason about an exit message or an edge.
fn save_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
let Some(script) = script else { return };
if !*file.dirty.get(&script) {
return;
}
if !(exiting || file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET)) {
if !file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET) {
return;
}
let Some(path) = file.path.clone() else {
@@ -266,42 +293,54 @@ fn save_layout(
*file.dirty.get(&script) = false;
}
/// `OnExit(InWorld)` — a `/logout` back to the glue, or a disconnect. The same edge the camera pose,
/// the chat look and the saved variables flush on, and it must not wait for the quiet second.
fn save_on_session_end(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
let Some(script) = script else { return };
if !*file.dirty.get(&script) {
return;
/// **The reference's own step of the UI shutdown** (`0x490c79`, between `PLAYER_LOGOUT` and the
/// flat saved file): write the player's layout cache from the live VM, now. Called from
/// [`crate::ui_script::shutdown_ui_state`], so it reaches every root the tail does — logout,
/// disconnect, quit and `/reload` — which is what an `OnExit(InWorld)` system could not (module
/// doc, and B353).
///
/// It takes the **identity the tail is holding** rather than reading [`LayoutFile`], for the
/// reason [`crate::ui_script::AddOnIdentity`] exists at all: that is the character the UI
/// actually loaded under, remembered because the roster's pick can be gone by the time the
/// shutdown runs. It is the same `(realm, character)` [`load_layout`] built its path from —
/// both are [`crate::ui_macro::identity`] of the same roster — so the tail writes back to the
/// file the load read.
///
/// Unconditional, like every other resident of the tail: the file is composed whole from the
/// live tree, so a session that placed nothing writes back what it restored, and a session that
/// un-placed a window writes the row's absence. The one guard is the tail's own — a session
/// whose in-game UI never loaded does not run it, so a UI-less VM cannot answer with its
/// emptiness.
pub(crate) fn save_now(script: &UiScript, identity: Option<&(String, String)>) {
let Some((realm, character)) = identity else {
return; // no character loaded under — a capture, a scenario, a test world
};
let Some(path) = crate::local_state::layout_character_path(realm, character) else {
return; // hermetic capture, or no state folder — session-only
};
let body = render(&script.user_placed_layouts());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
warn!("layout: cannot write {}: {e}", path.display());
}
if let Some(path) = file.path.clone() {
let body = render(&script.user_placed_layouts());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
warn!("layout: cannot write {}: {e}", path.display());
}
}
*file.dirty.get(&script) = false;
}
/// The layout cache's plugin — the chat look's shape one store over.
/// The layout cache's plugin. **Not the chat look's shape any more** (2029): that module keeps
/// its own session edge because its reference is the type-7 chat-cache saver `0x499a80`;
/// this file's reference is `0x490bd0`'s ordered tail, so its every orderly write lives in
/// [`save_now`] and the plugin has no edge left to own.
pub(crate) struct UiLayoutPlugin;
impl Plugin for UiLayoutPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LayoutFile>()
.add_systems(
Update,
(load_layout, watch_layout)
.chain()
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
save_on_session_end,
);
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
// watcher would lose the last second of a drag to the process ending.
crate::shutdown::on_app_exit(app, save_layout.into_configs());
// **One chain, and no session edges of its own.** Restore, watch, and the crash-only
// debounce; every orderly end is [`crate::ui_script::shutdown_ui_state`]'s [`save_now`],
// which is the reference's own single write site and the only one a `/reload` reaches.
app.init_resource::<LayoutFile>().add_systems(
Update,
(load_layout, watch_layout, save_layout)
.chain()
.run_if(in_state(crate::char_select::ClientState::InWorld)),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1878,6 +1878,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
cameras: 0,
},
);
s.resolve();
@@ -485,6 +485,7 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
cameras: 0,
},
);
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
@@ -486,6 +486,27 @@ fn quad_summary(q: Option<&UiQuad>) -> String {
)
}
/// **Hand the VM the host's font engine** — the one construction site for
/// [`crate::ui_text::AtlasMeasurer`], so a `SetText` → `GetStringWidth` pair *inside one Lua call*
/// returns a real number instead of 0 (the reference answers that getter inline, `0x79e510` →
/// `0x772890`).
///
/// `seam` is the raster seam the atlas answers under ([`super::seam_scale`]): a measurer built
/// under one seam does not answer for another, which is why both callers re-seat rather than
/// check first.
///
/// Two callers, and the second one is the point (decision 2028): this pass, at the seam edge and
/// on the first frame the atlas exists — and [`super::lifecycle::load_ingame_ui_on_world_entry`],
/// **before the manifest loads**, because that is where every `<OnLoad>` in the in-game UI runs.
/// Seated only from here, a VM born and loaded inside one exclusive system — which is exactly
/// what `ReloadUI()` does — runs its whole load edge measuring 0.
pub(crate) fn seat_text_measurer(script: &mut UiScript, atlas: &UiFontAtlas, seam: f32) {
script.set_text_measurer(Box::new(crate::ui_text::AtlasMeasurer::new(
atlas.engine(),
seam,
)));
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
/// drain to the log (throttled by being drained — each fires once).
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
@@ -621,13 +642,11 @@ pub(super) fn drive_script(
//
// Seated BEFORE the tick below, so the first update that runs already has it, and rebuilt only
// on the seam edge or the frame the atlas first exists — an `Arc` clone and an `f32`, never a
// per-frame cost.
// per-frame cost. This is the *frame* edge; the *load* edge is
// [`super::lifecycle::load_ingame_ui_on_world_entry`]'s own call to the same seat (2028).
if let Some(atlas) = font_atlas.as_deref() {
if seam_moved || !script.has_text_measurer() {
script.set_text_measurer(Box::new(crate::ui_text::AtlasMeasurer::new(
atlas.engine(),
s,
)));
seat_text_measurer(&mut script, atlas, s);
}
}
// Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost
@@ -1327,6 +1346,9 @@ fn convert_entry(
position,
own_alpha,
icon,
camera,
light,
fog,
} => {
use crate::portrait::PortraitSource;
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
@@ -1359,6 +1381,18 @@ fn convert_entry(
star_px_per_unit: 768.0 * diag * s * dpi,
facing,
position: Vec3::new(position.0, position.1, position.2),
// The PERSPECTIVE leg's root (decision 2027), which is in model units,
// not pixels: `T(pos · layoutScale) · R(facing) · S(s)` with
// `s = G48·(5/3)·modelScale·layoutScale` — and `G48·(5/3)` is exactly
// `√((4/3)²+1)/√(a²+1)`, the 4:3 renormalizer (camera-law §11.2). The
// camera is carried through the same matrix, so both terms cancel for
// framing; they are here because the record's near/far are NOT scaled
// with them, and because the geometry has to be drawn somewhere.
root_scale: (5.0 / 3.0) / diag * model_scale * layout,
root_pos: Vec3::new(position.0, position.1, position.2) * layout,
camera,
light,
fog,
icon: icon.clone(),
rect,
z_key: eq.z,
@@ -17,6 +17,24 @@ fn setup() -> UiScript {
s
}
/// One `/who` row as the app's feed would have resolved it.
fn who(name: &str, level: u32, class: &str, zone: &str) -> WhoInfo {
WhoInfo {
name: name.to_string(),
guild: String::new(),
level,
race: "Human".to_string(),
class: class.to_string(),
zone: zone.to_string(),
}
}
/// The name painted into who row `row`.
fn who_name(s: &UiScript, row: u32) -> String {
s.eval::<String>(&format!("return WhoFrameButton{row}Name:GetText()"))
.unwrap()
}
fn friend(name: &str, level: u32, class: &str, area: &str, connected: bool) -> FriendInfo {
FriendInfo {
name: name.to_string(),
@@ -437,6 +455,60 @@ fn the_who_dropdown_switches_the_variable_column() {
);
}
/// **B365's retest, pinned.** The Who list's column headers sort, the same header clicked twice
/// REVERSES, and the earlier click survives as a tie-breaker — the reference's seven-slot chain
/// (`SortWho 0x5ad890`, decision 2030).
///
/// What makes this the *window's* test rather than the chain's: every assertion reads the painted
/// row straight after `Click()`, with **no feed tick in between**. That can only pass if
/// `WHO_LIST_UPDATE` fires synchronously from inside the binding, the way `SignalEvent` does — the
/// half of the bug the director could see, since our old sort redrew a tick later and only ever
/// ascended.
#[test]
fn the_who_headers_sort_and_a_repeated_click_reverses() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
push(
&mut s,
SocialState {
who: vec![
who("Galas", 60, "Warrior", "Elwynn Forest"),
who("Erdrin", 12, "Mage", "Elwynn Forest"),
],
who_total: 2,
..Default::default()
},
"WHO_LIST_UPDATE",
);
let _ = s.take_social_requests();
assert_eq!(who_name(&s, 1), "Galas", "the server's order, unsorted");
// Header 1 is Name (`FriendsFrame.xml:1313`).
s.run("WhoFrameColumnHeader1:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Erdrin", "Name, ascending");
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SortWho("name".to_string())],
"and the app hears the click too"
);
s.run("WhoFrameColumnHeader1:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Galas", "the same header again reverses");
// Header 3 is Level (`:1394`) — ascending puts the level 12 first.
s.run("WhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Erdrin", "Level, ascending");
s.run("WhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Galas", "and Level reverses too");
// Back to Name: promoted from behind, it keeps the descending direction it was left in
// rather than flipping — the chain's memory, not a per-click toggle.
s.run("WhoFrameColumnHeader1:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Galas", "Name, still descending");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The who buttons need a selected row, and selecting one enables both. A fresh answer clears
/// the selection — row 3 of the last query is not row 3 of this one.
#[test]
@@ -248,6 +248,26 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
);
}
/// Install the host's font engine into `script` for the CURRENT raster seam, if the glyph atlas
/// exists yet — the load edge's half of [`super::extract::seat_text_measurer`].
///
/// No atlas means no measure to be had (it bakes on the first `Update`, from the patch chain and
/// the window's real `scale_factor`); the per-frame pass seats one on the first frame it appears,
/// exactly as before.
fn seat_text_measurer_for_load(world: &mut World, script: &mut UiScript) {
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let h = {
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world).map_or(0.0, Window::height)
};
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
return;
};
super::extract::seat_text_measurer(script, atlas, super::seam_scale(h, ui_scale));
}
/// Materialize the in-game UI for **this** session.
///
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
@@ -311,6 +331,16 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
.get_resource::<crate::cvars::CvarPersist>()
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
});
// **The VM's font engine, before the first `<OnLoad>` runs** (decision 2028). Every file the
// walk below loads may measure the text it just set — the era's own tab law is
// `label:GetStringWidth() + 40` at OnLoad, and the addon corpus writes the same pair — and a
// `GetStringWidth` with no measurer installed answers 0. Seated only from the per-frame pass
// (`extract::drive_script`, an `Update` system), a VM that is BORN and LOADED inside one
// exclusive `PreUpdate` slot never sees it: that is exactly `ReloadUI()`, which mints a fresh
// boot VM in `end_ui_session` and calls straight into here, so every `/reload` measured 0
// through its whole load edge and only converged a frame later off whatever poll the caller
// had written to survive it.
seat_text_measurer_for_load(world, &mut script);
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
@@ -448,12 +478,24 @@ pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
/// flat write and the `AddOns.txt` write were exactly that.
///
/// **The layout cache is step three, and it was missing until B353.** The quote above has always
/// carried `layout-cache.txt`; the body skipped it, because [`crate::ui_layout`] had hung its own
/// saver off `OnExit(InWorld)` instead. Two things follow from being outside the tail, and the
/// bug report is both of them: a `/reload` never leaves `InWorld` ([`run_pending_reload`] calls
/// this function and the rebuild back to back), so that saver never ran on the root a player uses
/// most; and on the roots where it did run it was racing [`end_ui_session`]'s VM replacement on
/// the same unordered edge — measured (bevy 0.18) to move with nothing but registration
/// positions. In the tail it is neither: one call, ahead of the replacement, on every root.
/// (It is also the one step that keeps a writer *outside* this function: a debounced
/// crash-save, which the reference has not got and which the next paragraph is not about.)
///
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
/// a few times a session, and every file is written whole from the live globals.
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
script.fire_event("PLAYER_LOGOUT", vec![]);
crate::ui_layout::save_now(script, identity);
crate::ui_saved::save(script);
addons::save_addon_variables(script, identity);
addons::save_enable_state(script, identity);
@@ -499,6 +499,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
ModelFileFacts {
sequences: vec![seq(127, 1333, false), seq(0, 833, true), seq(1, 333, false)],
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
},
);
assert!(
@@ -3964,3 +3964,114 @@ fn the_action_bars_page_toggles_the_real_bars() {
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 70.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The window tabs (decision 2028) ─────────────────────────────────────────────────────────────
/// A measurer that models the one property `FixedWidthFont` throws away: **glyph advances step to
/// whole physical pixels**, so a label is not proportionally the same width at two scales. Six
/// units per character at the DRAWN raster size (`6 × scale`, rounded), divided back by the scale
/// — the shape `ui_text::measurer` really has, small enough to predict by hand.
struct SteppedFont(f32);
impl benilla_ui::script::TextMeasure for SteppedFont {
fn measure(&mut self, req: &benilla_ui::script::MeasureRequest) -> (f32, f32, f32) {
let per_glyph = (self.0 * req.scale).round();
let natural = req.text.chars().count() as f32 * per_glyph / req.scale;
(natural, 12.0, natural)
}
}
/// **Both tabs fit their labels once per show** — the era's width law (`MinimalTab.lua` l.7:
/// label + 40) on the 1.12 tab template's own seat (`<OnShow>` → `PanelTemplates_TabResize`,
/// decision 1993), with no OnUpdate poll behind it and no `fitted` latch. The engine's measurer
/// answers `GetStringWidth` inside the Lua call that asks it, so one call is the whole fit.
///
/// **The seat is a show and not the era's OnLoad, and that is what the numbers below pin.**
/// `GetStringWidth` answers in the region's OWN units, so its number depends on the owner's
/// effective scale — and this window's scale is set by `OptionsFrame_UpdateScale` in the window's
/// own `<OnShow>`, after every `<OnLoad>` has already run. At OnLoad these tabs measure at scale 1
/// (64 and 76 here); on the show, behind the window's own OnShow, they measure at the drawn 0.78.
#[test]
fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
let mut s = UiScript::new().unwrap();
s.set_text_measurer(Box::new(SteppedFont(6.0)));
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.resolve();
let num = |s: &mut UiScript, expr: &str| -> f32 { s.eval(&format!("return {expr}")).unwrap() };
assert_eq!(
num(&mut s, "OptionsFrame:GetScale()"),
0.78,
"ERA_WINDOW_SCALE"
);
// The law, against each tab's own live measure.
for tab in ["OptionsFrameGameTab", "OptionsFrameAddOnsTab"] {
let w = num(&mut s, &format!("{tab}:GetWidth()"));
let l = num(&mut s, &format!("{tab}Text:GetStringWidth()"));
assert!(
(w - (l + 40.0)).abs() < 0.01,
"{tab} is {w} wide; the era's law is its label ({l}) + 40"
);
}
// …and the numbers themselves, which say WHEN it ran. At 0.78 a 6-unit glyph rasterizes at 5
// physical px and reads back 5/0.78 units, so "Game" (4) is 25.64 and "AddOns" (6) is 38.46.
// An OnLoad fit — before the window's OnShow set the scale — would have measured at 1 and left
// these at 64 and 76, which is 3.3 and 15.5 units off the drawn label.
let close = |a: f32, b: f32| (a - b).abs() < 0.01;
assert!(close(
num(&mut s, "OptionsFrameGameTab:GetWidth()"),
65.641_03
));
assert!(close(
num(&mut s, "OptionsFrameAddOnsTab:GetWidth()"),
78.461_54
));
// Nothing re-fits them afterwards: the same two numbers with four frames run, which is the
// value the retired OnUpdate settle used to converge to and latch.
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
assert!(close(
num(&mut s, "OptionsFrameGameTab:GetWidth()"),
65.641_03
));
assert!(close(
num(&mut s, "OptionsFrameAddOnsTab:GetWidth()"),
78.461_54
));
// A re-show re-fits rather than latching — the stock template's own behaviour, and what lets
// a tab follow the window's scale when the screen changes under it.
s.run("HideUIPanel(OptionsFrame) ShowUIPanel(OptionsFrame)")
.unwrap();
s.resolve();
assert!(close(
num(&mut s, "OptionsFrameGameTab:GetWidth()"),
65.641_03
));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The falsifier: **with no measurer installed**, the same one-shot measures 0 and each tab comes
/// out 40 wide — the law's padding and nothing else. That is the engine-less truth (`benilla-ui`
/// `script/measure.rs`: absent is a supported state, metrics stay 0 until the host's batch
/// round-trip fills them), and it is why the fit is a *shown* frame's job: by then the app has
/// seated `AtlasMeasurer` for certain, whatever order the boot took.
#[test]
fn without_a_seated_measurer_the_same_fit_reads_zero() {
let mut s = harness_on(UiScript::new().unwrap());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.resolve();
for tab in ["OptionsFrameGameTab", "OptionsFrameAddOnsTab"] {
let w: f32 = s.eval(&format!("return {tab}:GetWidth()")).unwrap();
assert!(
(w - 40.0).abs() < 0.01,
"{tab} is {w} wide, not the bare 40"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+45 -30
View File
@@ -51,10 +51,12 @@ fn shipped_gossip_frame_drives_end_to_end() {
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -256,10 +258,12 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -419,10 +423,12 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -478,8 +484,9 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
.collect();
s.set_measured_text_unwrapped(&answers);
};
// Frame 1: the labels are measured (their heights land for the NEXT tick — the round-trip is a
// frame late, exactly as the tab-fit and quest-panel resizes already live with).
// Frame 1: the labels are measured (their heights land for the NEXT tick — this harness drives
// the host's BATCH round-trip by hand, which is a frame late by construction; the app's own
// synchronous measurer closes the same loop inside `resolve`).
answer_measures(&mut s);
s.resolve();
// Frame 2: the frame's own settle pass reads those measures and sizes each row.
@@ -537,10 +544,12 @@ fn gossip_show_hide_plays_open_and_close_kits() {
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -583,10 +592,12 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -678,10 +689,12 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -852,10 +865,12 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
// Before BankFrame, not after: its close and purchase buttons inherit UIPanelCloseButton
// and UIPanelButtonTemplate, and an `inherits=` is resolved at LOAD.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
@@ -51,6 +51,7 @@ pub(super) fn cooldown_facts(s: &mut UiScript) {
ModelFileFacts {
sequences: vec![seq(0, 1000), seq(1, 1000)],
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
},
);
}
@@ -964,3 +964,198 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ── B353 · the layout cache is a resident of the shutdown tail ───────────────────────────────
//
// st1rk, 2026-09-01: *"Unlock a chat window (right-click tab → Unlock Window), resize or drag it,
// `/logout` or `/reload`, log back in. It's back at the original size. `benilla-config/layout/`
// isnt created."* The engine seam and the file round trip were already proven by
// [`crate::ui_script::chat_resize_tests::the_geometry_round_trips_through_the_save_file`]; what
// was wrong is which edge writes. [`crate::ui_layout`] hung its saver off `OnExit(InWorld)`, and a
// `/reload` never leaves `InWorld` — [`super::run_pending_reload`] calls the shutdown and the
// rebuild back to back — so on the root a player uses most nothing was written at all. These two
// drive the roots themselves, which is the only place that distinction is visible.
/// A window the player has placed, made the way a drag makes one: movable first, then the
/// userPlaced bit (`SetUserPlaced` refuses a frame that is neither movable nor resizable).
/// Parentless, so its anchor is the screen root — the file's `-` target — which is what lets this
/// need no FrameXML and no install.
fn place_a_window(world: &mut World) {
world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("a VM to place a window in")
.run(
"local f = CreateFrame(\"Frame\", \"B353Probe\") \
f:SetWidth(413) f:SetHeight(147) \
f:SetPoint(\"BOTTOMLEFT\", 61, 29) \
f:SetMovable(true) f:SetUserPlaced(true)",
)
.expect("place the probe window");
}
/// The layout cache the shutdown left behind for this character, if any.
fn layout_cache(character: &str) -> Option<String> {
let path = crate::local_state::layout_character_path("Realm", character)?;
std::fs::read_to_string(path).ok()
}
/// What a saved window's row has to say for the player to get it back.
fn assert_probe_row(text: &str) {
for want in [
"Frame: B353Probe",
"W: 413",
"H: 147",
"Point: BOTTOMLEFT - BOTTOMLEFT 61 29",
] {
assert!(
text.contains(want),
"the saved row is missing `{want}`:\n{text}"
);
}
}
/// **Logging out writes the window's geometry** — the tail's step three, on the root that leaves
/// the world.
#[test]
fn a_placed_window_is_written_at_logout() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-logout");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
assert!(
layout_cache("Onehunter").is_none(),
"nothing is written while the session is running"
);
super::end_ui_session(&mut world);
assert_probe_row(&layout_cache("Onehunter").expect("the logout wrote the layout cache"));
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **`/reload` writes it too — B353 itself.** The reload root never leaves `InWorld`, so it is
/// exactly the root an `OnExit(InWorld)` saver cannot see: pre-fix this finds no file at all, and
/// the player's unlocked chat window comes back on its authored anchors.
#[test]
fn a_placed_window_is_written_at_reload() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-reload");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
reload(&mut world, crate::char_select::ClientState::InWorld);
assert_probe_row(&layout_cache("Onehunter").expect(
"the reload wrote the layout cache — it runs the shutdown tail without a state edge",
));
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// The file is **per character**, and the tail writes back to the one the UI loaded under: a
/// second character's logout must not answer with the first one's windows.
#[test]
fn each_character_gets_its_own_layout_cache() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-two-chars");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
super::end_ui_session(&mut world);
log_in_as(&mut world, "Onewarrior", 2);
super::end_ui_session(&mut world);
assert_probe_row(&layout_cache("Onehunter").expect("the first character's file"));
let second = layout_cache("Onewarrior").expect("the second character's file");
assert!(
!second.contains("B353Probe"),
"a character who placed nothing must not inherit another's window:\n{second}"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// A window as FrameXML would author it — the shape the restore has to overwrite. Same name as
/// [`place_a_window`]'s, different geometry, and **not** user-placed: this is the fresh tree a
/// relog meets.
fn author_a_window(world: &mut World) {
world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("a VM to author a window in")
.run(
"local f = CreateFrame(\"Frame\", \"B353Probe\") \
f:SetWidth(100) f:SetHeight(100) \
f:SetPoint(\"BOTTOMLEFT\", 0, 0) \
f:SetMovable(true)",
)
.expect("author the probe window");
}
/// The probe window's live geometry, as the player sees it.
fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) {
world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("a VM")
.eval::<(f32, f32, String, f32, f32)>(
"local p, _, _, x, y = B353Probe:GetPoint(1) \
return B353Probe:GetWidth(), B353Probe:GetHeight(), p, x, y",
)
.expect("read the probe window back")
}
/// **The whole loop, on the root that reported it** — st1rk's retest, in one test: place a window,
/// `/reload`, meet a fresh tree that has it on its authored anchors, and let the loader seat the
/// saved geometry back over the top.
///
/// [`crate::ui_layout::load_layout`] is run directly because this harness drives the world's edges
/// rather than its schedules; the authored window stands in for the FrameXML the real reload
/// rebuilds.
#[test]
fn a_placed_window_comes_back_after_a_reload() {
use bevy::ecs::system::RunSystemOnce;
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-roundtrip");
let mut world = booted_world();
world.init_resource::<crate::ui_layout::LayoutFile>();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
let placed = window_geometry(&world);
reload(&mut world, crate::char_select::ClientState::InWorld);
author_a_window(&mut world);
assert_ne!(
window_geometry(&world),
placed,
"the rebuilt tree starts on its authored anchors — otherwise this proves nothing"
);
world
.run_system_once(crate::ui_layout::load_layout)
.expect("the layout loader ran");
assert_eq!(
window_geometry(&world),
placed,
"the window the player placed is back where they left it"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -65,6 +65,7 @@ fn arrow_facts(s: &mut UiScript) {
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
cameras: 0,
},
);
}
+205 -33
View File
@@ -28,6 +28,21 @@ const WHO_LIST_GUILD_FORMAT: &str = "|Hplayer:%s|h[%s]|h: Level %d %s %s <%s> -
const WHO_NUM_RESULTS: &str = "%d player total";
const WHO_NUM_RESULTS_P1: &str = "%d players total";
/// The chat-frame threshold on an answer the Who frame did not claim. `SMSG_WHO`'s parser
/// (`0x5adf60`) computes one **print flag** before its record loop (`0x5adf9c`–`0x5adfd5`) and
/// that flag gates both halves: with `SetWhoToUI` set the event always fires; with it clear,
/// `0x5adfca cmp eax,ecx` / `0x5adfcc jl` — signed, `eax = 3` — sends **more than three** rows to
/// `WHO_LIST_UPDATE` (the frame holds them for whenever it opens, printing nothing) and three or
/// fewer to the chat frame, with no event. Four hits go to the frame; three go to chat. The
/// threshold is the literal `3`: the pointer that could override it (`[[0xc2a128]+0x28]`) has one
/// reference image-wide and no writer.
///
/// **`ecx` is the RAW wire display count** (`[ebp-0x14]`), not the 50-capped global the cap at
/// `0x5adf92` writes — a re-implementation must not test its own clamped count and call it the
/// same rule. Ours is [`SocialState::who`]'s length, which is the wire's count uncapped (vmangos
/// sends at most 49), so the two agree. wow-re `who-list-sort-law.md` §11.3, decision 2030.
const WHO_CHAT_MAX: usize = 3;
/// What the feed last announced, so the Era events fire on edges rather than every frame.
#[derive(Default)]
pub(super) struct FedSocial {
@@ -60,7 +75,7 @@ pub(super) fn feed_social(
let (friends, display_order) = friend_rows(&social, &mut names, &commands, areas);
let (ignores, ignore_order) = ignore_rows(&social, &mut names, &commands);
let who: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
let who = who_rows(&social, areas);
let selected_friend = index_of(&display_order, social.selected_friend);
let selected_ignore = index_of(&ignore_order, social.selected_ignore);
@@ -74,6 +89,9 @@ pub(super) fn feed_social(
selected_ignore,
who,
who_total: social.who_total,
// The chain rides along because `SortWho` promotes and re-sorts inside the binding — see
// its comment in `benilla_ui::script::social`.
who_sort: social.who_sort.clone(),
});
// The three list events (`FriendsFrame_OnEvent`'s own arms). FRIENDLIST_SHOW is the answer to
@@ -93,17 +111,32 @@ pub(super) fn feed_social(
social.ignores_dirty = false;
script.fire_event("IGNORELIST_UPDATE", Vec::new());
}
// `SMSG_WHO`'s two exits ([`WHO_CHAT_MAX`]). Note the event is the answer's *only* announcement
// — `SortWho` fires its own, synchronously, from inside the binding — so a sort no longer
// re-announces the list a tick later (decision 2030).
if social.who_dirty {
social.who_dirty = false;
if social.who_to_ui {
if answer_goes_to_the_frame(social.who_to_ui, social.who.len()) {
script.fire_event("WHO_LIST_UPDATE", Vec::new());
} else {
// The frame isn't listening — the engine prints the results instead.
push_who_lines(&social, &mut chat_log, areas);
// Nobody is holding the list — the engine prints the results itself, **in wire
// order**: the per-record line is composed inside the parse loop (`0x5ae0a1`, one
// call per record) and the `qsort` at `0x5ae0e2` sits past the loop's back-edge, so
// it reorders only the array `GetWhoInfo` reads, never these lines.
let wire_order: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
for line in who_lines(&wire_order, social.who_total) {
system_line(&mut chat_log, line);
}
}
}
}
/// Which exit an `SMSG_WHO` answer takes: `true` fires `WHO_LIST_UPDATE` and prints nothing,
/// `false` prints the chat lines and fires nothing ([`WHO_CHAT_MAX`]).
fn answer_goes_to_the_frame(to_ui: bool, shown: usize) -> bool {
to_ui || shown > WHO_CHAT_MAX
}
/// Compose and push every result line whose name has resolved. A line needing a name waits (the
/// reference's resolve-then-compose order); one that doesn't goes out immediately.
fn drain_result_lines(
@@ -134,18 +167,20 @@ fn drain_result_lines(
social.pending_lines = still_pending;
}
/// The chat-routed `/who` output: the total line, then one line per row (module doc's four
/// engine-only templates).
fn push_who_lines(social: &SocialState, chat_log: &mut ChatLog, areas: Option<&AreaTableCatalog>) {
let total = social.who_total;
/// The chat-routed `/who` output (module doc's four engine-only templates): one line per row **in
/// the order given**, and the `WHO_NUM_RESULTS` total **last**.
///
/// The order is the parser's, not a presentation choice: `0x5ae0a1` composes a record's line
/// inside the loop that reads it, and the summary block `0x5ae0f1`–`0x5ae12a` sits after the
/// loop's back-edge (wow-re `who-list-sort-law.md` §11.4).
fn who_lines(rows: &[WhoInfo], total: u32) -> Vec<String> {
let template = if total == 1 {
WHO_NUM_RESULTS
} else {
WHO_NUM_RESULTS_P1
};
system_line(chat_log, template.replace("%d", &total.to_string()));
for entry in &social.who {
let row = who_row(entry, areas);
let mut lines = Vec::with_capacity(rows.len() + 1);
for row in rows {
let template = if row.guild.is_empty() {
WHO_LIST_FORMAT
} else {
@@ -167,8 +202,10 @@ fn push_who_lines(social: &SocialState, chat_log: &mut ChatLog, areas: Option<&A
line = replace_first_token(&line, &row.guild);
}
line = replace_first_token(&line, &row.zone);
system_line(chat_log, line);
lines.push(line);
}
lines.push(template.replace("%d", &total.to_string()));
lines
}
/// Replace the first `%s` or `%d` in `line` with `fill` — the templates interleave both, so a
@@ -261,6 +298,20 @@ fn ignore_rows(
rows.into_iter().map(|(guid, name)| (name, guid)).unzip()
}
/// The `/who` rows, resolved and then ordered by the sort chain — the reference's own
/// `qsort 0x5ae0e2` on every fresh answer, plus whatever a header click promoted since.
///
/// Recomputed from the wire mirror rather than kept sorted in place, because the comparator's
/// three DBC arms compare **resolved names** (`ChrClasses`/`ChrRaces`/`AreaTable`), which only
/// exist on this side of [`who_row`]. The result is the same either way: the chain always ends
/// with the name key somewhere in it, and no two rows of one answer share a name, so the order is
/// total — re-deriving it per frame lands on the identical list.
fn who_rows(social: &SocialState, areas: Option<&AreaTableCatalog>) -> Vec<WhoInfo> {
let mut rows: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
social.who_sort.sort(&mut rows);
rows
}
/// One `/who` row, ids resolved. Note the Lua API returns race *before* class while the wire
/// carries class first — the swap happens here, once.
fn who_row(entry: &WhoEntry, areas: Option<&AreaTableCatalog>) -> WhoInfo {
@@ -378,10 +429,11 @@ pub(super) fn drain_social(
request: Box::new(request),
});
}
SocialRequest::SortWho(sort_type) => {
social.who_sort = sort_type;
sort_who(&mut social);
}
// The click the binding already applied to the VM's copy of the chain, applied to
// ours — so the next push agrees. No `who_dirty`: `SortWho` fired `WHO_LIST_UPDATE`
// synchronously, inside the binding, and the reference fires it exactly once per
// click (decision 2030).
SocialRequest::SortWho(sort_type) => social.who_sort.promote(&sort_type),
SocialRequest::SetWhoToUi(on) => social.who_to_ui = on,
}
}
@@ -405,26 +457,146 @@ fn guid_named(order: &[u64], name: &str, names: &NameCache) -> Option<u64> {
})
}
/// Re-order the who results in place. Client-side, like the reference's own comparator — the
/// server sends them in whatever order its player map iterates.
fn sort_who(social: &mut SocialState) {
let key = social.who_sort.clone();
match key.as_str() {
"level" => social.who.sort_by_key(|e| e.level),
"class" => social.who.sort_by_key(|e| e.class),
"race" => social.who.sort_by_key(|e| e.race),
"zone" => social.who.sort_by_key(|e| e.zone),
"guild" => social.who.sort_by(|a, b| a.guild.cmp(&b.guild)),
// "name" and anything unrecognised: alphabetical, the list's resting order.
_ => social.who.sort_by(|a, b| a.name.cmp(&b.name)),
}
social.who_dirty = true;
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::friend_status;
use benilla_protocol::messages::{friend_status, WhoResults};
fn entry(name: &str, level: u32, guild: &str) -> WhoEntry {
WhoEntry {
name: name.to_string(),
guild: guild.to_string(),
level,
class: 1,
race: 1,
zone: 12,
}
}
fn names(rows: &[WhoInfo]) -> Vec<&str> {
rows.iter().map(|r| r.name.as_str()).collect()
}
/// **B365, app side.** A fresh `SMSG_WHO` answer is presented in the sort chain's order, not
/// the server's — the reference qsorts inside the parse itself (`0x5ae0e2`), so a list sorted
/// by level stays sorted by level when the next `/who` lands. And a header click's intent
/// re-orders the *next* push without needing a fresh answer.
#[test]
fn the_answer_is_presented_in_the_sort_chains_order() {
let mut social = SocialState::default();
social.apply_who(WhoResults {
displayed: 3,
total: 3,
entries: vec![
entry("Galas", 60, ""),
entry("erdrin", 12, ""),
entry("Bruk", 60, ""),
],
});
// The seeded chain is zone → level → class → group → name → …: one zone and one class
// here, so it resolves on level, then name.
assert_eq!(
names(&who_rows(&social, None)),
["erdrin", "Bruk", "Galas"],
"wire order was Galas, erdrin, Bruk"
);
// A Name click: ascending, and case-folded — `erdrin` sorts among the capitals, not after
// them as a byte compare would put it.
social.who_sort.promote("name");
assert_eq!(names(&who_rows(&social, None)), ["Bruk", "erdrin", "Galas"]);
// The same click again reverses.
social.who_sort.promote("name");
assert_eq!(names(&who_rows(&social, None)), ["Galas", "erdrin", "Bruk"]);
// A fresh answer arrives into that same chain.
social.apply_who(WhoResults {
displayed: 2,
total: 2,
entries: vec![entry("Aaa", 1, ""), entry("Zzz", 1, "")],
});
assert_eq!(names(&who_rows(&social, None)), ["Zzz", "Aaa"]);
}
/// The two exits of the `SMSG_WHO` parse. The threshold is what makes a broad `/who` typed
/// with the window shut go quiet instead of dumping fifty lines into the chat frame.
#[test]
fn a_small_answer_goes_to_chat_and_a_large_one_to_the_frame() {
assert!(!answer_goes_to_the_frame(false, 0));
assert!(!answer_goes_to_the_frame(false, 3), "three still print");
assert!(answer_goes_to_the_frame(false, 4), "four go to the frame");
// With the frame open it is always the frame's, however few hits there are.
for shown in 0..=4 {
assert!(answer_goes_to_the_frame(true, shown));
}
}
/// The chat lines come out in **wire** order with the total **last** — and they are the one
/// half of the who list the sort chain does not touch, however hard that reads as an
/// inconsistency.
///
/// The reference composes a record's line inside the loop that parses it (`0x5ae0a1`), and
/// the `qsort` that orders the array sits past that loop's back-edge (`0x5ae0e2`) — so the
/// lines are already gone by the time anything is sorted, and only the array `GetWhoInfo`
/// reads is ordered. Sorting them "for consistency" is the plausible wrong answer, and it was
/// this client's until wow-re read the call site (`who-list-sort-law.md` §11.4).
#[test]
fn the_chat_lines_come_out_in_wire_order_with_the_total_last() {
let mut social = SocialState::default();
social.apply_who(WhoResults {
displayed: 2,
total: 2,
entries: vec![entry("Zzz", 60, ""), entry("Aaa", 12, "")],
});
// A chain that would order them the other way round, to prove it is not consulted.
social.who_sort.promote("name");
assert_eq!(
names(&who_rows(&social, None)),
["Aaa", "Zzz"],
"the frame's order"
);
let wire: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, None)).collect();
let lines = who_lines(&wire, social.who_total);
assert_eq!(lines.len(), 3, "one per row plus the total: {lines:?}");
assert!(
lines[0].contains("Zzz"),
"wire order, not the chain's: {lines:?}"
);
assert!(lines[1].contains("Aaa"), "{lines:?}");
assert_eq!(lines[2], "2 players total", "the summary comes last");
}
/// The comparator's "missing DBC row" marker is the **empty** name, and it must stay that
/// way: `who_row` leaves an unresolvable class/race/zone empty, and the chain ties on it
/// rather than ordering it (wow-re §11.1 — the arm jumps to the loop's `inc esi`).
///
/// **This test is a tripwire.** The reference's `GetWhoInfo 0x5ad6e0` substitutes the
/// localized `"UNKNOWN"` on those same three legs (§11.2), so the two sides deliberately
/// disagree: the cell reads UNKNOWN and the row sorts as if the column were not there. If
/// this client ever adopts that substitution — it should; ours shows an empty cell today —
/// the miss has to travel to the comparator by some other route than the string, or the tie
/// silently becomes an alphabetical sort on the word "Unknown".
#[test]
fn an_unresolvable_id_leaves_the_name_empty_for_the_comparator() {
let row = who_row(
&WhoEntry {
name: "Nobody".into(),
guild: String::new(),
level: 1,
class: 200,
race: 200,
zone: 999_999,
},
None,
);
assert_eq!(
(row.class.as_str(), row.race.as_str(), row.zone.as_str()),
("", "", "")
);
}
/// A row index maps back through the *shown* order, and 0/past-the-end map to nothing — the
/// guard that keeps a stale click from removing a bystander.
+48 -2
View File
@@ -100,8 +100,10 @@ pub(crate) struct SocialState {
/// The last `/who` answer, and the server's true match total.
who: Vec<benilla_protocol::messages::WhoEntry>,
who_total: u32,
/// The current who-list sort key (`SortWho`), `"zone"` being the frame's own default column.
who_sort: String,
/// The `/who` sort chain (`SortWho`'s seven `{key, dir}` slots at `0xc2817c`) — the app's
/// authoritative copy, pushed to the VM with the rows so the binding can promote and re-sort
/// synchronously. **Survives a logout** ([`SocialState::clear_session`]).
who_sort: benilla_ui::script::WhoSortChain,
/// `SetWhoToUI` — does the *next* `/who` answer belong to the Who frame (true) or the chat
/// frame (false)? The WhoFrame's OnShow/OnHide drive it; a `/who` typed with the frame closed
/// prints its results as chat lines.
@@ -119,6 +121,21 @@ pub(crate) struct SocialState {
}
impl SocialState {
/// Drop everything the socket owned, keeping what the *process* owns.
///
/// Only [`Self::who_sort`] survives, and it survives because the reference's chain is
/// per-process: its initialiser `0x5adc50` is reached once from the process-start run at
/// `0x401666`, never from a login, so a player who left the who list sorted by level
/// descending finds it that way after a relog (wow-re `who-list-sort-law.md` §3). Everything
/// else is login-scoped for the reasons decision 0668 gives — the server re-pushes both lists
/// at the next login, and a stale ignore list would silence the wrong guids.
pub(crate) fn clear_session(&mut self) {
*self = Self {
who_sort: std::mem::take(&mut self.who_sort),
..Self::default()
};
}
/// Is `guid` on the ignore list? The reference's `FriendList::IsIgnored 0x5ae5a0` — the
/// predicate inbound chat, text emotes and duel challenges all gate on (module doc).
pub(crate) fn is_ignored(&self, guid: u64) -> bool {
@@ -450,6 +467,35 @@ mod tests {
assert_eq!(result_template(0x77), None, "an unknown code shows nothing");
}
/// A logout drops the lists and the last `/who`, and keeps the **sort chain** — which is
/// per-process in the reference, not per-login, so a player who left the who list sorted by
/// level descending finds it that way after a relog.
#[test]
fn a_logout_keeps_the_sort_chain_and_drops_everything_else() {
let mut social = SocialState::default();
social.apply_friend_list(vec![FriendEntry {
guid: 7,
..Default::default()
}]);
social.apply_ignore_list(vec![9]);
social.who_to_ui = true;
social.who_sort.promote("level");
social.who_sort.promote("level"); // descending
let chain = social.who_sort.clone();
social.clear_session();
assert!(social.friends.is_empty());
assert!(social.ignores.is_empty());
assert!(social.who.is_empty());
assert!(!social.who_to_ui, "the frame is closed after a logout");
assert_eq!(social.who_sort, chain, "the sort chain is process state");
assert_ne!(
social.who_sort,
benilla_ui::script::WhoSortChain::default(),
"and the assertion above only means something if it is not the seeded chain"
);
}
/// The online line takes the name twice (the player link); the rest take it once.
#[test]
fn the_online_line_fills_the_name_twice() {
+68
View File
@@ -17,6 +17,8 @@
//! References use `.mdx`/`.mdl`, but the physical archive file is `.m2`; callers map the extension when
//! forming the `mpq://…m2` load path (the loader registers for `m2`).
use std::sync::Arc;
use benilla_formats::{
hand_grip_finger_poses, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
parse_m2_collision_hull, parse_m2_global_sequence_bones, parse_m2_lights,
@@ -118,6 +120,13 @@ pub struct M2Model {
/// model with fewer than two cameras, where the client synthesizes a *fixed* camera instead
/// (transcribed at the framing site). Same Bevy-space conversion as `portrait_camera`.
pub pane_camera: Option<PortraitCamera>,
/// The **whole camera table**, in file order — the raw index space a `<Model>` widget's
/// `SetCamera(n)` walks (decision 2027). `camera0` and `pane_camera` above are the two fixed
/// indices two other paths take; this is the general one, and it is the only one that can
/// answer "how many cameras does this file have", which is the question that decides whether
/// a pane renders through the perspective leg at all (an index past the count installs a NULL
/// camera and the pane falls back to the orthographic leg).
pub cameras: Vec<PaneCamera>,
/// A bow's `$WTT`/`$WTB` bowstring anchors (wow-re `nocked-ammo-cancel.md` §G2), baked to Bevy
/// space: `[top, bottom]` as `(bone index, model-local position)` — the two limb-tip points the
/// engine-drawn string spans. `None` for every non-bow model.
@@ -143,6 +152,11 @@ pub struct M2SequenceInfo {
pub seq_index: usize,
/// `end − start` on the file's timeline, ms.
pub duration_ms: u32,
/// The band's **start** on the file's global timeline, ms (`M2Sequence+0x04`) — what a
/// per-sequence cursor is added to before an absolute-timeline track is sampled. The camera
/// tracks are the one consumer: their keys are absolute like every other M2 track's, while a
/// pane's play head is a cursor inside the armed band.
pub start_ms: u32,
/// Loops (flag bit 0 clear); a clamped sequence holds its last frame.
pub looping: bool,
}
@@ -166,6 +180,49 @@ pub struct PortraitCamera {
pub far: f32,
}
/// One record of the file's camera table, by **raw index** — see [`M2Model::cameras`].
///
/// The rest rig ([`Self::still`]) is Bevy space like every other camera on this asset; the
/// authored tracks stay raw WoW space behind [`Self::at`], which does the conversion, so there is
/// no mixed-convention field to misread.
#[derive(Clone)]
pub struct PaneCamera {
/// The record's `type` word — `0` portrait, `1` "characterinfo", `-1` on the fly-bys and the
/// six glue scenes. Carried, never selected on: the widget's index is raw.
pub camera_type: i32,
/// The rig at rest (bases + each track's first key), Bevy space. The whole answer for every
/// model a `<Model>` pane can name — wow-re's census over the composite's 9691 `.m2` found
/// every one of those cameras keying a single `(0,0,0)` on all three tracks.
pub still: PortraitCamera,
/// The authored tracks, raw WoW space, kept only when one of them actually moves — the
/// `Cameras\*.m2` fly-bys. Read through [`Self::at`].
tracks: Option<Arc<benilla_formats::M2CameraTracks>>,
}
impl PaneCamera {
/// The rig at absolute file-timeline `ms`, Bevy space — [`Self::still`] for a static camera,
/// the cubically sampled tracks for a moving one (`benilla_m2::M2Track::sample_ms`, the
/// reference's own four-way `interp` dispatch).
///
/// `wow_to_bevy` is linear, so converting the summed `base + track(t)` is the same as
/// converting each — which is why the tracks can stay raw behind this one door.
pub fn at(&self, ms: u32) -> PortraitCamera {
let Some(t) = self.tracks.as_deref() else {
return self.still;
};
let add = |b: [f32; 3], v: Option<[f32; 3]>| {
let v = v.unwrap_or([0.0; 3]);
wow_to_bevy([b[0] + v[0], b[1] + v[1], b[2] + v[2]])
};
PortraitCamera {
eye: add(t.position_base, t.positions.sample_ms(ms)),
target: add(t.target_base, t.target.sample_ms(ms)),
roll: t.roll.sample_ms(ms).unwrap_or(self.still.roll),
..self.still
}
}
}
/// The **billboard frame** an emitter's bone chain reaches — the host bone's arm, its pivot in raw
/// WoW model space, and **which bone it is**. See [`ModelEmitter::billboard`] for when it is `Some`
/// and why the emitter's live origin is then `pivot + camBasis·(def.position − pivot)`.
@@ -535,6 +592,7 @@ impl AssetLoader for M2ModelLoader {
anim_id: a.anim_id,
seq_index: a.seq_index,
duration_ms: a.end_ms.saturating_sub(a.start_ms),
start_ms: a.start_ms,
looping: a.looping,
})
.collect();
@@ -805,6 +863,15 @@ impl AssetLoader for M2ModelLoader {
let portrait_camera = parse_m2_portrait_camera(&bytes).map(to_bevy);
let camera0 = benilla_formats::parse_m2_camera(&bytes, 0).map(to_bevy);
let pane_camera = benilla_formats::parse_m2_camera(&bytes, 1).map(to_bevy);
// The whole table, raw index — the `<Model>` widget's `SetCamera(n)` space (2027).
let cameras: Vec<PaneCamera> = benilla_formats::parse_m2_pane_cameras(&bytes)
.into_iter()
.map(|c| PaneCamera {
camera_type: c.camera_type,
still: to_bevy(c.still),
tracks: c.tracks.map(|t| Arc::new(*t)),
})
.collect();
// The bowstring anchors (bows only): raw WoW positions → Bevy, the meshes' frame.
let string_anchors = benilla_formats::parse_m2_string_anchors(&bytes).map(|a| {
@@ -840,6 +907,7 @@ impl AssetLoader for M2ModelLoader {
portrait_camera,
camera0,
pane_camera,
cameras,
string_anchors,
cch_marker,
global_flags,
@@ -1536,3 +1536,60 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> {
}
Ok(())
}
/// Dump an M2's **camera table**, in raw file-index order — the index space a `<Model>` widget's
/// `Model:SetCamera(n)` walks (decision 2027; the selection is raw, `cameraLookup` is not
/// consulted there, so the table position IS the answer and the record's `type` is not).
///
/// Prints, per record: its `type` word, the eye and look-at target at rest (`base + key 0`, raw
/// WoW model space), the diagonal fov in radians and degrees, near/far, the roll, and the key
/// count of each of the three tracks — the last column being the one that says whether the camera
/// is a still rig (every character/creature/interface camera in the chain) or an authored path
/// (the `Cameras\*.m2` fly-bys). The `cameraLookup` table is printed beside it, because the
/// portrait bake selects through it and the pane does not, and confusing the two is a one-line
/// mistake with a wrong picture at the end of it.
pub fn m2cam(chain: &mut Chain, internal_path: &str) -> Result<()> {
let name = normalize(internal_path);
let data = chain
.read_file(&name)
.with_context(|| format!("reading '{name}' from chain"))?;
let cams = benilla_formats::parse_m2_pane_cameras(&data);
let lookup = benilla_m2::parse_camera_lookup(&data);
println!("{name}: {} camera(s), cameraLookup {lookup:?}", cams.len());
if cams.is_empty() {
return Ok(());
}
println!(
"idx type eye target fov(rad/deg) near far roll keys p/t/r"
);
for (i, c) in cams.iter().enumerate() {
let s = &c.still;
let keys = c.tracks.as_deref().map_or([1, 1, 1], |t| {
[
t.positions.keys.len(),
t.target.keys.len(),
t.roll.keys.len(),
]
});
println!(
"{i:>3} {:>4} ({:>8.4},{:>8.4},{:>8.4}) ({:>8.4},{:>8.4},{:>8.4}) {:.5}/{:>6.2} {:>7.4} {:>7.3} {:>5.3} {}/{}/{}{}",
c.camera_type,
s.position[0],
s.position[1],
s.position[2],
s.target[0],
s.target[1],
s.target[2],
s.fov,
s.fov.to_degrees(),
s.near_clip,
s.far_clip,
s.roll,
keys[0],
keys[1],
keys[2],
if c.tracks.is_some() { " ANIMATED" } else { "" },
);
}
Ok(())
}
@@ -172,6 +172,15 @@ enum Command {
/// Internal path to the `.m2` (forward or back slashes accepted).
internal_path: String,
},
/// Dump an M2's **camera table** by raw file index — the index space a `<Model>` widget's
/// `Model:SetCamera(n)` walks (decision 2027): type, rest eye/target, diagonal fov, near/far,
/// roll, and each track's key count (which says still rig vs authored path). The
/// `cameraLookup` table is printed beside it — the portrait bake selects through that, the
/// pane does not.
M2cam {
/// Internal path to the `.m2` (forward or back slashes accepted).
internal_path: String,
},
/// Dump an M2's animation EVENT keyframes per sequence (`$CSS`/`$CAH`/`$AH0-3`/`$CPP`/`$HIT`…,
/// time + payload) — the event-order instrument (decision 0279: whether `$CPP` precedes the
/// impact tag decides defense-anim vs flinch on the shared swing record).
@@ -892,6 +901,7 @@ fn main() -> Result<()> {
}
Command::Glueextent { batches } => glueextent::glueextent(&mut chain, batches)?,
Command::M2coll { internal_path } => m2dump::m2coll(&mut chain, &internal_path)?,
Command::M2cam { internal_path } => m2dump::m2cam(&mut chain, &internal_path)?,
Command::M2seq { internal_path } => m2dump::m2seq(&mut chain, &internal_path)?,
Command::M2events { internal_path } => m2dump::m2events(&mut chain, &internal_path)?,
Command::M2attach { internal_path } => m2dump::m2attach(&mut chain, &internal_path)?,
+16 -15
View File
@@ -243,21 +243,22 @@ pub use models::{
owner_last_rung, owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
rotation_2x2, shipped_glue_art_extent, uv_transform, wmo_group_doodad_refs,
wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim,
AlphaMap, AlphaSeq, AnimEvent, ArtExtent, BatchFootprint, Billboard, BillboardKind, BoneKeys,
BoneScaleAnim, BoneSpin, CharSkinSlot, CollisionMesh, Coverage, CoverageReader,
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera,
ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim,
ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass,
WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo,
WmoPortalRef, WmoPortals, WmoRoot, ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT,
GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_pane_cameras,
parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes,
parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights,
parse_wmo_portals, parse_wmo_root, rotation_2x2, shipped_glue_art_extent, uv_transform,
wmo_group_doodad_refs, wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header,
wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes,
wmo_root_id, AlphaAnim, AlphaMap, AlphaSeq, AnimEvent, ArtExtent, BatchFootprint, Billboard,
BillboardKind, BoneKeys, BoneScaleAnim, BoneSpin, CharSkinSlot, CollisionMesh, Coverage,
CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks,
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone,
StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
OWNER_RUNG_BUCKETS,
};
mod terrain;
pub use terrain::{
@@ -429,3 +429,125 @@ mod tests {
assert_eq!(bone_z_axis(&vec![0u8; 0x400], 0), [0.0, 0.0, 1.0]);
}
}
/// One record of the file's **camera table**, by RAW table index — what `Model:SetCamera(n)`
/// selects on a `<Model>` widget.
///
/// The selection is **raw**: `0x76cec0` reads the count off `MD20+0x124` and the record at
/// `[model+0x3c4] + idx·0x84 + 0x80`, and **`cameraLookup` is not consulted** on that path (wow-re
/// `ui/scratch/modelframe-camera-law.md` §2.1 — the sibling pair that *does* consult it,
/// `0x713500`/`0x713540`, has its one call site at the portrait bake). So the index decides and
/// [`Self::camera_type`] never does; it is carried because a reader will otherwise assume the
/// opposite.
///
/// [`Self::still`] is the rest rig — the record's bases plus each track's first key. That is the
/// whole answer for every model a `<Model>` pane can name: wow-re's census over all 9691 `.m2` of
/// the composite found every character/creature/interface camera's position/target/roll track
/// carrying exactly one key of `(0,0,0)`/`0` (§7). [`Self::at`] is the general case — the
/// `Cameras\*.m2` fly-bys, whose Bézier paths a widget would sample like any other track.
#[derive(Debug, Clone)]
pub struct M2PaneCamera {
/// The record's `type` word (`+0x00`): `0` = portrait, `1` = "characterinfo", `-1` on every
/// shipped fly-by and on the six glue scenes. Never selected on — see the type docs.
pub camera_type: i32,
/// The rig at rest: bases + each track's first key, raw WoW model space.
pub still: M2PortraitCamera,
/// The authored tracks, kept **only when one of them actually moves** (more than one distinct
/// key) — otherwise `still` is the camera at every instant and there is nothing to sample.
pub tracks: Option<Box<M2CameraTracks>>,
}
/// A moving camera's three authored tracks plus their bases — the publish pass's inputs
/// (`0x718960` `[0x718b60, 0x718c3a)`): `eye = position_base + positions(t)`,
/// `target = target_position_base + target(t)`, `roll = roll(t)`.
#[derive(Debug, Clone)]
pub struct M2CameraTracks {
pub positions: benilla_m2::M2Vec3SplineTrack,
pub position_base: [f32; 3],
pub target: benilla_m2::M2Vec3SplineTrack,
pub target_base: [f32; 3],
pub roll: benilla_m2::M2ScalarSplineTrack,
}
impl M2PaneCamera {
/// The rig at absolute file-timeline `ms` — [`Self::still`] for a static camera, the sampled
/// tracks for a moving one. Raw WoW model space, radians.
///
/// The sampler is [`benilla_m2::M2Track::sample_ms`], the reference's own four-way cubic
/// `interp` dispatch (STEP/LINEAR/BÉZIER/HERMITE) with both ends clamped — the fly-bys author
/// BÉZIER. `ms` is the model's animation clock, which for a widget pane is its private scene
/// clock resolved through the armed sequence's band (the pane's play head plus the band start).
pub fn at(&self, ms: u32) -> M2PortraitCamera {
let Some(t) = self.tracks.as_deref() else {
return self.still;
};
let add = |b: [f32; 3], v: Option<[f32; 3]>| {
let v = v.unwrap_or([0.0; 3]);
[b[0] + v[0], b[1] + v[1], b[2] + v[2]]
};
M2PortraitCamera {
position: add(t.position_base, t.positions.sample_ms(ms)),
target: add(t.target_base, t.target.sample_ms(ms)),
roll: t.roll.sample_ms(ms).unwrap_or(self.still.roll),
..self.still
}
}
}
/// Parse the whole camera table, in file order — the raw index space `Model:SetCamera(n)` walks.
/// Empty for the overwhelming majority of models (9225 of the composite's 9691).
///
/// This is the table read of [`parse_m2_camera`]'s single-record reduction; both go through
/// [`benilla_m2::parse_cameras`], which is the one place the `0x7c` record layout is written down.
pub fn parse_m2_pane_cameras(bytes: &[u8]) -> Vec<M2PaneCamera> {
benilla_m2::parse_cameras(bytes)
.into_iter()
.map(|cam| {
let key0 = |t: &benilla_m2::M2Vec3SplineTrack| {
t.keys.first().map_or([0.0; 3], |(_, k)| k.value)
};
let base_plus = |b: [f32; 3], t: &benilla_m2::M2Vec3SplineTrack| {
let k = key0(t);
[b[0] + k[0], b[1] + k[1], b[2] + k[2]]
};
let still = M2PortraitCamera {
fov: cam.fov,
far_clip: cam.far_clip,
near_clip: cam.near_clip,
position: base_plus(cam.position_base, &cam.positions),
target: base_plus(cam.target_base, &cam.target),
roll: cam.roll.keys.first().map_or(0.0, |(_, k)| k.value),
};
// A track that never leaves its first key is the still rig at every instant; keeping
// it would cost a sample per frame to reproduce a constant. Every shipped
// character/creature/interface camera lands here (wow-re camera-law §7).
let moves = |n: usize, same: bool| n > 1 && !same;
let v3_moves = |t: &benilla_m2::M2Vec3SplineTrack| {
let first = key0(t);
moves(t.keys.len(), t.keys.iter().all(|(_, k)| k.value == first))
};
let roll_moves = {
let first = cam.roll.keys.first().map_or(0.0, |(_, k)| k.value);
moves(
cam.roll.keys.len(),
cam.roll.keys.iter().all(|(_, k)| k.value == first),
)
};
let tracks =
(v3_moves(&cam.positions) || v3_moves(&cam.target) || roll_moves).then(|| {
Box::new(M2CameraTracks {
positions: cam.positions,
position_base: cam.position_base,
target: cam.target,
target_base: cam.target_base,
roll: cam.roll,
})
});
M2PaneCamera {
camera_type: cam.camera_type,
still,
tracks,
}
})
.collect()
}
+26
View File
@@ -56,6 +56,32 @@ impl Loader<'_> {
}
}
}
// The model pane's own fog attributes and `<FogColor>` child (`CSimpleModel::LoadXML`
// `0x76cac0`, render law §5.4). `fogNear`/`fogFar` are **clamped at `≥ 0`** here and only
// here (`76cbbb`-`76cbd2` / `76cbf3`-`76cc0a`: `0.0 fcomp value ; jne store ; else store
// 0.0`) — the Lua setters store raw. The `<FogColor>` child writes the packed colour AND
// arms the fog bit, so it is `SetFogColor` in every respect; nothing in XML touches the
// light. Decision 2027.
if model_kind {
for (attr, verb) in [("fogNear", "SetFogNear"), ("fogFar", "SetFogFar")] {
if let Some(v) = el.attr(attr).and_then(|v| v.trim().parse::<f32>().ok()) {
self.call(wrapper, verb, v.max(0.0), dbg);
}
}
if let Some(fc) = children_named(el, "FogColor").next() {
let ch = |k: &str, d: f32| {
fc.attr(k)
.and_then(|v| v.trim().parse::<f32>().ok())
.unwrap_or(d)
};
self.call(
wrapper,
"SetFogColor",
(ch("r", 0.0), ch("g", 0.0), ch("b", 0.0), ch("a", 1.0)),
dbg,
);
}
}
if let Some(id) = el.attr("id") {
if let Ok(n) = id.parse::<i64>() {
self.call(wrapper, "SetID", n, dbg);
+3
View File
@@ -127,6 +127,9 @@ impl UiScript {
position: m.position,
own_alpha: frame.map_or(1.0, |f| f.alpha),
icon: m.icon.clone(),
camera: m.camera,
light: m.light,
fog: m.armed_fog(),
},
_ => QuadContent::Frame,
};
+4
View File
@@ -152,6 +152,9 @@ mod trainer;
mod types;
mod unit;
mod weapon_enchant;
/// The `/who` list's seven-key sort chain and its comparator — its header is the whole
/// mechanism, including why a repeated header click reverses (decision 2030).
mod who_sort;
mod worldmap;
mod worldstate;
mod worn_display;
@@ -268,6 +271,7 @@ pub use unit::{
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
};
pub use weapon_enchant::WeaponEnchant;
pub use who_sort::{WhoSortChain, WhoSortKey};
pub use worldmap::{
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
WorldMapZoneView,
+177 -14
View File
@@ -94,7 +94,7 @@ use mlua::{Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::Model;
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelState};
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelLight, ModelState};
impl Model {
/// FrameXML units per **layout unit** — `768 · √(a²+1)` for the screen's aspect `a`
@@ -232,6 +232,103 @@ fn int(v: &Value) -> i32 {
num(v) as i32
}
/// `lua_isnumber` — a number, or a string that converts, and **nothing else**. Distinct from
/// [`num`] on purpose: `SetLight` *raises* on a non-number where the coordinate setters coerce,
/// so the two questions cannot share one helper.
fn number(v: &Value) -> Option<f32> {
match v {
Value::Number(n) => Some(*n as f32),
Value::Integer(i) => Some(*i as f32),
Value::String(s) => s.to_str().ok().and_then(|t| t.trim().parse().ok()),
_ => None,
}
}
/// `[0x8029d4]` — the client's "is this float zero" epsilon, shared by `SetLight`'s intensity
/// gate, `0x71b6a0`'s direction normalise and the paint's degenerate-rect test.
const FLOAT_EPS: f32 = 2.384_185_8e-7;
/// `Model:SetLight`'s usage string (`0x878c00`), which the binding raises verbatim.
const SET_LIGHT_USAGE: &str = "Usage: Model:SetLight(enabled[, omni, dirX, dirY, dirZ, \
ambIntensity[, ambR, ambG, ambB], dirIntensity[, dirR, dirG, dirB]]";
/// What [`parse_set_light`] decided.
enum SetLight {
/// A non-number where the binding requires one — `luaL_error(SET_LIGHT_USAGE)`.
Raise,
/// **Trap 1.** `enabled == 0` returns at `76e2cb` *before* the local light is copied into the
/// widget, so the call writes nothing at all: it neither disables a light nor edits one.
NoOp,
/// The light to copy wholesale over the widget's (`76e777 0x76cf30`, `rep movsd 0x1b`).
Set(ModelLight),
}
/// `Model:SetLight` `0x76e1e0`'s argument walk (render law §5.3), over the arguments **after
/// `self`** — so `a[0]` is the reference's Lua index 2.
///
/// The binding builds a **local** `CGLight` from `0x71b4a0` — type 1, every colour ZERO, which is
/// not the widget ctor's white — fills it, and copies the whole thing over the widget's at the
/// end. So an omitted colour block lands the *binding's* black, never the ctor's white.
///
/// **Trap 2** is the walking cursor: the ambient colour triple is read only when its intensity is
/// nonzero **and** all three components are numbers; when either fails, the colour stays white
/// and the cursor does *not* advance past it — so the second intensity is read at index 8 rather
/// than 11, and `SetLight(1, 0, x, y, z, ambI, dirI)` is a legal seven-argument form.
fn parse_set_light(a: &[Value]) -> SetLight {
let Some(enabled) = a.first().and_then(number) else {
return SetLight::Raise;
};
if enabled as i32 == 0 {
return SetLight::NoOp;
}
// Indices 3..=7 — omni, x, y, z, ambIntensity — are all mandatory once the light is enabled.
let mut head = [0.0f32; 5];
for (i, slot) in head.iter_mut().enumerate() {
match a.get(i + 1).and_then(number) {
Some(v) => *slot = v,
None => return SetLight::Raise,
}
}
let omni = head[0] as i32 != 0;
let mut vector = [head[1], head[2], head[3]];
if !omni {
// `0x71b6a0` normalises a DIRECTION on write (`71b6c5`-`71b705`); the position setter
// `0x71b650` stores raw.
let len = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
if len > FLOAT_EPS {
vector = vector.map(|v| v / len);
}
}
// The colour blocks: each packed to 8 bits per channel and unpacked again (`0x76f900`), so a
// Set→Get round trip is lossy exactly the way `SetFogColor`'s is.
let quantize = |v: f32| ((v.clamp(0.0, 1.0) * 255.0).round()) / 255.0;
let block = |at: usize| -> Option<[f32; 3]> {
let c: Vec<f32> = (at..at + 3)
.filter_map(|k| a.get(k).and_then(number))
.collect();
(c.len() == 3).then(|| [quantize(c[0]), quantize(c[1]), quantize(c[2])])
};
let ambient_i = head[4];
let (ambient_rgb, next) = match block(6).filter(|_| ambient_i.abs() > FLOAT_EPS) {
Some(rgb) => (rgb, 9), // the cursor advanced: Lua index 11
None => ([1.0; 3], 6), // white, and the cursor stayed: Lua index 8
};
let mut light = ModelLight {
enabled: true,
omni,
vector,
ambient: ambient_rgb.map(|c| c * ambient_i),
diffuse: [0.0; 3],
};
if let Some(diffuse_i) = a.get(next).and_then(number) {
let rgb = block(next + 1)
.filter(|_| diffuse_i.abs() > FLOAT_EPS)
.unwrap_or([1.0; 3]);
light.diffuse = rgb.map(|c| c * diffuse_i);
}
SetLight::Set(light)
}
/// **Shape A's gate** (decision 1717's taxonomy, for a *string* position): the client's
/// `lua_isstring` accepts tags 3|4 — a string **or a number** — and nothing else. A number is
/// rendered to decimal text and used as the path, which is why this coerces rather than matching
@@ -366,11 +463,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetModelScale",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.scale))?,
)?;
// `SetCamera(n)` = `0x76e0e0` -> `0x76cec0`: select the file's camera by **RAW table index**
// (`cameraLookup` is not on this path). With the file's facts in hand the index is resolved
// now — an index past the table's count installs the NULL camera, which is the orthographic
// leg; without them it is deferred, and the pane draws nothing until it resolves.
m.set(
"SetCamera",
lua.create_function(|lua, (this, c): (Table, Value)| {
let c = int(&c);
with_model(lua, &this, |m| m.camera = c)
let facts = facts_of_pane(lua, &this)?;
with_model(lua, &this, |m| m.install_camera(c, facts.as_deref()))
})?,
)?;
m.set(
@@ -390,9 +492,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ── The scene: light and fog ────────────────────────────────────────────────────────────
//
// `SetLight`'s numbers are stored VERBATIM and handed back verbatim. The engine core has no
// lighting model, so typing this tuple would be asserting a scene semantics nobody has
// verified — and a wrong typing is worse than an opaque one, because it reads as knowledge.
// Typed since decision 2027, off the render law's §5.1-§5.4: the widget's embedded `CGLight`
// and the four fog fields, with `SetLight`'s argument walk and both of its traps.
m.set(
"SetLight",
lua.create_function(|lua, args: MultiValue| {
@@ -401,19 +502,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a Model")),
};
let nums: Vec<f32> = it.map(|v| num(&v)).collect();
with_model(lua, &this, |m| m.light = Some(nums))
let a: Vec<Value> = it.collect();
match parse_set_light(&a) {
SetLight::Raise => Err(mlua::Error::runtime(SET_LIGHT_USAGE)),
SetLight::NoOp => with_model(lua, &this, |_| ()),
SetLight::Set(l) => with_model(lua, &this, |m| m.light = l),
}
})?,
)?;
m.set(
"GetLight",
lua.create_function(|lua, this: Table| {
let light = with_model(lua, &this, |m| m.light.clone())?;
let out = light
.unwrap_or_default()
.into_iter()
.map(|n| Value::Number(f64::from(n)))
.collect::<Vec<_>>();
let l = with_model(lua, &this, |m| m.light)?;
let mut out: Vec<Value> = vec![
Value::Number(f64::from(u8::from(l.enabled))),
Value::Number(f64::from(u8::from(l.omni))),
Value::Number(f64::from(l.vector[0])),
Value::Number(f64::from(l.vector[1])),
Value::Number(f64::from(l.vector[2])),
];
// Each colour block comes back as `1.0, r, g, b` when ANY component is `> 0`, and as
// a lone `0` otherwise (`76e91f`/`76e92f`/`76e93f je 0x76e953` -> `push 1.0`) — which
// is what makes the arity 7 | 10 | 13 rather than a fixed 13.
for c in [l.ambient, l.diffuse] {
if c.iter().any(|&v| v > 0.0) {
out.push(Value::Number(1.0));
out.extend(c.iter().map(|&v| Value::Number(f64::from(v))));
} else {
out.push(Value::Number(0.0));
}
}
Ok(MultiValue::from_vec(out))
})?,
)?;
@@ -429,7 +547,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
let a = a.as_ref().map_or(1.0, num);
let packed = (q(a) << 24) | (q(num(&r)) << 16) | (q(num(&g)) << 8) | q(num(&b));
with_model(lua, &this, |m| m.fog_color = packed)
// `76f059 or [edi+0x3a4],1` — setting the colour ARMS the fog. That is the only
// arming verb (with the XML `<FogColor>` child); there is no `SetFogEnabled`.
with_model(lua, &this, |m| {
m.fog_color = packed;
m.fog = true;
})
},
)?,
)?;
@@ -444,6 +567,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `SetFogNear 0x76f1e0` / `SetFogFar 0x76f390` store the number RAW — no clamp, no ordering
// check (`76f282`/`76f432 fstp`); the `≥ 0` clamp exists only on the XML attribute path. The
// getters read the same fields back. Nothing here arms the fog.
m.set(
"SetFogNear",
lua.create_function(|lua, (this, v): (Table, Value)| {
let v = num(&v);
with_model(lua, &this, |m| m.fog_near = v)
})?,
)?;
m.set(
"GetFogNear",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog_near))?,
)?;
m.set(
"SetFogFar",
lua.create_function(|lua, (this, v): (Table, Value)| {
let v = num(&v);
with_model(lua, &this, |m| m.fog_far = v)
})?,
)?;
m.set(
"GetFogFar",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog_far))?,
)?;
// `ClearFog 0x76f540`: `76f5c5 and [edi+0x3a4],-2` — **bit 0 and nothing else**. The colour,
// the near and the far all survive, so a later `SetFogColor` re-arms the same ramp. A clear
// that also reset them would read as knowledge and be wrong.
m.set(
"ClearFog",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog = false))?,
)?;
// ── The two verbs that touch no pane state ──────────────────────────────────────────────
// `AdvanceTime()` — `0x878948[14]` -> `0x76eca0`. **It does nothing, and that is the verified
@@ -683,6 +839,13 @@ impl crate::script::UiScript {
};
let path = m.path.as_deref()?;
let facts = model.model_facts.get(&model_key(path))?;
// The reference's draw gate (`76d5f0 cmp [this+0x320],-1 ; jne`): a pane whose
// camera question is still open paints NOTHING — not the model, not its
// `OnUpdateModel`. Reachable from Lua by `SetCamera(n)` on a pane whose file has
// not landed yet (decision 2027).
if m.camera_pending.is_some() {
return None;
}
Some(ModelPaneFrame {
handle: h,
clock_ms: m.clock_ms,
+178 -9
View File
@@ -21,6 +21,8 @@
use mlua::{Lua, Value};
use super::binding_abi::string_arg;
use super::who_sort::WhoSortChain;
use super::Model;
/// One friend row, already resolved for display — see the module doc on why the app resolves
@@ -78,6 +80,13 @@ pub struct SocialState {
/// The last `/who` answer's *total* match count — `GetNumWhoResults`'s second return, which
/// can exceed `who.len()` and is what drives the "(50 displayed)" suffix.
pub who_total: u32,
/// The `/who` sort chain ([`WhoSortChain`]) as the app holds it. Pushed with the rows because
/// `SortWho` has to promote it and re-sort **inside the binding** — the reference's
/// `WHO_LIST_UPDATE` is synchronous, so the redraw it triggers reads the new order before the
/// click script returns, a tick before the app's own copy could have pushed it back.
/// [`super::SocialRequest::SortWho`] carries the same click to the app, whose next push then
/// agrees. Same shape as `SetSelectedFriend`'s (module doc).
pub who_sort: WhoSortChain,
}
/// Outbound social intents queued by the Era API, drained by the app
@@ -111,8 +120,10 @@ pub enum SocialRequest {
/// `SendWho(filter)` — the raw filter string as typed; parsing it into wire fields needs the
/// DBCs, so it happens app-side.
Who(String),
/// `SortWho(sortType)` — `"name"`/`"level"`/`"class"`/`"zone"`/`"guild"`/`"race"`. Sorting
/// is client-side; the app re-orders its own results and pushes them back.
/// `SortWho(sortType)` — `"name"`/`"level"`/`"class"`/`"zone"`/`"guild"`/`"race"`, the raw
/// argument as the click passed it. Sorting is client-side and the binding has **already**
/// done it to the snapshot ([`SocialState::who_sort`]); this carries the same click to the
/// app so its authoritative chain promotes identically and the next push agrees.
SortWho(String),
/// `SetWhoToUI(flag)` — where the *next* `/who` answer goes: the Who frame (true) or the chat
/// frame (false). The WhoFrame's own OnShow/OnHide drive it.
@@ -476,14 +487,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SortWho(sortType) — the column-header and dropdown sorts.
// `SortWho(sortType) 0x5ad890` — the column-header and dropdown sorts, and three things at
// once (wow-re `who-list-sort-law.md`; decision 2030):
//
// 1. **promote** the key into the seven-slot chain, flipping its direction only if it was
// already at the front — so a repeated click on the same header REVERSES;
// 2. **sort right here**, through the chain-walking comparator; and
// 3. fire `WHO_LIST_UPDATE` **synchronously**, inside the binding (`0x5ad9ed
// mov ecx,0x184; call SignalEvent 0x703e50`), so `FriendsFrame_OnEvent` has already
// re-read the list through `GetWhoInfo` by the time the header's OnClick plays its sound.
// Queueing it would redraw a tick late — the visible half of B365.
//
// The intent still goes to the app, which owns the same chain and re-sorts the answers it
// receives; sorting the snapshot here is what makes the synchronous redraw show the new
// order, exactly as `SetSelectedFriend` mutates the snapshot it also queues (module doc).
//
// Zero return values, and a non-string/number argument RAISES with the client's own typo
// (`.rdata 0x85db88`) rather than answering nil — `0x5ad898`'s `0x6f3510` guard into
// `luaL_error`, [`super::binding_abi`]'s shape A.
g.set(
"SortWho",
lua.create_function(|lua, sort_type: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.social_requests
.push(SocialRequest::SortWho(sort_type));
lua.create_function(|lua, sort_type: Value| {
let sort_type = string_arg(lua, sort_type, "Usgae: SortWho(\"type\")")?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let model = &mut *model;
model.social.who_sort.promote(&sort_type);
model.social.who_sort.sort(&mut model.social.who);
model
.social_requests
.push(SocialRequest::SortWho(sort_type));
}
super::tick::fire_event_into(lua, "WHO_LIST_UPDATE", Vec::new());
Ok(())
})?,
)?;
@@ -525,9 +560,143 @@ fn clamp_index(index: i64, len: usize) -> u32 {
#[cfg(test)]
mod tests {
use super::SocialRequest;
use super::{SocialRequest, SocialState, WhoInfo};
use crate::script::UiScript;
fn who(name: &str, level: u32, zone: &str) -> WhoInfo {
WhoInfo {
name: name.to_string(),
guild: String::new(),
level,
race: "Human".to_string(),
class: "Warrior".to_string(),
zone: zone.to_string(),
}
}
/// A VM seeded with two hits and a watcher that records the order it can SEE from inside the
/// `WHO_LIST_UPDATE` handler — which is the only vantage point that can tell a synchronous
/// fire from a queued one.
fn seated() -> UiScript {
let mut s = UiScript::new().unwrap();
s.run(
r#"
fired = 0
order = ""
local f = CreateFrame("Frame", "WhoWatcher")
f:RegisterEvent("WHO_LIST_UPDATE")
f:SetScript("OnEvent", function()
fired = fired + 1
order = ""
for i = 1, GetNumWhoResults() do
order = order .. GetWhoInfo(i) .. ","
end
end)
"#,
)
.unwrap();
s.set_social(SocialState {
who: vec![
who("Galas", 60, "Elwynn Forest"),
who("Erdrin", 12, "Elwynn Forest"),
],
who_total: 2,
..Default::default()
});
s
}
/// **B365.** `SortWho` sorts the list it already holds, fires `WHO_LIST_UPDATE`
/// **synchronously** so the handler reads the NEW order (`0x5ad9ed`, `SignalEvent 0x703e50`
/// running every listener inline), and queues the same click for the app. A queued event
/// would leave `order` one click stale here — which is exactly what the bug looked like.
#[test]
fn sort_who_sorts_in_place_and_fires_the_event_synchronously() {
let mut s = seated();
assert_eq!(s.eval::<i64>("return fired").unwrap(), 0);
s.run(r#"SortWho("name")"#).unwrap();
assert_eq!(s.eval::<i64>("return fired").unwrap(), 1);
assert_eq!(
s.eval::<String>("return order").unwrap(),
"Erdrin,Galas,",
"the handler must already see the sorted list"
);
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SortWho("name".into())],
"and the app hears the same click, so its copy of the chain follows"
);
// Zero return values (`0x5ad9f8 xor eax,eax; ret`).
assert_eq!(
s.eval::<i64>(r#"return select('#', SortWho("name"))"#)
.unwrap(),
0
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The visible half of the report: **clicking the same header twice reverses**, and clicking
/// a different one in between does not undo that — the direction is remembered per key and
/// flipped only when the key was already at the front of the chain.
#[test]
fn a_repeated_header_click_reverses_and_the_direction_is_remembered() {
let s = seated();
s.run(r#"SortWho("name")"#).unwrap();
assert_eq!(s.eval::<String>("return order").unwrap(), "Erdrin,Galas,");
s.run(r#"SortWho("name")"#).unwrap();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"Galas,Erdrin,",
"the second click on the same key reverses it"
);
// Level ascending puts Erdrin (12) first; name is still descending behind it.
s.run(r#"SortWho("level")"#).unwrap();
assert_eq!(s.eval::<String>("return order").unwrap(), "Erdrin,Galas,");
// Back to name: promoted from slot 1, so it CARRIES its descending direction rather than
// flipping to ascending.
s.run(r#"SortWho("name")"#).unwrap();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"Galas,Erdrin,",
"a key promoted from behind keeps the direction it was left in"
);
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
4,
"one fire per click"
);
}
/// The argument ABI: a number is stringified and falls through to the name key; anything that
/// is neither number nor string RAISES with the client's own misspelt usage string
/// (`.rdata 0x85db88`), abandoning the caller's statement rather than answering nil.
#[test]
fn sort_who_takes_the_reference_argument_abi() {
let mut s = seated();
s.run("SortWho(5)").unwrap();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"Erdrin,Galas,",
"an unrecognised key is the name key"
);
let _ = s.take_social_requests();
let err = s.run("SortWho({})").unwrap_err().to_string();
assert!(
err.contains(r#"Usgae: SortWho("type")"#),
"the reference's own typo, verbatim: {err}"
);
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
1,
"a raised call sorts nothing and fires nothing"
);
}
/// The LFG pair as the bytes define it (1961, correcting 1959): four string-or-nil returns,
/// the slots zeroed by the reference's own pack, the comment from argument 7 behind the gate
/// on argument 4, and the wire only on a change.
@@ -28,6 +28,7 @@ fn facts(rows: &[(u16, u32, bool)]) -> ModelFileFacts {
})
.collect(),
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
}
}
@@ -46,7 +47,7 @@ fn ping_facts() -> ModelFileFacts {
}
/// The pane's scene state, read through the arena (1.12 has no getter for any of it).
fn pane(s: &UiScript, name: &str) -> ModelState {
pub(super) fn pane(s: &UiScript, name: &str) -> ModelState {
let model = s.lua().app_data_ref::<Model>().expect("model");
let fh = model.arena.lookup(name).expect("pane frame");
match &model.arena.frame(fh).expect("live frame").kind_state {
@@ -427,6 +428,7 @@ fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
cameras: 0,
},
);
};
+191 -33
View File
@@ -274,19 +274,13 @@ fn the_two_model_tables_are_the_references_own() {
];
/// `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) — their
/// bodies are uncarved and they have no corpus caller. Moving a name OUT of here means the
/// verb was actually implemented — as `AdvanceTime` and `ReplaceIconTexture` were, once
/// wow-re carved them (`ui/scratch/model-advancetime-replaceicontexture.md`). The five left
/// are the fog near/far set, whose `ClearFog` semantics over the colour/near/far triple nobody
/// has read; guessing that clear would read as knowledge.
const UNBUILT: [&str; 5] = [
"SetFogNear",
"GetFogNear",
"SetFogFar",
"GetFogFar",
"ClearFog",
];
/// 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);
@@ -385,34 +379,75 @@ fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
assert_eq!(armed(&s).1, None);
}
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
/// `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**.
///
/// The engine core has no lighting model; typing this tuple would assert a scene semantics nobody
/// has verified, and a wrong typing is worse than an opaque one because it reads as knowledge. So
/// the contract is exactly "what went in comes out".
/// 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)
m:SetLight(1, 0, 0, -1, -1, 0.7, 1, 1, 1, 0.8, 1, 1, 1)
"#,
)
.unwrap();
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!(
s.eval::<usize>("return table.getn({ MLight:GetLight() })")
.unwrap(),
13,
"every number handed to SetLight comes back"
);
assert_eq!(
s.eval::<(f64, f64)>("local t = { MLight:GetLight() } return t[1], t[6]")
.unwrap(),
(1.0, 0.7_f32 as f64)
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
@@ -451,3 +486,126 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
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));
}
+8 -4
View File
@@ -683,8 +683,11 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
/// for each live GameTooltip, frame size = max measured line width (a double line is
/// left + gap + right) + 2·pad, floored by `SetMinimumWidth`, × summed line heights + gaps; each
/// visible right column's anchor re-points so its right edge sits at the text inset. Skips
/// frames whose lines haven't been measured yet (the XML default size holds until the measure
/// round-trip lands, one frame later — the same convergence the Lua loop had).
/// frames whose lines haven't been measured yet — the XML default size holds until the extents
/// land. With a [`TextMeasure`](super::TextMeasure) installed that is the SAME `resolve`
/// (`resolve` solves, fills the pending measures inline, and solves again, so this pre-pass runs
/// a second time with real extents); without one it is the host's batch round-trip, one frame
/// later, which is the engine-less path every measurer-free VM still takes.
pub(super) fn layout_tooltips(model: &mut Model) {
// The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at
// the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most
@@ -715,8 +718,9 @@ pub(super) fn layout_tooltips(model: &mut Model) {
// already the wrapped one; no second round-trip.)
// Per-line metrics next (one read pass), then the size + right-flush writes. Only
// measured cells contribute — a row with no measured cell adds no height and no gap, and
// a tooltip with nothing measured yet is skipped whole (its declared size holds until
// the measure round-trip lands, one frame later).
// a tooltip with nothing measured yet is skipped whole (its declared size holds until the
// extents land — this resolve's own `fill_measures` with a measurer installed, the host's
// batch round-trip a frame later without one).
let n = num.min(lefts.len()).min(rights.len());
let rows: Vec<(Cell, Cell)> = (0..n)
.map(|i| (cell(model, lefts[i]), cell(model, rights[i])))
+12
View File
@@ -130,6 +130,18 @@ pub enum QuadContent {
/// `ReplaceIconTexture`'s path — the type-14 texture override, or `None` for the file's
/// own textures.
icon: Option<String>,
/// The installed camera as a RAW index into the file's camera table, or `None` for the
/// NULL camera. **This is the render leg** (decision 2027): `None` is the orthographic
/// one — the model laid flat over the pane's rect — and `Some(n)` the perspective one,
/// framed by the file's own record `n`.
camera: Option<u32>,
/// The pane's embedded `CGLight`. Disabled on every shipped pane, which is why an
/// unlit-on-every-material UI M2 never notices; a LIT batch under a disabled light draws
/// black, and that is the reference's answer, not a gap.
light: crate::widget::ModelLight,
/// The pane's fog, only when armed — colour, near and far as the fill callback stages
/// them. Per-batch from there: a material with the UNFOGGED bit ignores it.
fog: Option<crate::widget::ModelFog>,
},
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
Texture {
+422
View File
@@ -0,0 +1,422 @@
//! The `/who` list's sort — the reference's seven-slot key chain, its promote-and-flip rule, and
//! the comparator that walks it (`SortWho 0x5ad890`, comparator `0x5ada00`; wow-re
//! `system/net/scratch/who-list-sort-law.md`, §5-crossed 2026-09-05, benilla decision 2030).
//!
//! **A `/who` sort is not "order by the clicked column".** The reference keeps a *chain* of seven
//! `{key, dir}` slots at `0xc2817c`/`0xc28180`, seeded once per process to `key[i] = i`
//! (`0x5add04`), i.e. **zone → level → class → group → name → race → guild**, every direction
//! ascending. Each click *promotes* its key to the front of that chain; the keys it displaces
//! stay behind it, so the chain reads most-recently-used first and the comparator walks it as a
//! **tie-breaker list**, not as a single key. Two players on the same level keep whatever order
//! the previously clicked key gave them.
//!
//! Two consequences that are the whole of bug B365:
//!
//! - **A repeated click reverses.** The promote flips the direction **only when the key was
//! already at the front** (`0x5ad99e`–`0x5ad9a7`, the `sete` on `i == 0`); promoting a key from
//! anywhere else carries its *remembered* direction over unchanged. So Name, Name reverses; but
//! Name, Level, Name does **not** — the second Name click restores the direction the first one
//! left there.
//! - **Nothing here is a request to the server.** The chain and the comparator are engine state;
//! `SortWho` sorts the array it already holds and fires `WHO_LIST_UPDATE` synchronously, inside
//! the binding ([`super::social`]'s `SortWho`).
//!
//! The chain is **per-process** in the reference — its initialiser `0x5adc50` runs once from the
//! process-start run at `0x401666` — so it survives a logout and the next login inherits it.
//! `SocialState::clear_session` on the app side keeps it for that reason.
use std::cmp::Ordering;
use super::social::WhoInfo;
/// The seven keys `SortWho` maps its argument to. **The declaration order is the reference's key
/// numbering** (`edi` in `0x5ad8bb`–`0x5ad979`) *and* the chain's seeded order, because the
/// initialiser writes `key[i] = i`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum WhoSortKey {
/// `0` — `"zone"`, the frame's own resting column.
Zone,
/// `1` — `"level"`, the one numeric arm: at `dir = 0` the **lower** level comes first.
Level,
/// `2` — `"class"`.
Class,
/// `3` — `"group"`. A real chain key whose comparator arm (`0x5adbb2`) *is* the continue
/// label: it orders nothing, ever. Unreachable from the shipped UI as well — the header that
/// carries `sortType = "group"` sits inside `FriendsFrame.xml`'s commented span 1413–1429 —
/// but it is modelled because it occupies a slot, and promoting it still displaces whatever
/// key was at the front.
Group,
/// `4` — `"name"`, and the answer for **any** string the seven compares miss: `edi` is
/// preloaded with 4 at `0x5ad8bb` and never reassigned on the no-match path.
#[default]
Name,
/// `5` — `"race"`.
Race,
/// `6` — `"guild"`.
Guild,
}
impl WhoSortKey {
/// Every key, in the chain's seeded order.
const ALL: [Self; 7] = [
Self::Zone,
Self::Level,
Self::Class,
Self::Group,
Self::Name,
Self::Race,
Self::Guild,
];
/// `SortWho`'s argument → key: seven `0x64a4c0` compares (`0x5ad8c0`–`0x5ad979`), which are
/// **case-insensitive** (`0x414310` folds `A`–`Z` on both operands), with [`Self::Name`] as
/// the no-match answer.
pub fn from_sort_type(sort_type: &str) -> Self {
match () {
_ if sort_type.eq_ignore_ascii_case("name") => Self::Name,
_ if sort_type.eq_ignore_ascii_case("level") => Self::Level,
_ if sort_type.eq_ignore_ascii_case("class") => Self::Class,
_ if sort_type.eq_ignore_ascii_case("group") => Self::Group,
_ if sort_type.eq_ignore_ascii_case("race") => Self::Race,
_ if sort_type.eq_ignore_ascii_case("zone") => Self::Zone,
_ if sort_type.eq_ignore_ascii_case("guild") => Self::Guild,
_ => Self::Name,
}
}
/// This key's own ordering of two rows at `dir = 0` — the comparator's seven jump-table arms
/// (`0x5adbd8`), each returning `0x64a4c0`'s `−1/0/+1` or the level arm's `setge` sign.
fn order(self, a: &WhoInfo, b: &WhoInfo) -> Ordering {
match self {
// `a+0` / `a+0x30`: wire strings, compared case-insensitively. An empty guild is a
// real value here (`""` sorts before any name), not a missing row.
Self::Name => ascii_ci_cmp(&a.name, &b.name),
Self::Guild => ascii_ci_cmp(&a.guild, &b.guild),
// `a+0x90`, i32: `cmp; je tie; setge dl; lea edx,[edx*2-1]` — lower level first.
Self::Level => a.level.cmp(&b.level),
// The three DBC arms: the *localized name* out of `ChrClasses`/`ChrRaces`/`AreaTable`
// is what gets compared, which is exactly what the app already resolved into the row.
Self::Class => dbc_name_cmp(&a.class, &b.class),
Self::Race => dbc_name_cmp(&a.race, &b.race),
Self::Zone => dbc_name_cmp(&a.zone, &b.zone),
// `0x5adbb2` is the continue label itself.
Self::Group => Ordering::Equal,
}
}
}
/// The seven-slot `{key, dir}` chain at `0xc2817c`/`0xc28180`: the sort keys most-recently-used
/// first, each with the direction it was last left in. See the module doc.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WhoSortChain {
/// `(key, reversed)`, slot 0 first. Every key appears exactly once — a promote permutes, it
/// never inserts or drops.
slots: [(WhoSortKey, bool); 7],
}
impl Default for WhoSortChain {
/// The initialiser `0x5add04`–`0x5add16`: `key[i] = i`, `dir[i] = 0`.
fn default() -> Self {
Self {
slots: WhoSortKey::ALL.map(|key| (key, false)),
}
}
}
impl WhoSortChain {
/// `SortWho`'s promote (`0x5ad980`–`0x5ad9c9`): move `sort_type`'s key to the front, flipping
/// its direction **only if it was already there**, and shift the keys it passed down one slot
/// each. Keys behind it keep their own remembered directions.
pub fn promote(&mut self, sort_type: &str) {
let key = WhoSortKey::from_sort_type(sort_type);
let Some(at) = self.slots.iter().position(|(k, _)| *k == key) else {
// Unreachable: the chain is a permutation of all seven keys. The reference's own
// not-found edge (`0x5ad991 jmp 0x5ad9cf`) skips the promote and sorts anyway.
return;
};
let dir = if at == 0 {
!self.slots[0].1
} else {
self.slots[at].1
};
self.slots[..=at].rotate_right(1);
self.slots[0] = (key, dir);
}
/// The comparator `0x5ada00`: walk the chain, return the first key that does not tie —
/// negated when that slot's direction is set — and `Equal` when all seven tie.
pub fn compare(&self, a: &WhoInfo, b: &WhoInfo) -> Ordering {
for (key, reversed) in self.slots {
let order = key.order(a, b);
if order != Ordering::Equal {
return if reversed { order.reverse() } else { order };
}
}
Ordering::Equal
}
/// Order `rows` by the chain — the `qsort` at `0x5ad9cf` (a click) and `0x5ae0e2` (a fresh
/// `SMSG_WHO` answer, which is why results arrive already in the current order).
///
/// The reference's `qsort` is unstable and this is not; the difference is unobservable,
/// because the name key never leaves the chain and no two rows of one realm's `/who` answer
/// share a name, so [`Self::compare`] is a total order over the rows that can actually occur.
pub fn sort(&self, rows: &mut [WhoInfo]) {
rows.sort_by(|a, b| self.compare(a, b));
}
/// The chain's keys, front (most recently clicked) first — for tests and diagnostics.
#[cfg(test)]
fn keys(&self) -> [(WhoSortKey, bool); 7] {
self.slots
}
}
/// `0x64a4c0` → `0x414310`: a **case-insensitive ASCII** compare, folding only `A`–`Z` by `+0x20`
/// on both operands (`0x41434a`–`0x41435c`) and answering `−1/0/+1`.
///
/// Deliberately not `to_lowercase()`: that is a Unicode fold, and it would order the accented
/// names the localized clients allow differently from the reference, which only ever touches the
/// 26 ASCII letters.
fn ascii_ci_cmp(a: &str, b: &str) -> Ordering {
let (a, b) = (a.as_bytes(), b.as_bytes());
for (x, y) in a.iter().zip(b) {
match x.to_ascii_lowercase().cmp(&y.to_ascii_lowercase()) {
Ordering::Equal => {}
other => return other,
}
}
// The reference compares NUL-terminated strings, so the shorter one wins its own prefix.
a.len().cmp(&b.len())
}
/// The three DBC-backed arms' compare. **A row the id could not resolve TIES** — verified on all
/// three arms (class `0x5ada7b`, race `0x5adadf`, zone `0x5adb40`), which are one idiom
/// byte-for-byte: three miss conditions (a negative id, an id past the *inclusive* bound, a NULL
/// row at an in-range id) zero the row register, and either side being zero jumps to `0x5adbb2`,
/// which **is** the chain loop's `inc esi`. So it is not a returned `0` compared against
/// something — the key contributes nothing and the next slot decides. `dir` cannot reach it
/// either (the `neg` is gated behind the compare the tie branch skips).
///
/// The tie matters more than it looks: an unresolved id would otherwise sort as `""`, parking
/// every unknown zone at the top of the list ahead of Ahn'Qiraj.
///
/// **The empty string is this side's marker for "no row", and the two sides of the reference
/// disagree on purpose.** `GetWhoInfo 0x5ad6e0` substitutes the localized `"UNKNOWN"` on the same
/// three legs, so a row can read UNKNOWN in the cell while sorting as though that column did not
/// exist. Anything that starts showing UNKNOWN here has to carry the miss to this comparator by
/// some route other than the string (wow-re `who-list-sort-law.md` §11.1–§11.2).
fn dbc_name_cmp(a: &str, b: &str) -> Ordering {
if a.is_empty() || b.is_empty() {
return Ordering::Equal;
}
ascii_ci_cmp(a, b)
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str, level: u32, class: &str, zone: &str) -> WhoInfo {
WhoInfo {
name: name.to_string(),
guild: String::new(),
level,
race: "Human".to_string(),
class: class.to_string(),
zone: zone.to_string(),
}
}
fn names(rows: &[WhoInfo]) -> Vec<&str> {
rows.iter().map(|r| r.name.as_str()).collect()
}
/// The seeded chain is the reference's `key[i] = i`, all ascending — so a list nobody has
/// clicked is ordered zone, then level, then class, then name.
#[test]
fn the_chain_is_seeded_in_key_order() {
assert_eq!(
WhoSortChain::default().keys(),
[
(WhoSortKey::Zone, false),
(WhoSortKey::Level, false),
(WhoSortKey::Class, false),
(WhoSortKey::Group, false),
(WhoSortKey::Name, false),
(WhoSortKey::Race, false),
(WhoSortKey::Guild, false),
]
);
}
/// The key mapping, including the case-insensitive compares and the name fallback that
/// swallows every unrecognised string.
#[test]
fn the_sort_type_maps_case_insensitively_and_falls_back_to_name() {
for (arg, key) in [
("zone", WhoSortKey::Zone),
("level", WhoSortKey::Level),
("class", WhoSortKey::Class),
("group", WhoSortKey::Group),
("name", WhoSortKey::Name),
("race", WhoSortKey::Race),
("guild", WhoSortKey::Guild),
("LEVEL", WhoSortKey::Level),
("Guild", WhoSortKey::Guild),
] {
assert_eq!(WhoSortKey::from_sort_type(arg), key, "{arg}");
}
for arg in ["", "sortme", "5", "levels"] {
assert_eq!(
WhoSortKey::from_sort_type(arg),
WhoSortKey::Name,
"{arg} must fall back to name"
);
}
}
/// **B365's headline.** A first click sorts ascending; the *same* key clicked again reverses.
#[test]
fn a_repeated_click_reverses() {
let mut rows = vec![
row("Galas", 60, "Warrior", "Elwynn Forest"),
row("Erdrin", 60, "Warrior", "Elwynn Forest"),
];
let mut chain = WhoSortChain::default();
chain.promote("name");
chain.sort(&mut rows);
assert_eq!(names(&rows), ["Erdrin", "Galas"], "first click ascends");
chain.promote("name");
chain.sort(&mut rows);
assert_eq!(names(&rows), ["Galas", "Erdrin"], "the repeat reverses");
chain.promote("name");
chain.sort(&mut rows);
assert_eq!(names(&rows), ["Erdrin", "Galas"], "and back again");
}
/// The flip is `i == 0` ONLY. Clicking a different key in between promotes name from slot 1
/// with its remembered direction *carried*, so the third click below does not re-reverse —
/// it restores what the second one left, which is the half a naive "toggle on every click"
/// implementation gets wrong.
#[test]
fn promoting_from_behind_carries_the_direction_it_left() {
let mut chain = WhoSortChain::default();
chain.promote("name"); // ascending
chain.promote("name"); // descending
chain.promote("level"); // name drops to slot 1, still descending
assert_eq!(chain.keys()[1], (WhoSortKey::Name, true));
chain.promote("name"); // back to the front — NOT a flip
assert_eq!(chain.keys()[0], (WhoSortKey::Name, true));
let mut rows = vec![
row("Aaa", 1, "Mage", "Durotar"),
row("Bbb", 1, "Mage", "Durotar"),
];
chain.sort(&mut rows);
assert_eq!(names(&rows), ["Bbb", "Aaa"], "still descending");
}
/// The displaced keys stay behind the clicked one, most-recently-used first — so a click is a
/// tie-breaker chain, not a fresh single-key sort. Level, then class: rows equal on class
/// keep the *level* order the earlier click established.
#[test]
fn earlier_clicks_survive_as_tie_breakers() {
let mut chain = WhoSortChain::default();
chain.promote("level");
chain.promote("class");
assert_eq!(
chain.keys()[..2],
[(WhoSortKey::Class, false), (WhoSortKey::Level, false)]
);
let mut rows = vec![
row("Ca", 40, "Rogue", "Westfall"),
row("Aa", 60, "Mage", "Westfall"),
row("Ba", 20, "Rogue", "Westfall"),
];
chain.sort(&mut rows);
assert_eq!(
names(&rows),
["Aa", "Ba", "Ca"],
"Mage before Rogue; the two Rogues in the level order the earlier click gave them"
);
}
/// The level arm is the numeric one, and ascending means the LOWER level first.
#[test]
fn level_ascends_before_it_reverses() {
let mut rows = vec![row("High", 60, "Mage", "Z"), row("Low", 12, "Mage", "Z")];
let mut chain = WhoSortChain::default();
chain.promote("level");
chain.sort(&mut rows);
assert_eq!(names(&rows), ["Low", "High"]);
chain.promote("level");
chain.sort(&mut rows);
assert_eq!(names(&rows), ["High", "Low"]);
}
/// Names fold case the way `0x414310` does — `apple` and `Apple` compare equal, and `Zed`
/// sorts after `apple` rather than before it as a byte compare would have it.
#[test]
fn the_string_arms_fold_ascii_case() {
assert_eq!(ascii_ci_cmp("Apple", "apple"), Ordering::Equal);
assert_eq!(ascii_ci_cmp("apple", "Zed"), Ordering::Less);
assert_eq!(ascii_ci_cmp("Zed", "apple"), Ordering::Greater);
assert_eq!(ascii_ci_cmp("Ann", "Anne"), Ordering::Less, "prefix first");
}
/// An unresolved DBC name ties rather than sorting as `""` — otherwise every row whose zone
/// id has no `AreaTable` row would pile up at the top of a zone sort.
#[test]
fn an_unresolved_dbc_name_ties() {
let mut chain = WhoSortChain::default();
chain.promote("zone");
let known = row("Bbb", 10, "Mage", "Westfall");
let unknown = row("Aaa", 10, "Mage", "");
// Not `Less`: a `""` zone does not sort first, it does not sort at all — the zone key
// ties and the chain falls through to the name, where Aaa beats Bbb.
assert_eq!(
chain.compare(&known, &unknown),
Ordering::Greater,
"the zone key ties, so the name decides"
);
assert_eq!(chain.compare(&unknown, &known), Ordering::Less);
// A *guild* is not a DBC lookup: an empty guild is a real value and sorts first.
assert_eq!(ascii_ci_cmp("", "Legacy"), Ordering::Less);
}
/// `"group"` is a live chain key with a dead arm: it displaces whatever was at the front, and
/// orders nothing itself, so the list falls straight through to the rest of the chain.
#[test]
fn the_group_key_displaces_but_orders_nothing() {
let mut chain = WhoSortChain::default();
chain.promote("level");
chain.promote("group");
assert_eq!(
chain.keys()[..2],
[(WhoSortKey::Group, false), (WhoSortKey::Level, false)]
);
let mut rows = vec![row("A", 60, "Mage", "Z"), row("B", 12, "Mage", "Z")];
chain.sort(&mut rows);
assert_eq!(
names(&rows),
["B", "A"],
"ordered by level, group having tied"
);
}
/// Every key ties ⇒ `Equal`, the comparator's `xor eax,eax` tail. Only reachable for two rows
/// that agree on all six live keys.
#[test]
fn identical_rows_tie_on_every_key() {
let chain = WhoSortChain::default();
let a = row("Same", 30, "Priest", "Ironforge");
assert_eq!(chain.compare(&a, &a.clone()), Ordering::Equal);
}
}
@@ -352,6 +352,7 @@ mod tests {
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
cameras: 0,
},
);
}
+156 -13
View File
@@ -417,16 +417,28 @@ pub struct ModelState {
pub facing: f32,
/// `SetModelScale` — the model's own scale within the pane, default 1.
pub scale: f32,
/// `SetCamera(index)` — which of the model's baked camera setups to view it through.
pub camera: i32,
/// The **pending** camera index (`CSimpleModel+0x320`) — `Some(n)` while the camera question
/// is open, `None` once it is settled. The ctor writes `Some(0)`, a standing request for raw
/// camera 0, which the model-ready hook applies ([`ModelState::seed_from_facts`]); `SetCamera`
/// with no facts yet defers into it (`0x76cec0`'s two early legs). **While it is `Some`, the
/// pane draws nothing at all** — the reference's draw gate is
/// `76d5f0 cmp [this+0x320],-1 ; jne <skip everything>` (decision 2027).
pub camera_pending: Option<i32>,
/// The **installed** camera (`+0x31c`) as a RAW index into the file's camera table, or `None`
/// for the NULL camera — which is what an index past the table's count installs (`76cf08`)
/// and what a file with no cameras always gets. `None` is the **orthographic** render leg;
/// `Some(n)` is the perspective one, framed by the file's own record `n`.
///
/// Raw is the whole point: `0x76cec0` reads the count off `MD20+0x124` and the record at
/// `[model+0x3c4] + idx·0x84 + 0x80`, and **never consults `cameraLookup`** — that array is
/// the portrait bake's path (wow-re `modelframe-camera-law.md` §2.1).
pub camera: Option<u32>,
/// `SetPosition(x, y, z)` — the model's offset within the pane's scene.
pub position: (f32, f32, f32),
/// `SetLight(...)` — the scene's light, stored verbatim as the 14-number tuple the binding
/// takes (`enabled, omni, dirX, dirY, dirZ, ambIntensity, ambR, ambG, ambB, dirIntensity,
/// dirR, dirG, dirB` plus the leading `enabled`). Opaque here on purpose: the engine core has
/// no lighting model, and inventing a typed one would be asserting a scene semantics we have
/// not verified.
pub light: Option<Vec<f32>>,
/// The pane's embedded `CGLight` (`CSimpleModel+0x324`) — see [`ModelLight`]. Typed since
/// decision 2027: the render law (§5.1–§5.3) carves every field, its consumer and both of
/// `SetLight`'s traps, so the tuple no longer has to be stored opaquely.
pub light: ModelLight,
/// `SetFogColor(r, g, b, a)` as the reference stores it: **one packed `0xAARRGGBB` dword**,
/// which is why its getter is four values wide and why a Set→Get round trip is **lossy** —
/// eight bits per channel (decision 1845).
@@ -435,10 +447,90 @@ pub struct ModelState {
/// not four zeros. It used to be `Option<(f32, f32, f32)>` here: three components, no alpha,
/// and `None` for unset — every one of those three wrong.
///
/// The client's fog surface is seven verbs, not two: this pair plus
/// `SetFogNear`/`GetFogNear`/`SetFogFar`/`GetFogFar`/`ClearFog`, which the struct doc names as
/// unbuilt.
/// All seven fog verbs are built as of decision 2027 — this pair plus
/// `SetFogNear`/`GetFogNear`/`SetFogFar`/`GetFogFar`/`ClearFog` — because the render law
/// (§5.4) carves every one of their bodies.
pub fog_color: u32,
/// `+0x3a4` **bit 0** — fog armed. Set by `SetFogColor` (`76f059 or [edi+0x3a4],1`) and by the
/// XML `<FogColor>` child; cleared by `ClearFog` (`76f5c5 and [edi+0x3a4],-2` — **bit 0 only**,
/// so the colour, near and far all survive a clear and come back on the next `SetFogColor`).
/// The ctor leaves it off.
pub fog: bool,
/// `+0x3ac` — fog near, raw and unclamped from `SetFogNear` (`76f282 fstp`), clamped at `≥ 0`
/// only on the XML `fogNear` attribute path (`76cbbb`). Ctor `0.0`.
pub fog_near: f32,
/// `+0x3b0` — fog far, same shape as [`Self::fog_near`] (`76f432`, XML `fogFar` at `76cbf3`).
/// **Ctor `1.0`**, not `0.0`: the fill callback stages `1/(far − near)` and the batch fogs
/// only when that is `> 0`, so the ctor's pair is already a valid (if tiny) ramp.
pub fog_far: f32,
}
/// A model pane's **armed** fog — [`ModelState::armed_fog`]'s answer, and the whole of what the
/// renderer needs: the disarmed values are engine state that nothing draws.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelFog {
/// The packed `0xAARRGGBB` colour (`+0x3a8`). The alpha byte exists and is **never read** by
/// the fill callback (`0x76d680` takes bytes 2,1,0 only).
pub color: u32,
/// `+0x3ac` / `+0x3b0`, raw. The fill stages `1/(far − near)` and a batch fogs only when that
/// is `> 0`, so a `far <= near` pair arms the flag and still draws unfogged.
pub near: f32,
pub far: f32,
}
impl ModelFog {
/// The colour as linear `[r, g, b]` in `0..=1` — the fill callback's own unpack
/// (`0x7bbf20` -> bytes 2,1,0 × 1/255).
pub fn rgb(&self) -> [f32; 3] {
[16, 8, 0].map(|shift| ((self.color >> shift) & 0xff) as f32 / 255.0)
}
}
/// A model pane's embedded **`CGLight`** — the 0x6c-byte object at `CSimpleModel+0x324` that the
/// per-paint fill callback `0x76d680` adds to the model's light collector, and the only light a
/// `<Model>` widget has (wow-re `modelframe-render-law.md` §5.1/§5.2).
///
/// **A plain `<Model>`'s is DISABLED and stays that way unless Lua enables it.** The ctor
/// `0x76c8e0` leaves `+0x60 = 0`, so `0x71bf90` returns before adding anything and the collector
/// finalizes with zero ambient and zero diffuse — under which a LIT batch draws **black**. That
/// is harmless by asset design (every in-game UI M2 is UNLIT on every material, §5.7) and it is
/// the faithful answer for an addon's lit one. `<PlayerModel>`'s ctor `0x505680` enables its own
/// instead, which is why the paper doll is lit; those panes are the portrait booth's, not this
/// widget's.
///
/// Each colour is stored **already multiplied by its intensity** — `SetLight` folds
/// `rgb/255 × intensity` before the copy — which is why there is no separate intensity field.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelLight {
/// `CGLight+0x60` — enabled. `SetLight`'s first argument writes it, and **only when nonzero**:
/// `SetLight(0, …)` returns at `76e2cb` without touching the widget at all, so it is not a
/// way to turn a light off (§5.3 trap 1).
pub enabled: bool,
/// `+0x08` — type. `true` = point/omni (`1`, what both ctors write), `false` = directional.
/// Chooses which of the two vector setters `SetLight`'s `(x, y, z)` reaches.
pub omni: bool,
/// `+0x0c` position (when [`Self::omni`]) or `+0x24` direction (when not) — the direction is
/// **normalised on write** by `0x71b6a0`, and is a *from-light* vector.
pub vector: [f32; 3],
/// `+0x30` ambient, intensity already folded in.
pub ambient: [f32; 3],
/// `+0x3c` diffuse, intensity already folded in.
pub diffuse: [f32; 3],
}
/// The `<Model>` ctor's light (`0x76c8e0`): `0x71b4a0` (type 1, everything else zero), then
/// enabled `0` at `76c99c`, type `1` at `76c9a5`, ambient `(1,1,1)` at `76c9b4`–`76c9d3` and
/// diffuse `(1,1,1)` at `76c9d6`–`76c9ff`. White, and switched off.
impl Default for ModelLight {
fn default() -> Self {
Self {
enabled: false,
omni: true,
vector: [0.0; 3],
ambient: [1.0; 3],
diffuse: [1.0; 3],
}
}
}
/// A fresh `Model` pane: no content, sequence 0, unrotated, unit scale, camera 0, at the origin.
@@ -459,10 +551,14 @@ impl Default for ModelState {
implicit_size: false,
facing: 0.0,
scale: 1.0,
camera: 0,
camera_pending: Some(0),
camera: None,
position: (0.0, 0.0, 0.0),
light: None,
light: ModelLight::default(),
fog_color: 0xffff_ffff,
fog: false,
fog_near: 0.0,
fog_far: 1.0,
}
}
}
@@ -527,6 +623,11 @@ pub struct ModelFileFacts {
/// The header bounding box, raw WoW model space (`min`, `max`) — the implicit rect of a
/// size-less `<Model>` (render law §3) and the arrow's re-centring.
pub bbox: ([f32; 3], [f32; 3]),
/// How many records the file's **camera table** holds (`MD20+0x124`) — the bound
/// `Model:SetCamera(n)` is checked against (`0x76cec0`: `76ceeb if idx >= count -> install
/// NULL`). `0` for the overwhelming majority of models, which is why a plain `<Model>` almost
/// always ends on the orthographic leg. Decision 2027.
pub cameras: u32,
}
impl ModelFileFacts {
@@ -551,6 +652,13 @@ impl ModelFileFacts {
((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0))
}
/// Which camera raw index `idx` installs: `Some(idx)` when the table has it, `None` — the
/// **NULL** camera, i.e. the orthographic leg — when it does not. Negative indices land in the
/// same place the reference's unsigned `jae` puts them.
pub fn camera_at(&self, idx: i32) -> Option<u32> {
u32::try_from(idx).ok().filter(|&i| i < self.cameras)
}
/// The loader's idle seed (`0x70ebd0`'s tail, `0x710153`–`0x71019b`): **id 0 (`Stand`) if
/// the file owns it, else `animations[0]`'s own id**; `None` only for a file with no
/// sequences at all.
@@ -647,6 +755,41 @@ impl ModelState {
self.armed = None;
}
}
// The model-ready hook's other half (`0x76ce00` `76ce3e`): apply the pending camera index
// if the question is still open. The ctor's standing `Some(0)` is what gives a plain
// `<Model>` its default camera 0, and it is applied here, once, exactly as the reference
// applies it on the asset-ready edge.
if let Some(idx) = self.camera_pending {
self.install_camera(idx, Some(facts));
}
}
/// The pane's fog, **only when it is armed** (`+0x3a4` bit 0) — the fill callback's own gate
/// (`76d68a test byte [esi+0x3a4],1 ; je`). A disarmed pane stages nothing, and its collector
/// keeps the zeroed `1/(far − near)` that the per-batch fog test refuses.
pub fn armed_fog(&self) -> Option<ModelFog> {
self.fog.then_some(ModelFog {
color: self.fog_color,
near: self.fog_near,
far: self.fog_far,
})
}
/// `0x76cec0` — select a camera by RAW table index. With no facts yet (the reference's "no
/// model" / "not ready" legs at `76cece`/`76cedb`) the index is **deferred** into
/// [`Self::camera_pending`] and nothing draws until it resolves; with facts, the index is
/// bounds-checked against the table's count and either installed or answered with the NULL
/// camera, and **either way the pending index is cleared** (`0x76ce80`'s
/// `76cead mov [esi+0x320],-1` — installing *any* camera, NULL included, settles the
/// question).
pub fn install_camera(&mut self, idx: i32, facts: Option<&ModelFileFacts>) {
match facts {
Some(f) => {
self.camera = f.camera_at(idx);
self.camera_pending = None;
}
None => self.camera_pending = Some(idx),
}
}
/// Where the armed sequence stands on the scene clock, under `facts`: wrapped for a looping
+5 -5
View File
@@ -176,11 +176,11 @@ pub use kinds::{
model_key, slider_fraction, slider_grab, ArmedSequence, ButtonFont, ButtonState,
ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, EditUnit,
FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts,
ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState, SequenceFacts,
SliderState, StatusBarState, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK,
MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN,
MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
TOOLTIP_WRAP_WIDTH,
ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState,
ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipState,
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
+7 -1
View File
@@ -78,7 +78,13 @@ impl Plugin for CloudsPlugin {
// After the submersion verdict: the surfacing edge (wet→dry) must fire the
// full rebuild the SAME frame the dome un-hides, or the first visible frame
// still shows the submerged-era tile.
tick_clouds.after(crate::liquid::SubmersionVerdict),
// …and on the READ side of the lighting resolve: the surfacing rebuild
// below takes its density and palette from `WowLighting`, so an unordered
// tick rebuilds the field from the atmosphere we just LEFT — the underwater
// one, whose cloud density is 0.0 in Stranglethorn (B354, decision 2032).
tick_clouds
.after(crate::liquid::SubmersionVerdict)
.in_set(crate::lighting::LightingConsumeSet),
// After the resolve: the dome and the painted skybox must agree WITHIN a frame,
// or one frame draws both (the ordering `crate::sky`'s gate already takes) —
// and after the submersion verdict, for the same one-frame-agreement reason.
+85 -10
View File
@@ -37,16 +37,26 @@
//! attachments from the owner's node; independent sampling would flicker at shadow edges mid-swing).
//!
//! Interplay: the interior classifier ([`crate::interior`]) owns a part's tag while it stands in a WMO
//! room (packed floor colour — no sun indoors, so no shade either); this system skips those parts and
//! room (an SH-probe slot — no sun indoors, so no shade either); this system skips those parts and
//! runs after the classifier to re-assert the byte over its exterior reclaim. Fades own the alpha field
//! only (they write through `with_alpha`), so shade rides through appear/despawn/zoom feathering.
//!
//! **The descendant tree is a TRANSFORM relation, not a light one, and the difference is a bug we
//! shipped** (B373). A WMO-display GameObject's doodad props — a transport's cabin furniture — are
//! parented under the net entity so they sail with the deck (decision 0474), but their light is
//! their own baked MODD colour folded into an SH probe, the reference's `CMapDoodadDef` provider
//! (`0x6a8050`) rather than the WENTITY node this file models. They were nonetheless in the walk,
//! and since the shade byte overlaps the probe slot in bits 6..=13, every one of them was pushed
//! onto a *different* probe: a neighbouring prop's light where the renamed index happened to be
//! live, and an unallocated — zeroed — row where it was not, which draws solid black. Both write
//! sites now ask [`probe_payload`], the payload question rather than the classifier question.
use benilla_assets::AdtTile;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::interior::{classify_entity_interior, InteriorLit};
use crate::mesh_tag::{shade_of, with_shade};
use crate::mesh_tag::{shade_of, with_shade, InteriorProbePayload};
use crate::terrain_stream::{doodad_ground_shade, ShadeResolve, TerrainStreamer};
// Decision 0354 generalized this file from "the MCSH ground-shade byte" to the entity light
@@ -276,15 +286,27 @@ pub(crate) fn update_ground_shade(
Option<&Visibility>,
)>,
children: Query<&Children>,
// Parts are matched by carrying a `MeshTag`; interior-classified ones are skipped (their payload
// is the packed floor colour). Fading parts are NOT skipped — shade and fade own disjoint fields.
// Parts are matched by carrying a `MeshTag`; a part whose payload is a PROBE SLOT is skipped,
// because the shade byte lives in the same bits (see [`probe_payload`]). Fading parts are NOT
// skipped — shade and fade own disjoint fields.
mut parts: Query<
(&mut MeshTag, Option<&InteriorLit>),
(
&mut MeshTag,
Option<&InteriorLit>,
Has<InteriorProbePayload>,
),
Without<crate::billboard::BillboardCard>,
>,
// A card is a world ROOT (the facing system owns its transform), so the descendant walk below
// cannot reach one — it carries its owner instead. Disjoint from `parts` by the filter above.
mut cards: Query<(&crate::billboard::BillboardCard, &mut MeshTag)>,
// It asks the same payload question: an interior prop's glow card carries its doodad's probe
// slot, and this pass reaches it by walking UP from the card's owner (B373).
mut cards: Query<(
&crate::billboard::BillboardCard,
&mut MeshTag,
Option<&InteriorLit>,
Has<InteriorProbePayload>,
)>,
// Reused across frames: each shaded ROOT → its shade byte this frame (a few hundred entries).
// The card pass walks `ChildOf` up to the nearest such root — this map used to record every
// descendant too (~10-20k inserts/frame) so that walk could be a single lookup.
@@ -410,11 +432,11 @@ pub(crate) fn update_ground_shade(
// joint entities deeper down — same full-tree walk as the self-fade). Change-gated per part on
// the byte, so a settled entity writes nothing and never re-triggers render extraction.
for part in children.iter_descendants(root) {
let Ok((mut tag, lit)) = parts.get_mut(part) else {
let Ok((mut tag, lit, own_probe)) = parts.get_mut(part) else {
continue;
};
if lit.is_some_and(InteriorLit::is_bake) {
continue; // the footprint-bake lane: the classifier owns the payload (probe slot)
if probe_payload(lit, own_probe) {
continue;
}
if shade_of(tag.0) != byte {
tag.0 = with_shade(tag.0, byte);
@@ -429,7 +451,10 @@ pub(crate) fn update_ground_shade(
// anchor or a deep joint) resolves to the NEAREST shaded root above it: with nested roots (a
// mounted unit — rider and mount each carry a node) that is the mount's, matching the
// one-node-per-object structure above; the old whole-tree map made this pick last-writer-wins.
for (card, mut tag) in &mut cards {
for (card, mut tag, lit, own_probe) in &mut cards {
if probe_payload(lit, own_probe) {
continue;
}
let Some(byte) = card_root_shade(&root_shade, &child_of, card.follows()) else {
continue; // a fixed terrain doodad's card — its shade rides the material selector
};
@@ -439,6 +464,27 @@ pub(crate) fn update_ground_shade(
}
}
/// **Does this instance's `MeshTag` payload carry an SH-probe slot?** If it does, this file must
/// not touch it: the shade byte occupies bits 6..=13 and the probe slot 6..=18, so a shade write
/// renames the slot to `(slot & 0x1f00) | byte` — a foreign probe, or an unallocated (zeroed ⇒
/// **black**) row.
///
/// Two populations answer yes, by two different components, and the guard used to know only the
/// first:
/// - the interior classifier's **Bake law** — an entity part standing in a WMO room, whose slot
/// the classifier re-seats as the body moves ([`InteriorLit::is_bake`]);
/// - a lit interior **MODD prop**, whose slot was folded once at spawn and never moves
/// ([`InteriorProbePayload`]).
///
/// The second only ever meets this walk on a WMO-display GameObject, whose doodad props are
/// parented under the net entity so they ride a moving transport (decision 0474) — a *transform*
/// relationship that the descendant walk read as a *light* one. That is bug B373: every cabin
/// prop on every 1.12 transport read probe slot `(its own & 0x1f00) | the boat's shade byte`, and
/// the ones that landed on an unallocated row drew as solid black silhouettes beside lit walls.
fn probe_payload(lit: Option<&InteriorLit>, own_probe: bool) -> bool {
own_probe || lit.is_some_and(InteriorLit::is_bake)
}
/// The shade byte a card inherits: the NEAREST shaded root at or above `follows` (the owner itself
/// first, then the `ChildOf` chain). `None` — no owner, or no shaded root over it — skips the card,
/// exactly as the old whole-tree map's miss did.
@@ -554,6 +600,35 @@ mod tests {
);
}
/// **The payload question, both populations** (B373). The guard used to ask "is this part on
/// the classifier's Bake law", which is only one of the two ways a `MeshTag` comes to hold a
/// probe slot; a WMO doodad prop holds one from spawn and carries no `InteriorLit` at all.
/// A transport's cabin furniture is where the second population lands inside an entity's
/// descendant walk, and every one of those props drew under a foreign probe.
///
/// (The Bake half is the classifier's own state and is pinned by `interior`'s tests; what is
/// new here is that the marker answers on its own, whatever the classifier says.)
#[test]
fn a_spawned_probe_payload_is_hands_off_with_no_interior_lit_at_all() {
let matte = InteriorLit::new(crate::interior::InteriorKind::Matte, Handle::default());
assert!(
probe_payload(None, true),
"a spawn-time MODD prop owns its payload, with no InteriorLit to say so"
);
assert!(
probe_payload(Some(&matte), true),
"and it owns it whatever law the classifier would have applied"
);
assert!(
!probe_payload(Some(&matte), false),
"the matte indoor lane carries no slot — the byte is its field"
);
assert!(
!probe_payload(None, false),
"an ordinary exterior part takes the shade byte"
);
}
/// Nested shade roots — a mounted unit: rider root and mount root each carry a node, the mount
/// a descendant of the rider. A card under the MOUNT takes the mount's byte (the nearest root),
/// pinned here because the old whole-tree map answered this by insert order.
+13 -1
View File
@@ -948,7 +948,19 @@ impl Plugin for FfxGlowPlugin {
ExtractResourcePlugin::<FfxHazeMix>::default(),
ExtractResourcePlugin::<FfxWave>::default(),
))
.add_systems(Update, (sync_gain, sync_haze, sync_wave, ensure_ffx_glow));
.add_systems(
Update,
(
// The gain is the zone's `LightParams.glow`, so the sync is on the resolve's
// read side; the haze floor and the wave's arm are the camera-eye submersion
// verdict, so they are after the slot that writes it. Unordered, both flipped
// a frame late — the underwater blur and warp outlived the surfacing frame
// they belong to, exactly like the sky dome's stops (decision 2032).
sync_gain.in_set(crate::lighting::LightingConsumeSet),
(sync_haze, sync_wave).after(crate::liquid::SubmersionVerdict),
ensure_ffx_glow,
),
);
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
+5 -1
View File
@@ -418,7 +418,11 @@ impl Plugin for InteriorPlugin {
// placements it rays are the ones this frame's residency published.
.add_systems(
Update,
classify_entity_interior.after(crate::wmo_portal::WmoPvsSet),
classify_entity_interior
.after(crate::wmo_portal::WmoPvsSet)
// The fold reads the resolved `WowLighting`, so it belongs on the resolve's
// read side (`lighting::LightingConsumeSet`, decision 2032).
.in_set(crate::lighting::LightingConsumeSet),
)
.add_observer(enqueue_on_fade_latch);
}
+13 -2
View File
@@ -63,9 +63,20 @@ impl LightBlob {
/// Fog: colour, far distance, and whether the shader applies it. Near is always 0 and the
/// farclip wall stays inert — the reference's off-world fog rows (`CharModelFogInfo` and
/// `AccountLogin.xml`) carry a far and nothing else.
pub fn fog(mut self, rgb: [f32; 3], far: f32, on: bool) -> Self {
pub fn fog(self, rgb: [f32; 3], far: f32, on: bool) -> Self {
self.fog_span(rgb, 0.0, far, on)
}
/// Fog with an explicit **near**, the ramp's low end (`fog_params.x`): the factor is
/// `(far − eye_z)/(far − near)` clamped, so near is where the fog first bites and not a
/// cosmetic. The one producer that can state one is the `<Model>` widget's `SetFogNear`
/// (decision 2027) — every other off-world blob leaves it at 0, which is what
/// [`Self::fog`] passes and what `CharModelFogInfo`'s own `SetFogNear(0)` says.
/// The farclip wall (`.w`) stays inert at `10_000`: it is a per-pixel *discard*, not a fade,
/// and an off-world scene has no clip distance to enforce.
pub fn fog_span(mut self, rgb: [f32; 3], near: f32, far: f32, on: bool) -> Self {
self.rows[4] = [rgb[0], rgb[1], rgb[2], if on { 1.0 } else { 0.0 }];
self.rows[5] = [0.0, far, 0.0, 10_000.0];
self.rows[5] = [near, far, 0.0, 10_000.0];
self
}
+172
View File
@@ -286,6 +286,41 @@ pub struct GameClock {
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LightingResolveSet;
/// **The read side of [`LightingResolveSet`] — every `Update` system that reads the resolved
/// [`WowLighting`] belongs in this set.** Configured once, here, as
/// `LightingConsumeSet.after(LightingResolveSet)`; a consumer joins the contract with
/// `.in_set(..)` instead of remembering a bespoke `.after(..)`, which is precisely what five of
/// them did not remember (decision 2032).
///
/// **Why the contract needs a name of its own.** [`resolve::update_time_lighting`] is a *late*
/// system by construction — it waits on the wire drain ([`crate::schedule::WorldStage::Net`]),
/// the weather tick and the submersion verdict, each of which waits on something else. A consumer
/// that declares no ordering waits on nothing, so Bevy's executor starts it at the top of
/// `Update` and it reads the value the resolve wrote **last** frame. That is not a race: it is
/// deterministic, it happens on every frame, and it is invisible for as long as the atmosphere
/// only drifts with the clock.
///
/// It stops being invisible at a **submersion crossing**, where the atmosphere does not drift but
/// jumps — the whole underwater `LightParams` swaps in or out in one frame, on the very frame
/// [`crate::sky`]'s and [`crate::clouds`]'s own gates un-hide the domes. Report B354 is that
/// frame photographed: coming up off the Savage Coast, the first dry frame painted the sky dome
/// in Stranglethorn's UNDERWATER sky stops (`LightParams` 27) over an already-dry world, and
/// [`crate::clouds`]'s surfacing full-rebuild — the fix for the 0.4 s cloud pop-in — rebuilt the
/// coverage field at the *underwater* cloud density, which Stranglethorn authors as 0.0, so it
/// rebuilt an empty sky and the clouds crept back band by band exactly as they had before that
/// fix existed.
///
/// A consumer that runs in `PostUpdate` (the material/GPU pushes, the camera-anchored follows) is
/// already after the resolve by schedule and needs nothing from this set.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LightingConsumeSet;
/// The one place [`LightingConsumeSet`] is tied to [`LightingResolveSet`] — a named function so
/// the test below locks the SAME edge the plugin installs, rather than a copy of it.
pub(crate) fn configure_lighting_sets(app: &mut App) {
app.configure_sets(Update, LightingConsumeSet.after(LightingResolveSet));
}
/// The lighting subsystem: registers the WoW-light resource + game-clock, a **black** background
/// (Phase 0 — the DBC sky comes back in a later step), and the two per-frame systems that resolve the
/// `Light.dbc` values for the time of day and push them onto the materials.
@@ -293,6 +328,8 @@ pub(crate) struct LightingPlugin;
impl Plugin for LightingPlugin {
fn build(&self, app: &mut App) {
// The read side of the resolve, configured once (see [`LightingConsumeSet`]).
configure_lighting_sets(app);
app.init_resource::<WowLighting>()
.init_resource::<PropProbes>()
.init_resource::<GameClock>()
@@ -321,3 +358,138 @@ impl Plugin for LightingPlugin {
global_light::register(app);
}
}
#[cfg(test)]
mod ordering_tests {
use super::*;
/// The edge itself, asserted the way the defect presented: **not** "did they happen to run in
/// this order" — B354's whole point is that an unordered pair *does* run in some order, one
/// the executor picks and that changes with the graph around it — but "is the order
/// DETERMINED". Two conflicting systems with no path between them are an *ambiguity*, which
/// Bevy will name when asked; deleting or inverting [`configure_lighting_sets`] puts the
/// ambiguity back and this fails.
///
/// (An earlier cut of this test asserted the observed order and passed with the edge removed,
/// because the executor's arbitrary choice happened to be the right one. That is exactly the
/// reassurance the bug was hiding behind for months.)
#[test]
fn consumers_are_ordered_against_the_resolve() {
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings, ScheduleBuildWarning};
#[derive(Resource, Default)]
struct Probe(u32);
let mut app = App::new();
app.init_resource::<Probe>();
configure_lighting_sets(&mut app);
app.edit_schedule(Update, |s| {
s.set_build_settings(ScheduleBuildSettings {
ambiguity_detection: LogLevel::Warn,
..default()
});
});
app.add_systems(
Update,
(
(|mut p: ResMut<Probe>| p.0 += 1).in_set(LightingConsumeSet),
(|mut p: ResMut<Probe>| p.0 += 1).in_set(LightingResolveSet),
),
);
app.update();
let warnings = app
.get_schedule(Update)
.expect("Update schedule")
.warnings()
.iter()
.filter(|w| matches!(w, ScheduleBuildWarning::Ambiguity(_)))
.count();
assert_eq!(
warnings, 0,
"a `LightingConsumeSet` system and a `LightingResolveSet` system are ambiguous — \
`configure_lighting_sets` no longer orders the read side after the resolve"
);
}
/// **Every `Update` reader of [`WowLighting`] in this crate joins the contract.** The defect
/// B354 photographed was not a subtle one in any single system — it was five systems that
/// each read the resolved atmosphere and none of which said when, so the executor started
/// them at the top of `Update` and they got last frame's. A named set only helps if joining
/// it is not something a sixth consumer can quietly forget, so this reads the crate's own
/// source and insists.
///
/// The allowlist is the set of readers that are after the resolve **by schedule** rather than
/// by the set — every one of them runs in `PostUpdate` or is chained onto the resolve itself.
/// Adding a name here is a claim about WHEN it runs; make it only with the reason.
///
/// Scoped to this crate's sources — `benilla-app`'s three readers are outside what a
/// `benilla-world` test can see (two are after the resolve by schedule; `minimap` is a
/// deliberate exception, decision 2032).
#[test]
fn every_update_reader_joins_the_consume_set() {
/// after-by-schedule, so the set would be redundant — file → why.
const ALLOWED: &[(&str, &str)] = &[
(
"lighting/resolve.rs",
"apply_sky_backdrop is .chain()ed onto the resolve",
),
(
"lighting/global_light.rs",
"build_light_data: PostUpdate, .after(update_time_lighting)",
),
(
"sun/follow.rs",
"the celestial follows: PostUpdate, BillboardPlace",
),
("weather/precip/mod.rs", "push_precip: PostUpdate"),
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("read_dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let text = std::fs::read_to_string(&path).expect("read source");
// A system param, not a doc mention: `Res<…WowLighting>` on one line.
let reads = text.lines().any(|l| {
!l.trim_start().starts_with("//")
&& l.contains("Res<")
&& l.contains("WowLighting>")
});
// Membership means a real `.in_set(..)` CALL, not a doc comment naming the set —
// an early cut of this check passed a file whose only mention was the comment
// explaining why it joined.
let joins = text.lines().any(|l| {
!l.trim_start().starts_with("//")
&& l.contains("in_set(")
&& l.contains("LightingConsumeSet")
});
if !reads || joins {
continue;
}
let rel = path
.strip_prefix(&root)
.expect("under src")
.to_string_lossy()
.replace('\\', "/");
if !ALLOWED.iter().any(|(f, _)| *f == rel) {
offenders.push(rel);
}
}
}
assert!(
offenders.is_empty(),
"these read `WowLighting` without joining `LightingConsumeSet`: {offenders:?}\n\
Order the system `.in_set(crate::lighting::LightingConsumeSet)`, or — if it already \
runs after the resolve by schedule (PostUpdate, or chained onto it) — add it to \
ALLOWED here with the reason."
);
}
}
+45
View File
@@ -106,6 +106,40 @@ pub(crate) use surface::{
spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource, LiquidSurface,
};
/// `WOW_FORCE_SUB=<frames>` — **hold the camera-eye verdict submerged for the first `<frames>`
/// frames, then release it.** The surfacing crossing on demand, with no server, no swim and no
/// water under the camera: everything that forks on the verdict (the atmosphere, the sky and
/// cloud domes' gates, the drift cloud, the FFX haze and warp) takes the wet→dry edge on a frame
/// this names, so a transition artefact can be photographed or logged deterministically inside
/// the capture harness (`WOW_CAPTURE=<scenario>`).
///
/// Built for B354 (decision 2032), where the whole defect lived in the ONE frame after the edge
/// and the reported spot was a dusk swim off the Savage Coast — a place a screenshot harness
/// cannot get to and a bug a still frame cannot catch. It reproduced in `water-noon` in one
/// command.
fn forced_submersion_frames() -> u32 {
std::env::var("WOW_FORCE_SUB")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0)
}
/// The [`forced_submersion_frames`] override, applied after the real probe so it overwrites a
/// genuine verdict rather than racing it. Prints the release frame, which is the frame every
/// crossing artefact is read against. Registered only when the env names a hold.
fn force_submersion(mut underwater: ResMut<Underwater>, mut frame: Local<u32>) {
let hold = forced_submersion_frames();
*frame += 1;
if *frame <= hold {
underwater.0 = benilla_formats::Submersion::Water;
} else if *frame == hold + 1 {
info!(
"WOW_FORCE_SUB: frame {} — eye verdict released to Dry",
*frame
);
}
}
/// The frame slot where [`Underwater`] is written — the label every consumer of the submersion
/// verdict orders itself `.after(..)`. The submerged view is a whole-screen swap (atmosphere, clear
/// colour, and the sky-pass suppression all flip on it), so a consumer reading a frame-old verdict
@@ -161,6 +195,17 @@ impl Plugin for LiquidPlugin {
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate)
.run_if(|| std::env::var_os("WOW_NO_LIQUID").is_some()),
);
// The scripted surfacing crossing (see [`force_submersion`]) — the node is only added
// when the env asks for it, so an ordinary run carries neither the system nor a per-frame
// run condition that would re-read the environment to say "no" 60 times a second.
if forced_submersion_frames() > 0 {
app.add_systems(
Update,
force_submersion
.after(query::detect_submersion)
.in_set(SubmersionVerdict),
);
}
// The underwater drift cloud — the one thing in this subsystem that RENDERS because the
// eye is submerged, rather than answering where the liquid is (see the layout note above).
drift::register(app);
+48 -2
View File
@@ -98,8 +98,17 @@
//! re-opaque it — since 0755 that writer carries alpha through rather than forcing it).
//! 5. `entity_shade::update_ground_shade` owns the **shade field** on entity M2 parts (decision
//! 0173): read-modify-write via [`with_shade`], never touching alpha — so it composes with 1–4
//! instead of racing them. It skips interior-classified parts (their payload is a colour) and
//! runs after 2 to re-assert the byte over 2's exterior reclaim.
//! instead of racing them, and runs after 2 to re-assert the byte over 2's exterior reclaim.
//!
//! **It must skip every part whose payload is a PROBE SLOT, and there are TWO such
//! populations, not one.** The shade byte and the probe slot overlap in bits 6..=13, so a
//! shade write into a probe payload silently renames the slot — `(slot & 0x1f00) | byte`,
//! a foreign probe or an unallocated (zeroed ⇒ **black**) row. The classifier's Bake parts
//! are one population and answer with `InteriorLit::is_bake`; a WMO doodad prop spawned with
//! its own folded probe is the other, and it holds no `InteriorLit` at all — it answers with
//! [`InteriorProbePayload`]. Asking only the first question is B373: a transport's cabin
//! furniture is parented under the GameObject for TRANSFORM reasons, so the entity light
//! node's descendant walk reached props it does not light, and every one of them went black.
//!
//! A further *payload* writer (stealth, ghost form, …) should claim reserved bits through a typed
//! accessor here — never a new ad-hoc whole-payload convention (decision 0066's rule, upheld by
@@ -111,6 +120,21 @@
//! (dropping the flag); running after them re-asserts it the same frame, so they never need to know
//! it exists.
/// **This instance's payload is the interior-probe mode** — bits 6..=18 are an SH-probe table
/// slot ([`probe_bits`]), not a ground-shade byte. Spawned onto every batch of a lit interior
/// MODD prop (`terrain_stream`'s placed-model assembler), cards included, and read by the
/// exterior-payload writer as its "hands off" (`entity_shade`).
///
/// It exists because the *other* probe-slot population — the interior classifier's Bake-law
/// entity parts — is recognised by a component the props do not carry (`InteriorLit`), and the
/// shade writer's guard asked for that one instead of asking the payload question. Where the two
/// populations meet is a WMO-display GameObject: its doodad props are parented under the net
/// entity so they ride a moving transport, which puts them inside the entity light node's
/// descendant walk while their light is their own baked MODD colour (the reference's
/// `CMapDoodadDef` provider `0x6a8050`, never the WENTITY node). See decision 2031 / bug B373.
#[derive(bevy::prelude::Component)]
pub struct InteriorProbePayload;
/// Bit 31 of the `MeshTag`: the hover/target **model-brighten** flag (the real client's
/// per-model highlight emissive — `SetHighlight 0x614550` writing the config RGB into the CM2;
/// wow-re `object-layer/scratch/selection-circle.md` PART 2). The shader adds the emissive lift
@@ -488,6 +512,28 @@ mod tests {
assert_eq!(with_exterior_reset(0) & ALPHA_MASK, ALPHA_MASK);
}
/// **Why the shade writer must never touch a probe payload** (B373): the byte lives in bits
/// 6..=13 and the slot in 6..=18, so a shade write does not *corrupt* the slot in a way
/// anything downstream can notice — it RENAMES it, to `(slot & 0x1f00) | byte`, which is a
/// perfectly well-formed index into somebody else's probe (or into an unallocated, zeroed
/// row: solid black). Pinned here because the failure has no error path anywhere: the prop
/// simply draws under another prop's light.
#[test]
fn a_shade_write_renames_a_probe_slot_instead_of_breaking_it() {
let slot = 440u16; // 0b1_1011_1000 — bits 14..=18 hold 1, bits 6..=13 hold 184
let tag = with_shade(probe_bits(slot), 191);
assert_eq!(
(tag & PROBE_MASK) >> PROBE_SHIFT,
447,
"the boat's shade byte replaced the slot's low 8 bits: 1<<8 | 191"
);
assert_eq!(
alpha_of(tag),
1.0,
"and the alpha field rode through intact"
);
}
#[test]
fn with_shade_materializes_the_untagged_sentinel_as_opaque() {
// Shading an untagged (payload 0) instance must not defeat the "0 ⇒ opaque" rule by making
+5 -1
View File
@@ -127,7 +127,11 @@ impl Plugin for SkyPlugin {
.add_systems(
Update,
(
update_sky_colors,
// The dome's stops are the resolved atmosphere, so the push belongs on the
// READ side of the resolve (`lighting::LightingConsumeSet`) — unordered it
// runs at the top of `Update` and paints last frame's palette, which on a
// surfacing frame is the underwater one (B354, decision 2032).
update_sky_colors.in_set(crate::lighting::LightingConsumeSet),
// The dome stands down for a WMO skybox, so its gate must read the SETTLED
// resolve, not whichever side of it the executor picked (`crate::skybox`).
apply_sky_visibility
+1 -1
View File
@@ -57,7 +57,7 @@ pub use window::StreamWindow;
// The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two
// outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their
// `terrain_stream::X` paths, same as the `queries` items below.
pub use spawn::prop_light::{fold_interior_probe, interior_light_up, PropLobeLight};
pub use spawn::prop_light::{fold_interior_probe, hex_word, interior_light_up, PropLobeLight};
// The shared placed-model assembler + the off-thread collider build — also the WMO-gameobject
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
@@ -617,9 +617,15 @@ pub fn spawn_model_entities(
// (`mesh_tag::probe_bits` — bits 16-29 since the 0355 re-lane): this site kept the old
// bits-0..=15 write through that re-lane, so every static interior prop read probe slot 0,
// taking whichever probe won the streaming race — the director's inn-doodad regression.
let mesh_tag = match interior_slot {
Some(slot) if interior_probe => MeshTag(crate::mesh_tag::probe_bits(slot)),
_ => MeshTag(alpha_bits(1.0)),
// ONE decision for "does this batch's payload carry a probe slot", read twice below —
// once for the tag's bits and once for the component that SAYS so
// ([`crate::mesh_tag::InteriorProbePayload`]). Two independent predicates could drift,
// and a reader that disagrees with the writer about which payload a part is on is
// exactly B373.
let probe_slot = interior_slot.filter(|_| interior_probe);
let mesh_tag = match probe_slot {
Some(slot) => MeshTag(crate::mesh_tag::probe_bits(slot)),
None => MeshTag(alpha_bits(1.0)),
};
// A billboard batch (glow card / chain) faces the camera each frame, so its transform is owned
// by the billboard system. It still distance-fades with its doodad (same `radius` band) — the
@@ -728,6 +734,16 @@ pub fn spawn_model_entities(
(entity, local_center)
};
by_batch[batch_idx] = Some(entity);
// This batch's payload is a PROBE SLOT, said as a component so the exterior-payload
// writer can see it (`mesh_tag::InteriorProbePayload`). Cards included, for the same
// reason they carry the slot at all: they are batches of the same model, shaded through
// the same light node (0778) — and `entity_shade`'s card pass reaches a card by walking
// UP from its owner, a route its descendant-walk guard never covers.
if probe_slot.is_some() {
commands
.entity(entity)
.insert(crate::mesh_tag::InteriorProbePayload);
}
// Animated material alpha (decision 0130 phase 2): the rare batch whose colour-alpha/weight
// tracks animate (fire flicker) or constantly dim gets its per-instance sampler; the
// visibility authority composes the value into the render-alpha tag + the A ≤ 0 cull.
@@ -53,10 +53,7 @@ impl PropLight {
/// as long as it did (decision 0969). `sky-lit` is the exterior lane; the interior lane prints
/// the MODD-colour words it actually commits, so a base of `#000000` names itself on hover.
pub(crate) fn inspector_label(&self) -> String {
let hex = |c: &[f32; 3]| {
let b = c.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8);
format!("#{:02x}{:02x}{:02x}", b[0], b[1], b[2])
};
let hex = |c: &[f32; 3]| hex_word(*c);
match self {
PropLight::Exterior => "sky-lit".into(),
PropLight::Interior {
@@ -73,6 +70,14 @@ impl PropLight {
}
}
/// A committed light word (0–1 RGB) as `#rrggbb` — the reading every prop inspector prints, here
/// once so the terrain lane's label and the WMO-gameobject lane's (`entities::wmo_props`) cannot
/// drift into two different renderings of the same number.
pub fn hex_word(c: [f32; 3]) -> String {
let b = c.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8);
format!("#{:02x}{:02x}{:02x}", b[0], b[1], b[2])
}
/// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour
/// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the
/// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict).