diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 13f31ab8..27c40f8c 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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 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 , + -- after every OnLoad has already run. Fired from the tabs' , 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). --> diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 7cc40392..c47ff7d6 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -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(); \ diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 8b3edc4e..51fefee7 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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; diff --git a/crates/benilla-app/src/capture/pick_probe.rs b/crates/benilla-app/src/capture/pick_probe.rs index f0b26de2..562a8989 100644 --- a/crates/benilla-app/src/capture/pick_probe.rs +++ b/crates/benilla-app/src/capture/pick_probe.rs @@ -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, diff --git a/crates/benilla-app/src/capture/probe_crossing.rs b/crates/benilla-app/src/capture/probe_crossing.rs index 4ed06670..8253091f 100644 --- a/crates/benilla-app/src/capture/probe_crossing.rs +++ b/crates/benilla-app/src/capture/probe_crossing.rs @@ -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::()); - 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}) \ diff --git a/crates/benilla-app/src/capture/probe_model_camera.rs b/crates/benilla-app/src/capture/probe_model_camera.rs new file mode 100644 index 00000000..03a73f14 --- /dev/null +++ b/crates/benilla-app/src/capture/probe_model_camera.rs @@ -0,0 +1,357 @@ +//! `WOW_PROBE_MODEL_CAMERA=1` — the live probe for the `` 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 ``'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::() + .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, + 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 { + 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, + >, + roots: &Query<(&GlobalTransform, &RenderLayers), With>, +) -> Option { + 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, + script: Option>, + tiles: Res, + self_player: Query<(), With>, + cams: Query< + (&Camera, &GlobalTransform, &Projection, &RenderLayers), + With, + >, + roots: Query<(&GlobalTransform, &RenderLayers), With>, + mut exit: MessageWriter, +) { + 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} 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); + }); + } + } +} diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 687f6ebb..74a16d63 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -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 `` 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 diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index ea4e298d..9a9a1e1d 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -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, diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index c4124aaa..0f42eb16 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -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 diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index bb8ac963..125126d9 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -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 diff --git a/crates/benilla-app/src/portrait/framing.rs b/crates/benilla-app/src/portrait/framing.rs index e7609f0f..a9b43d04 100644 --- a/crates/benilla-app/src/portrait/framing.rs +++ b/crates/benilla-app/src/portrait/framing.rs @@ -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 { diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index e2633396..eb2a4ee3 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -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, Handle, >| { - 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 = pet .parts diff --git a/crates/benilla-app/src/portrait/light.rs b/crates/benilla-app/src/portrait/light.rs index 2c4b4680..fea6eb35 100644 --- a/crates/benilla-app/src/portrait/light.rs +++ b/crates/benilla-app/src/portrait/light.rs @@ -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 `` pane that never armed any. + RigUnfogged, + /// The rig lane with the batch's **authored** fog policy kept — a `` 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, materials: &mut Assets, - rig: bool, + lane: VariantLane, ) -> Option> { 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 diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index f374ea8f..505f5845 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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 `` 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 `` 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!( diff --git a/crates/benilla-app/src/transport.rs b/crates/benilla-app/src/transport.rs index a92c675c..668dcd2e 100644 --- a/crates/benilla-app/src/transport.rs +++ b/crates/benilla-app/src/transport.rs @@ -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 { + ) -> Option<(TransportSample, Option)> { 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 { diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs index 3b4781f7..339e6204 100644 --- a/crates/benilla-app/src/ui_chat/settings.rs +++ b/crates/benilla-app/src/ui_chat/settings.rs @@ -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 diff --git a/crates/benilla-app/src/ui_layout.rs b/crates/benilla-app/src/ui_layout.rs index d577a47a..48f175af 100644 --- a/crates/benilla-app/src/ui_layout.rs +++ b/crates/benilla-app/src/ui_layout.rs @@ -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 { /// 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>, roster: Res, mut file: ResMut, @@ -239,18 +269,15 @@ fn watch_layout(script: Option>, mut file: ResMut>, - mut file: ResMut, - mut exits: MessageReader, -) { - 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>, mut file: ResMut) { 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>, mut file: ResMut) { - 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::() - .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::().add_systems( + Update, + (load_layout, watch_layout, save_layout) + .chain() + .run_if(in_state(crate::char_select::ClientState::InWorld)), + ); } } diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index 5d7b2cf6..2cc924dd 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -4,6 +4,14 @@ //! //! ## The law (wow-re `system/ui/scratch/modelframe-render-law.md`, `e1b1794b`) //! +//! A `` **whose file supplies a camera** draws its scene through it: the widget holds a raw +//! camera index (ctor 0, `SetCamera(n)`), and the record's `lookAt(eye, target, up)` plus the +//! client's diagonal-FOV projection at the pane's own width/height frame the model (§2, camera-law +//! §4a). The authored eye and target are carried through the model's root transform, so +//! `SetModelScale` and `SetPosition` cancel for framing on that leg and bite only on the record's +//! unscaled near/far. An index past the file's camera count installs a NULL camera, which is the +//! other leg: +//! //! A `` with no M2 camera picked draws its scene **orthographically over the frame's //! rect**: origin at the rect's bottom-left, `+X` right, `+Y` up, `Z` depth only, with the root //! matrix `T(pos · layoutScale) · R(facing, +Z) · S(G48 · 5/3 · modelScale · layoutScale)` — so one @@ -45,14 +53,31 @@ //! so the `AnimationPlayer` is paused and seeked rather than advanced, and every per-sequence //! material track is sampled off that same cursor. //! +//! ## The two legs in this shape +//! +//! The **orthographic** leg is the atlas's one camera: every pane on it shares that camera and its +//! layer, and its cell is where its root is placed. The **perspective** leg cannot share a camera +//! with anything — the projection and the view are the pane's own — so a perspective pane takes a +//! slot of a small pool ([`UI_MODEL_CAM_LAYERS`]) and gets a camera whose **viewport is its cell of +//! the same atlas** and whose layer is its own. That is the reference's own structure: one target, +//! one viewport per widget, depth cleared per widget. The orthographic camera runs first and is +//! the one that CLEARS the atlas; the perspective cameras load into it and clear only depth. And +//! because the perspective root sits in model space rather than at a cell, each of those cameras +//! needs a layer of its own or it would draw every other perspective pane over its own cell. +//! //! ## What a tile's light is //! //! ``'s embedded light is DISABLED (§5.2), and a LIT batch under no light renders black — //! which never shows on the shipped UI M2s because all of them are unlit, and which is the -//! faithful answer for an addon's lit one. So the tile light buffer is a black light (no -//! ambient, no diffuse, fog off) and unlit batches bypass it. `SetLight` is stored by the engine -//! and not read here (the reference's `SetLight(0, …)` is a no-op too; the enabled form has no -//! caller in 1.12's FrameXML). +//! faithful answer for an addon's lit one. So the DEFAULT tile light buffer is a black light (no +//! ambient, no diffuse, fog off) and unlit batches bypass it. +//! +//! A pane that arms one — `SetLight(1, …)`, or fog through `SetFogColor` — takes a slot of the +//! light pool instead ([`TileRig`]): its own buffer, its own material twins, its scene folded the +//! way the reference's collector folds it (a directional light into probe slot 0's SH, a point +//! light into the point table, the fog into rows 4/5 with the batch's authored `UNFOGGED` bit left +//! to do its own work). Nothing in the shipped interface arms either, so the pool normally holds +//! exactly the one buffer the tiles have always had. //! //! ## Texture transforms (decision 2019) //! @@ -63,11 +88,16 @@ //! The shader composes them as the reference does — `uv' = R((uv + t − p) ⊙ s) + p` — which is //! how the cooldown indicator's four quadrant quads turn their mask into the clockwise sweep. //! -//! ## What is deliberately NOT here yet +//! ## What is deliberately NOT here //! -//! - The **perspective leg** (`SetCamera(n)` naming a real M2 camera; the character panes): a -//! plain `` in the shipped interface never picks one, and the character panes keep -//! their booths. Named, not built. +//! - The **character panes** (`PlayerModel`/`DressUpModel`/`TabardModel`) keep their booths +//! (`crate::portrait`): the reference frames those through raw camera 1 **frozen** at load, or +//! a fixed fallback camera, which is a different selection and a different lifecycle from this +//! widget's live one. +//! - A pane's fog does not reach its **particles**: the effect lane carries its own fog enum and +//! reads its span from a render-world params uniform rather than from the light buffer, so a +//! fogged pane's cloud draws unfogged. No shipped UI M2 with an emitter is fogged (the shine +//! and the pings are UNFOGGED or unlit anyway), and the honest fix is on the effect lane. use std::collections::HashMap; use std::sync::Arc; @@ -83,7 +113,7 @@ use benilla_assets::materials::WowModelMaterial; use benilla_assets::{m2_url, quantize, M2Model, WorldAssets}; use benilla_formats::{SeqLoops, UvAnim}; use benilla_ui::script::{ModelPaneFrame, UiScript}; -use benilla_ui::widget::{FrameHandle, ModelFileFacts, SequenceFacts}; +use benilla_ui::widget::{FrameHandle, ModelFileFacts, ModelFog, ModelLight, SequenceFacts}; use benilla_world::doodad_anim::spawn_anim_host; use benilla_world::lighting::LightBlob; use benilla_world::mat_anim_table::{affine_row, MatAnimMirrors, MatAnimTable}; @@ -97,7 +127,9 @@ use benilla_world::rig_anim::{GlobalSeqDrive, RigPose}; use benilla_world::rig_palette::{RigPaletteMirrors, RigPalettes, RigPart, RigSkin}; use crate::portrait::{ - booth_view_shape, material_variant, new_target_image_sized, StageRig, UI_MODELS_LAYER, + booth_view_shape, material_variant, new_target_image_sized, pane_projection, StageRig, + VariantLane, WowPortraitProjection, UI_MODELS_LAYER, UI_MODEL_CAM_LAYERS, + UI_MODEL_CAM_LAYER_BASE, }; use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect}; @@ -136,6 +168,23 @@ pub(crate) struct TileRequest { pub facing: f32, /// `SetPosition`, layout units. pub position: Vec3, + /// The **perspective** leg's root scale (decision 2027): `s = G48 · (5/3) · modelScale · + /// layoutScale`, a pure number — that leg has no pixels-per-unit, because its projection is + /// the file's own camera and its viewport is the cell. Both this and [`Self::root_pos`] + /// cancel for framing (the camera is carried through the same matrix) and bite only on the + /// record's unscaled near/far. + pub root_scale: f32, + /// The perspective leg's root translation: `SetPosition · layoutScale`, model units. + pub root_pos: Vec3, + /// **Which leg.** The installed camera as a raw index into the file's table, or `None` for + /// the NULL camera — the orthographic leg, which is every shipped in-game pane. Resolved by + /// the engine against the file's camera count, exactly where `0x76cec0` resolves it. + pub camera: Option, + /// The pane's embedded `CGLight` — disabled on a fresh ``, which is what makes a LIT + /// batch draw black. + pub light: ModelLight, + /// The pane's fog, only when armed. + pub fog: Option, /// `ReplaceIconTexture`'s path — the type-14 batches' texture. pub icon: Option, /// The pane's rect on the window — y-down logical px, the quad pass's space — where the @@ -175,22 +224,168 @@ pub(crate) struct UiModelTiles { pub dpi: f32, } -/// The renderer's own fixed state: the camera, its layer, the black light buffer and the -/// per-material twin cache against it. -#[derive(Resource)] -struct TileRig { - layer: RenderLayers, - light: Option, +/// A pane's whole light input: its embedded `CGLight` and its armed fog. Two panes with the same +/// scene share one light buffer and one material-twin cache; the DEFAULT scene — a disabled light +/// and no fog — is every shipped in-game pane, and it is slot 0. +#[derive(Clone, Copy, PartialEq)] +struct TileScene { + light: ModelLight, + fog: Option, +} + +impl TileScene { + /// The `` ctor's scene: white, **disabled**, unfogged. What every pane the shipped + /// interface draws holds, and what makes a LIT batch render black. + fn default_scene() -> Self { + Self { + light: ModelLight::default(), + fog: None, + } + } + + /// The light buffer this scene wants — the reference's collector, finalized. + /// + /// The collector is zeroed every frame and gathers only what the fill callback `0x76d680` + /// stages (render law §5.5): the fog when it is armed, and the light when it is **enabled**. + /// With the light off, the finalize writes zero ambient and zero diffuse and a LIT batch draws + /// black — the reference's answer, not a gap. With it on, the type decides which arm: + /// directional folds ambient + one diffuse lobe through the SH accumulators + /// (`0x71bc70`/`0x71bce0`), point drops into the ≤4-nearest heap and contributes **no** + /// ambient (wow-re `glue-model-lighting.md` §16 B1). + /// + /// The lit lane here is the rig one, so the ambient and the lobe go into **probe slot 0** — + /// the slot every tile part's `MeshTag` names — exactly as the glue booth's own scene blob + /// does; rows 0-2 are dead on that lane. + fn blob(&self) -> LightBlob { + let l = self.light; + let (ambient, lobes, point) = match (l.enabled, l.omni) { + (false, _) => ([0.0; 3], Vec::new(), None), + // Type 1 (point/omni): the position is model space, the falloff is the shared + // `1/(0.7d + 0.03d²)` the CGLight ctor's attenuation names and the point lane + // already implements — so no range gate, the falloff does the bounding. + (true, true) => ( + [0.0; 3], + Vec::new(), + Some((benilla_assets::coords::wow_to_bevy(l.vector), l.diffuse)), + ), + // Type 0 (directional): `CGLight+0x24` is the direction the light **PROPAGATES**, + // and an SH lobe is centred on the TOWARD-light vector — so it goes in negated. + // That sign is `0x71bce0`'s own: the moments at `collector+0x18…+0x50` take `d` as it + // is, and then three `fchs` (`71be7c`/`71be81`/`71be86`) negate it before the nine SH + // basis terms are built from it. The two families take opposite signs off the one + // field, which is exactly how two careful readers can disagree about "the direction" + // without either misreading a byte; the lobe's is the negated one. + (true, false) => ( + l.ambient, + vec![( + (-benilla_assets::coords::wow_to_bevy(l.vector)).normalize_or_zero(), + l.diffuse, + )], + None, + ), + }; + let mut blob = LightBlob::model(ambient, [0.0; 3], Vec3::NEG_Y).probe(ambient, &lobes); + if let Some((pos, colour)) = point { + blob = blob.point(pos, POINT_RANGE, colour); + } + match self.fog { + Some(f) => blob.fog_span(f.rgb(), f.near, f.far, true), + None => blob.fog_span([0.0; 3], 0.0, 0.0, false), + } + } +} + +/// One light rig of the pool: the scene it was built for, its GPU buffer, the mirror key it +/// registered under, and the material twins bound to it. +struct TileLight { + scene: TileScene, + buffer: Buffer, variants: HashMap, Handle>, } -/// Marks the tile camera. +/// The candidacy range packed with a pane's point light. The reference's gather has no range gate +/// at all (a sorted ≤4-nearest heap), so this is effectively unbounded and the `1/(0.7d + 0.03d²)` +/// falloff does the bounding — the same number and the same reason as the glue scene's. +const POINT_RANGE: f32 = 1.0e6; + +/// The mirror keys the light pool registers under, one per slot. Static because both mirror maps +/// are keyed by `&'static str`, and monotone because a key is only ever ADDED: the mat-anim +/// upload gates on the mirror count, so a set that grew and shrank in one frame could skip an +/// upload and leave a fresh buffer's rows at the identity. +const LIGHT_MIRROR_KEYS: [&str; UI_MODEL_CAM_LAYERS] = [ + "ui_models", + "ui_models_1", + "ui_models_2", + "ui_models_3", + "ui_models_4", + "ui_models_5", + "ui_models_6", + "ui_models_7", +]; + +/// The renderer's own fixed state: the ortho tiles' layer, and the light pool. +/// +/// **The pool is lazily grown and capped.** Slot 0 is the default scene and is built at startup; +/// a pane that arms `SetLight(1, …)` or fog takes the next free slot, and a scene past the cap +/// falls back to slot 0 (drawn as if it had armed nothing). Every slot costs a whole shared-light +/// buffer — several megabytes, most of it the palette and probe regions it must carry to be +/// bindable at all — so the pool is not a per-pane map: the shipped interface arms neither verb +/// on any pane, so in practice it holds exactly the one buffer the tiles have always had. +#[derive(Resource)] +struct TileRig { + layer: RenderLayers, + lights: Vec, +} + +impl TileRig { + /// The pool slot for `scene`, growing the pool if it is new and there is room; `0` (the + /// default scene's) when there is not. + fn slot_for( + &mut self, + scene: TileScene, + device: &RenderDevice, + queue: &RenderQueue, + mirrors: &mut RigPaletteMirrors, + anim_mirrors: &mut MatAnimMirrors, + ) -> usize { + if let Some(i) = self.lights.iter().position(|l| l.scene == scene) { + return i; + } + if self.lights.len() >= UI_MODEL_CAM_LAYERS { + warn!("ui_models: the light pool is full — a pane's SetLight/fog is not applied"); + return 0; + } + let key = LIGHT_MIRROR_KEYS[self.lights.len()]; + let blob = scene.blob(); + let buffer = blob.create(device, "wow_ui_model_light"); + blob.write(queue, &buffer); + mirrors.0.insert(key, buffer.clone()); + anim_mirrors.0.insert(key, buffer.clone()); + self.lights.push(TileLight { + scene, + buffer, + variants: HashMap::new(), + }); + self.lights.len() - 1 + } +} + +/// Marks the tile atlas's one **orthographic** camera — the leg every pane without a file camera +/// takes, and the camera that CLEARS the atlas (which is why it stays active whenever any cell is +/// packed, even with no ortho tile on it: the perspective cameras load, they never clear). #[derive(Component)] struct TileCamera; -/// Marks a tile's root entity (the model root, at its cell). +/// One of the perspective pool's cameras — `slot` is its layer and its order offset, held for the +/// life of the app and aimed at whichever pane holds the slot this frame. #[derive(Component)] -struct TileRoot; +pub(crate) struct TilePerspectiveCamera { + slot: usize, +} + +/// Marks a tile's root entity (the model root, at its cell or in its own perspective layer). +#[derive(Component)] +pub(crate) struct TileRoot; /// One batch of a built tile whose alpha the file animates: sampled here off the pane's play /// head (a hosted `MatAnim` would read the paused player, which has no node for a sequence @@ -284,6 +479,14 @@ struct Tile { m2: Handle, /// The tree is spawned (parts, rig, emitters) — until then the root is bare. built: bool, + /// The light-pool slot the tree's materials and emitters are bound to. A pane whose light or + /// fog changes lands on a different slot, and the tree is rebuilt against it — a material + /// twin points at exactly ONE light buffer. + light_slot: usize, + /// The perspective pool slot this tile holds, and therefore its render layer — `None` for an + /// orthographic tile, which shares the atlas camera's one layer. Also a rebuild key: the + /// layer is on every spawned entity. + cam_slot: Option, /// The graph node per `AnimationData` id the file keys a bone for, and its file slot. clips: HashMap, /// Which id the player is currently arming (to re-arm only on change). @@ -342,7 +545,8 @@ impl Plugin for UiModelsPlugin { .add_systems(Update, sync_tiles.after(crate::ui_script::UiInput)) // The composite: this frame's cells, appended in the lane the minimap fill uses — // after the cells are packed, before the mesh rebuild reads the lane. - .add_systems(Update, compose_tiles.in_set(UiQuadAppend).after(sync_tiles)); + .add_systems(Update, compose_tiles.in_set(UiQuadAppend).after(sync_tiles)) + .add_systems(Update, reap_tile_variants); } } @@ -354,18 +558,26 @@ fn setup_tiles( mut mirrors: ResMut, mut anim_mirrors: ResMut, ) { - // ``'s embedded light is disabled: no ambient, no diffuse, no fog (render law §5.2). - // The direction is irrelevant at zero intensity; the builder wants one. - let blob = LightBlob::model([0.0; 3], [0.0; 3], Vec3::NEG_Y); - let light = blob.create(&device, "wow_ui_model_light"); - blob.write(&queue, &light); - // Tile rigs skin from THIS buffer's palette region (decision 0720's mirror law), and the - // tiles' animated materials read their mat-anim rows from it too (decision 2023): a twin - // binds this buffer, not the world's, so the table has to be mirrored here or the rows the - // tiles write every frame reach a buffer nothing in a tile ever samples. - mirrors.0.insert("ui_models", light.clone()); - anim_mirrors.0.insert("ui_models", light.clone()); let layer = RenderLayers::layer(UI_MODELS_LAYER); + let mut rig = TileRig { + layer: layer.clone(), + lights: Vec::new(), + }; + // Slot 0 — the `` ctor's scene: light disabled, fog off (render law §5.2). Built here + // rather than lazily because every shipped pane wants it and nothing else ever does. + // + // Tile rigs skin from a light buffer's palette region (decision 0720's mirror law), and the + // tiles' animated materials read their mat-anim rows from it too (decision 2023): a twin + // binds ITS buffer, not the world's, so every buffer the pool creates joins both mirror + // lists or the rows the tiles write every frame reach a buffer nothing in a tile samples. + rig.slot_for( + TileScene::default_scene(), + &device, + &queue, + &mut mirrors, + &mut anim_mirrors, + ); + let light_buf = rig.lights[0].buffer.clone(); commands.spawn(( Name::new("ui model tiles camera"), booth_view_shape(), @@ -394,11 +606,32 @@ fn setup_tiles( layer.clone(), TileCamera, )); - commands.insert_resource(TileRig { - layer, - light: Some(light), - variants: HashMap::new(), - }); + // The perspective pool: one camera per slot, each on its own layer, each parked inactive + // until a pane takes the slot. They are built once here rather than spawned on demand — a + // camera that appears mid-frame misses the render world's extract, and a pane that took its + // slot this frame would draw nothing until the next. + for slot in 0..UI_MODEL_CAM_LAYERS { + commands.spawn(( + Name::new(format!("ui model pane camera {slot}")), + booth_view_shape(), + Camera { + order: TILE_CAMERA_ORDER + 1 + slot as isize, + // **Load, never clear.** These share the atlas with the orthographic camera, + // which runs first (a lower order) and clears the whole target; a second clear + // here would wipe the cells already drawn into it. Depth is a different matter + // and clears per camera by default, which is the reference's own per-widget + // depth clear (`76d5e1 GxClear(2)` inside the widget's viewport). + clear_color: ClearColorConfig::None, + is_active: false, + ..default() + }, + benilla_world::ffx_glow::FfxGlow::UI_PANE, + RenderLayers::layer(UI_MODEL_CAM_LAYER_BASE + slot), + TilePerspectiveCamera { slot }, + )); + } + commands.insert_resource(rig); + let _ = light_buf; } /// The bevy-space → tile-camera-space rotation: WoW `+X` (bevy `−Z`) to the right, WoW `+Y` @@ -433,6 +666,12 @@ struct TileRender<'w> { rig: ResMut<'w, TileRig>, /// The shared mat-anim table: the tiles' animated materials own rows in it (2019). table: ResMut<'w, MatAnimTable>, + /// The light pool grows lazily, so the device, the queue and both mirror lists have to be + /// reachable from the per-frame pass, not only from startup. + device: Res<'w, RenderDevice>, + queue: Res<'w, RenderQueue>, + mirrors: ResMut<'w, RigPaletteMirrors>, + anim_mirrors: ResMut<'w, MatAnimMirrors>, } /// The per-frame pass: feed the engine the facts it asked for, keep one tile per visible pane, @@ -452,7 +691,17 @@ fn sync_tiles( &mut Projection, &mut Transform, ), - With, + (With, Without), + >, + mut pane_cams: Query< + ( + &mut Camera, + &mut RenderTarget, + &mut Projection, + &mut Transform, + &TilePerspectiveCamera, + ), + Without, >, mut roots: Query< ( @@ -460,9 +709,20 @@ fn sync_tiles( &mut Visibility, Option<&mut AnimationPlayer>, ), - (With, Without), + ( + With, + Without, + Without, + ), + >, + mut parts: Query< + (&mut MeshTag, &mut Visibility), + ( + Without, + Without, + Without, + ), >, - mut parts: Query<(&mut MeshTag, &mut Visibility), (Without, Without)>, mut emitters: Query<&mut ParticleEmitter>, ) { state.frame += 1; @@ -474,6 +734,9 @@ fn sync_tiles( } bridge.cells.clear(); set_camera_active(&mut cams, false); + for (mut cam, _, _, _, _) in &mut pane_cams { + cam.is_active = false; + } return; }; @@ -531,7 +794,54 @@ fn sync_tiles( // cell across frames. let _ = &live; - for (handle, req, _) in &live { + // ── 2a. Which light rig, and which perspective slot ───────────────────────────────── + // + // The light pool dedups by scene, so every default pane — which is every shipped one — + // lands on slot 0 and shares one buffer and one twin cache, exactly as before 2027. + let light_slots: Vec = live + .iter() + .map(|(_, req, _)| { + render.rig.slot_for( + TileScene { + light: req.light, + fog: req.fog, + }, + &render.device, + &render.queue, + &mut render.mirrors, + &mut render.anim_mirrors, + ) + }) + .collect(); + // A perspective pane needs a camera of its own, and therefore a layer of its own. Slots are + // sticky: a pane that already holds one keeps it, so a tile is not rebuilt every frame for + // its layer, and the free ones go to whoever is new. A pane past the pool draws nothing — + // the same degrade as a tile that does not fit the capped atlas. + let mut taken = [false; UI_MODEL_CAM_LAYERS]; + let mut cam_slots: Vec> = live + .iter() + .map(|(h, req, _)| { + let held = state.tiles.get(h).and_then(|t| t.cam_slot); + match (req.camera.is_some(), held) { + (true, Some(slot)) if !taken[slot] => { + taken[slot] = true; + Some(slot) + } + (true, _) => None, + (false, _) => None, + } + }) + .collect(); + for (i, (_, req, _)) in live.iter().enumerate() { + if req.camera.is_none() || cam_slots[i].is_some() { + continue; + } + cam_slots[i] = taken.iter().position(|&t| !t).inspect(|&slot| { + taken[slot] = true; + }); + } + + for (i, (handle, req, _)) in live.iter().enumerate() { let key = benilla_ui::widget::model_key(&req.path); let Some(m2) = state.loaded.get(&key).cloned() else { if trace_on() { @@ -539,21 +849,25 @@ fn sync_tiles( } continue; // facts not landed ⇒ the engine would not have listed it; defensive }; - let stale = state - .tiles - .get(handle) - .is_some_and(|t| t.key != key || t.icon != req.icon); + let (light_slot, cam_slot) = (light_slots[i], cam_slots[i]); + let stale = state.tiles.get(handle).is_some_and(|t| { + t.key != key + || t.icon != req.icon + || t.light_slot != light_slot + || t.cam_slot != cam_slot + }); if stale { if let Some(t) = state.tiles.remove(handle) { t.retire(&mut commands, &mut render.table); } } + let layer = tile_layer(&render.rig, cam_slot); let tile = state.tiles.entry(*handle).or_insert_with(|| Tile { root: commands .spawn(( Transform::IDENTITY, Visibility::Hidden, - render.rig.layer.clone(), + layer.clone(), TileRoot, )) .id(), @@ -561,6 +875,8 @@ fn sync_tiles( icon: req.icon.clone(), m2: m2.clone(), built: false, + light_slot, + cam_slot, clips: HashMap::new(), armed: None, alpha_parts: Vec::new(), @@ -586,6 +902,8 @@ fn sync_tiles( &mut assets.forms, &mut assets.meshes, &mut render, + light_slot, + &layer, ) { info!( "ui_models: tile built for {} — {} parts, {} emitters, {} animated alphas", @@ -635,7 +953,15 @@ fn sync_tiles( // ── 4. Pack the atlas ─────────────────────────────────────────────────────────────── let drawing: Vec<&(FrameHandle, TileRequest, ModelPaneFrame)> = live .iter() - .filter(|(h, _, _)| state.tiles.get(h).is_some_and(|t| t.built)) + .filter(|(h, r, _)| { + state.tiles.get(h).is_some_and(|t| { + // A pane that wants the perspective leg and found no free camera holds no cell and + // draws nothing — the same degrade as a tile that does not fit the capped atlas. + // Falling through to the orthographic placement would draw it at that leg's pixel + // ladder, which for a creature is a wall of fur. + t.built && !(r.camera.is_some() && t.cam_slot.is_none()) + }) + }) .collect(); let sizes: Vec = drawing.iter().map(|(_, r, _)| r.size_px).collect(); let (cells, atlas_size) = pack(&sizes); @@ -672,6 +998,7 @@ fn sync_tiles( // ── 5. Place every drawing tile: cell, unit ladder, facing, play head ─────────────── let mut hidden: Vec = state.tiles.values().map(|t| t.root).collect(); + let mut aimed = [false; UI_MODEL_CAM_LAYERS]; for (i, (handle, req, pane)) in drawing.iter().enumerate() { let Some(cell) = cells.get(i).copied() else { continue; // did not fit the capped atlas @@ -684,23 +1011,9 @@ fn sync_tiles( let Ok((mut tf, mut vis, player)) = roots.get_mut(tile.root) else { continue; }; - // The root: the cell's bottom-left in camera space, plus `SetPosition` in layout units. - let cell_bl = Vec2::new( - cell.origin.x as f32, - atlas_h - (cell.origin.y + cell.size.y) as f32, - ); - let pos = Vec2::new(req.position.x, req.position.y) * req.pos_px_per_unit; - let depth = req.position.z * req.pos_px_per_unit; - // `T(pos) · R(facing about WoW +Z) · S(px per unit)`, in camera space: the facing turns - // about bevy `+Y` (WoW's `+Z` after `wow_to_bevy`), then the axis fix, then the scale. - *tf = Transform { - translation: Vec3::new(cell_bl.x + pos.x, cell_bl.y + pos.y, depth), - rotation: wow_to_screen() * Quat::from_rotation_y(req.facing), - scale: Vec3::splat(req.px_per_unit), - }; - *vis = Visibility::Visible; - // The play head: the engine's cursor drives the paused player and every alpha track. + // The play head: the engine's cursor drives the paused player, every alpha track, every + // material row — and, on the perspective leg, the camera's own authored path. let (armed, cursor_s, seq_slot) = match pane.play { Some(ph) => { let slot = tile.clips.get(&ph.anim_id).map(|&(_, s)| s); @@ -708,6 +1021,99 @@ fn sync_tiles( } None => (None, 0.0, None), }; + + // ── The leg ──────────────────────────────────────────────────────────────────── + // + // PERSPECTIVE (render law §2, camera-law §4a): the file's own camera record frames the + // pane, through a camera of this tile's own whose viewport is the tile's cell. The root + // is `T(pos·layoutScale) · R(facing) · S(s)` in MODEL units — no pixel ladder, because + // the projection is the record's and the viewport is the cell — and the authored + // eye/target are carried through that same matrix, which is what `0x718960`'s publish + // does and why `SetModelScale` and `SetPosition` cancel for framing here. + // + // ORTHOGRAPHIC: unchanged since 2013 — the model laid over the cell at + // `1280 · modelScale · layoutScale` FrameXML units per model unit. + let perspective = tile.cam_slot.zip(req.camera).and_then(|(slot, idx)| { + let cam = assets.m2s.get(&tile.m2)?.cameras.get(idx as usize)?; + Some((slot, cam.clone())) + }); + let mut leg_trace = String::from("ortho"); + if let Some((slot, cam)) = perspective { + // The camera's tracks read the file's ABSOLUTE timeline, like every other M2 track, + // while the pane's play head is a cursor inside the armed band — so the band start + // is what turns one into the other. Static on every camera a shipped pane can name. + let file_ms = seq_slot + .and_then(|slot| assets.m2s.get(&tile.m2)?.sequences.get(slot)) + .map_or(0, |seq| seq.start_ms) + + pane.play.map_or(0, |ph| ph.cursor_ms); + let record = cam.at(file_ms); + let aspect = req.size_px.x as f32 / req.size_px.y.max(1) as f32; + let leg = perspective_rig(&record, req, aspect); + *tf = leg.root; + *vis = Visibility::Visible; + for (mut c, mut target_ref, mut proj, mut ctf, marker) in &mut pane_cams { + if marker.slot != slot { + continue; + } + // The target FIRST, and the activation only if there is one: these cameras are + // spawned with the default render target, which is the primary window — an + // active one without the atlas installed would draw the model over the game. + let Some(atlas) = bridge.atlas.clone() else { + continue; + }; + *target_ref = RenderTarget::Image(atlas.into()); + aimed[slot] = true; + c.is_active = true; + c.viewport = Some(bevy::camera::Viewport { + physical_position: cell.origin, + physical_size: cell.size, + depth: 0.0..1.0, + }); + *proj = Projection::custom(leg.projection.clone()); + *ctf = leg.camera; + } + if trace_on() { + use bevy::camera::CameraProjection; + let m = leg.projection.get_clip_from_view(); + let eye = leg.camera.translation; + let fwd = leg.camera.forward().as_vec3(); + leg_trace = format!( + "persp[cam {:?} slot {slot} t={file_ms}ms] fov={:.5} aspect={aspect:.4} near={:.4} far={:.3} eye={:.3},{:.3},{:.3} fwd={:.3},{:.3},{:.3} root(s={:.4} pos={:?}) m00={:.4} m11={:.4}", + req.camera, + record.fov, + record.near, + record.far, + eye.x, + eye.y, + eye.z, + fwd.x, + fwd.y, + fwd.z, + req.root_scale, + req.root_pos.to_array(), + m.x_axis.x, + m.y_axis.y, + ); + } + } else { + // The root: the cell's bottom-left in camera space, plus `SetPosition` in layout + // units. + let cell_bl = Vec2::new( + cell.origin.x as f32, + atlas_h - (cell.origin.y + cell.size.y) as f32, + ); + let pos = Vec2::new(req.position.x, req.position.y) * req.pos_px_per_unit; + let depth = req.position.z * req.pos_px_per_unit; + // `T(pos) · R(facing about WoW +Z) · S(px per unit)`, in camera space: the facing + // turns about bevy `+Y` (WoW's `+Z` after `wow_to_bevy`), then the axis fix, then + // the scale. + *tf = Transform { + translation: Vec3::new(cell_bl.x + pos.x, cell_bl.y + pos.y, depth), + rotation: wow_to_screen() * Quat::from_rotation_y(req.facing), + scale: Vec3::splat(req.px_per_unit), + }; + *vis = Visibility::Visible; + } if let Some(mut player) = player { if tile.armed != armed { player.stop_all(); @@ -736,6 +1142,15 @@ fn sync_tiles( facts_slot }); let gseq_s = pane.clock_ms as f64 / 1000.0; + let light_trace = if trace_on() { + let sc = &render.rig.lights[tile.light_slot].scene; + format!( + "slot{} enabled={} omni={} fog={:?}", + tile.light_slot, sc.light.enabled, sc.light.omni, sc.fog + ) + } else { + String::new() + }; let mut trace_alphas: Vec = Vec::new(); for part in &tile.alpha_parts { let a = part.anim.sample(seq_slot, cursor_s, gseq_s); @@ -773,9 +1188,10 @@ fn sync_tiles( }) .collect(); info!( - "tile-trace: {} {} cell=({},{} {}x{}) px/unit={:.2} pos_px/unit={:.2} armed={:?} cursor={:.3}s slot={:?} clock={}ms alphas={:?} rows=[{}]", + "tile-trace: {} {} leg={} cell=({},{} {}x{}) px/unit={:.2} pos_px/unit={:.2} armed={:?} cursor={:.3}s slot={:?} clock={}ms light={light_trace} alphas={:?} rows=[{}]", req.path, script.frame_name(*handle).unwrap_or_default(), + leg_trace, cell.origin.x, cell.origin.y, cell.size.x, @@ -790,9 +1206,18 @@ fn sync_tiles( rows.join(", ") ); } + // A particle's half-extent is added in EYE space, so its unit is the leg's: the + // orthographic leg measures the cell in pixels and hands it `768·√(a²+1)` FrameXML units + // per model unit (§6, carrying neither scale), while the perspective leg's eye space IS + // the root's, and its own projection does the conversion — the world's plain 1.0. + let star = if tile.cam_slot.is_some() && req.camera.is_some() { + 1.0 + } else { + req.star_px_per_unit + }; for &e in &tile.emitters { if let Ok(mut em) = emitters.get_mut(e) { - em.set_size_scale(req.star_px_per_unit); + em.set_size_scale(star); } } } @@ -803,12 +1228,110 @@ fn sync_tiles( } } } + for (mut cam, _, _, _, marker) in &mut pane_cams { + let want = aimed[marker.slot]; + if cam.is_active != want { + cam.is_active = want; + } + } + // The ORTHOGRAPHIC camera stays on whenever anything is packed, even with no ortho tile on + // it: it is the one that clears the atlas, and every perspective camera loads. set_camera_active( &mut cams, !bridge.cells.is_empty() && bridge.atlas.is_some(), ); } +/// The perspective leg's rig for one pane — the pure half, so the law's own worked numbers can be +/// checked without a world. +struct PerspectiveRig { + /// The model's root, `T(pos · layoutScale) · R(facing, +Z) · S(s)` in Bevy model space — + /// `0x76d1a0`'s `model+0xbc`, minus the pixel ladder the orthographic leg needs. + root: Transform, + /// The camera, `lookAt(eye, target, up)`, with the eye as the view origin. + camera: Transform, + /// The record's projection at the pane's own width/height. + projection: WowPortraitProjection, +} + +/// Build it (render law §2, camera-law §4a/§11). +/// +/// **The authored eye and target are carried through the root transform.** That is `0x718960`'s +/// publish — `eye_published = (position_base + posTrack) · M_root` — and it is the whole reason +/// `SetModelScale` and `SetPosition` cancel here: the camera moves with the model, so the framing +/// is invariant to both. They are still applied rather than skipped, because the record's near and +/// far are copied into the camera **unscaled** while every eye-space depth scales with `s`, so a +/// LARGE `SetModelScale` drives the model through the **far** plane and a small one through the +/// near (wow-re `modelframe-facing-cancels.md` §6 — the direction is the opposite of the obvious +/// guess). `near`/`far` reach only `m22`/`m32`: `near` cancels algebraically out of `m00`/`m11`, +/// so the x/y screen scale is `fov` and `aspect` alone. +/// +/// The **up** vector does not ride the root: `0x7ac640` assembles it out of four `CCamera` fields +/// the publish never writes — `up = (sin(a₆)·sin(roll), −cos(a₆)·sin(roll), cos(roll))` in WoW +/// model space, and property `a₆` has **no writer image-wide**, so it holds its constructor `0` +/// for ever and the vector is `(0, −sin(roll), cos(roll))` (wow-re +/// `modelframe-facing-cancels.md` §2). At `roll = 0` that is model-space `+Z` exactly — which is +/// the very axis `SetFacing` turns the model about, and *that* is why the facing cancels here too: +/// the eye, the target and the geometry all turn about an axis the up vector lies on, so the image +/// does not move (§3). The reference's own one-frame publish lag — it reads the eye before it +/// rebuilds the root, so the frame a facing CHANGES draws one step out of phase (§4) — is a quirk +/// of the ordering, not the mechanism, and is deliberately not reproduced. +fn perspective_rig( + record: &benilla_assets::PortraitCamera, + req: &TileRequest, + aspect: f32, +) -> PerspectiveRig { + let root = Transform { + translation: benilla_assets::coords::wow_to_bevy(req.root_pos.to_array()), + rotation: Quat::from_rotation_y(req.facing), + scale: Vec3::splat(req.root_scale), + }; + let m = root.to_matrix(); + let (eye, target) = ( + m.transform_point3(record.eye), + m.transform_point3(record.target), + ); + // `up = (0, −sin(roll), cos(roll))`, WoW model space — the camera's own, not a roll about the + // view axis. They agree at `roll = 0`, which is every camera a `` pane can name. + let (sin_roll, cos_roll) = record.roll.sin_cos(); + let up = benilla_assets::coords::wow_to_bevy([0.0, -sin_roll, cos_roll]); + PerspectiveRig { + root, + camera: Transform::from_translation(eye).looking_at(target, up), + projection: pane_projection(record, aspect), + } +} + +/// Reap the material twins whose world source material died — the same law and the same event as +/// the booths' (`portrait::light::reap_dead_variants`), which the tiles were missing: a twin is +/// its own asset pinned only by this cache, so without the reap every UI M2 material a session +/// ever showed would survive a map-scope teardown (which clears the world material cache, killing +/// exactly these keys) for the life of the process. A live tile keeps its twin through its own +/// `MeshMaterial3d`; only the dedup entry drops. +fn reap_tile_variants( + mut events: MessageReader>, + rig: Option>, +) { + let Some(mut rig) = rig else { return }; + for ev in events.read() { + if let AssetEvent::Removed { id } = ev { + for light in &mut rig.lights { + light.variants.remove(id); + } + } + } +} + +/// The render layer a tile's entities live on: the atlas camera's shared one for an orthographic +/// tile, and the perspective pool's own for a tile with a camera — one layer per camera, or every +/// perspective camera would draw every other pane's model over its cell. +fn tile_layer(rig: &TileRig, cam_slot: Option) -> RenderLayers { + match cam_slot { + Some(slot) => RenderLayers::layer(UI_MODEL_CAM_LAYER_BASE + slot), + None => rig.layer.clone(), + } +} + #[allow(clippy::type_complexity)] // the system's own query, borrowed fn set_camera_active( cams: &mut Query< @@ -818,7 +1341,7 @@ fn set_camera_active( &mut Projection, &mut Transform, ), - With, + (With, Without), >, active: bool, ) { @@ -892,6 +1415,7 @@ fn facts_of(model: &M2Model) -> ModelFileFacts { .bounds .as_ref() .map_or(([0.0; 3], [0.0; 3]), |b| (b.bbox_min, b.bbox_max)), + cameras: model.cameras.len() as u32, } } @@ -961,12 +1485,15 @@ fn build_tile( forms: &mut ModelForms, meshes: &mut Assets, render: &mut TileRender, + light_slot: usize, + layer: &RenderLayers, ) -> Option { if !render.mats.ready() { return None; } - let light = render.rig.light.clone()?; - let layer = render.rig.layer.clone(); + let light = render.rig.lights.get(light_slot)?.buffer.clone(); + let fogged = render.rig.lights[light_slot].scene.fog.is_some(); + let layer = layer.clone(); // The render forms, now (the booth/marker lanes' exception to the paced furnisher: one small // model, on demand). forms.ensure_now_rigged(handle, &model.submeshes, meshes); @@ -984,14 +1511,22 @@ fn build_tile( sub.texture.clone() }; let world = render.mats.steady(sub, texture, (i + 1) as u16)?; - // The twin: same material, the tile's black light, fog OFF (the `rig` leg forces it; - // the shade selector it also flips is inert on an unlit batch). + // The twin: same material, this rig's light buffer, and the batch's AUTHORED fog policy + // when the pane armed fog — which is where the per-material UNFOGGED bit does its own + // work (render law §5.6: a fogged pane still draws its unfogged materials unfogged). A + // pane with no fog forces it off, as every tile did before 2027. The shade selector both + // lanes flip is inert on an unlit batch, which is every shipped UI M2. + let lane = if fogged { + VariantLane::RigFogged + } else { + VariantLane::RigUnfogged + }; let twin = material_variant( - &mut render.rig.variants, + &mut render.rig.lights[light_slot].variants, &light, &world, render.mats.materials(), - true, + lane, )?; // A batch whose texture transform animates draws through a clone of its own, with its // own table rows — the rows are written off THIS pane's play head, so two panes on one @@ -1205,6 +1740,11 @@ mod tests { star_px_per_unit: 2742.62, facing: 0.0, position: Vec3::ZERO, + root_scale: 1.0, + root_pos: Vec3::ZERO, + camera: None, + light: ModelLight::default(), + fog: None, icon: None, rect: Rect::new(303.2, 767.1, 334.9, 798.8), z_key: 3_458_840_389_530_157_056, @@ -1249,6 +1789,234 @@ mod tests { assert!((br[0] - 130.0 / 512.0).abs() < 1e-6 && (br[1] - 65.0 / 512.0).abs() < 1e-6); } + /// A `TileRequest` for the perspective tests: the pane's own size and root terms, nothing + /// the leg does not read. + fn persp_req(size: UVec2, root_scale: f32, root_pos: Vec3, facing: f32) -> TileRequest { + TileRequest { + path: String::new(), + size_px: size, + px_per_unit: 1.0, + pos_px_per_unit: 1.0, + star_px_per_unit: 1.0, + facing, + position: Vec3::ZERO, + root_scale, + root_pos, + camera: Some(0), + light: ModelLight::default(), + fog: None, + icon: None, + rect: Rect::new(0.0, 0.0, 1.0, 1.0), + z_key: 0, + alpha: 1.0, + clip: None, + } + } + + /// `HumanMale`'s camera 1 as `benilla-extract m2cam` reads it — and as wow-re + /// `modelframe-camera-law.md` §7 records it, to the digit. + fn human_male_cam1() -> benilla_assets::PortraitCamera { + let wow = benilla_assets::coords::wow_to_bevy; + benilla_assets::PortraitCamera { + eye: wow([3.6585, 0.0338, 0.9227]), + target: wow([-0.3644, 0.0291, 0.9873]), + roll: 0.0, + fov: 0.97991, + near: 0.222_222_22, + far: 27.777_779, + } + } + + /// The perspective leg's projection is the client's `0x5c3cc0`: a **diagonal** fov, so + /// `t = tan(fovy / (2·√(aspect²+1)))`, `m11 = 1/t`, `m00 = m11/aspect` — not a vertical fov, + /// and not an aspect-independent crop. + /// + /// The numbers are wow-re's own worked checks. `camera-law.md` §12.1: a `318×224` pane gives + /// `θ = 0.287938 · fov` and a `233×224` pane `θ = 0.346523 · fov`. §8's fallback check: + /// `aspect = 1.4196429`, `√(aspect²+1) = 1.7364815`, `tan(0.5/(2·1.7364815)) = 0.1449700`. + #[test] + fn the_perspective_projection_is_the_clients_diagonal_fov_matrix() { + use bevy::camera::CameraProjection; + + // §8's worked line, on the fallback camera's `fov = 0.5` at the pet pane's 318×224. + let cam = benilla_assets::PortraitCamera { + eye: Vec3::new(0.0, 0.0, 5.0), + target: Vec3::ZERO, + roll: 0.0, + fov: 0.5, + near: 1.0 / 36.0, + far: 5000.0, + }; + let aspect: f32 = 318.0 / 224.0; + assert!((aspect - 1.419_642_9).abs() < 1e-6); + let m = pane_projection(&cam, aspect).get_clip_from_view(); + // `m11 = 1/t` with `t = tan(θ)`; §8's `t` is 0.1449700. + let t = 1.0 / m.y_axis.y; + assert!((t - 0.144_97).abs() < 1e-5, "t = {t}"); + // …and `m00 = m11 / aspect` — the one `aspect` doing both jobs, never 1.0 (a transplanted + // 1.0 would stretch a sphere 1.42× wider than tall in this pane). + assert!( + (m.x_axis.x - m.y_axis.y / aspect).abs() < 1e-6, + "m00 = m11/aspect: {} vs {}", + m.x_axis.x, + m.y_axis.y / aspect + ); + // §12.1's two half-angles, as fractions of the record fov. + for (w, h, want) in [(318.0, 224.0, 0.287_938), (233.0, 224.0, 0.346_523_f32)] { + let a: f32 = w / h; + let theta = (0.5_f32 * cam.fov / (a * a + 1.0).sqrt()) / cam.fov; + assert!((theta - want).abs() < 1e-5, "{w}x{h}: {theta} vs {want}"); + } + } + + /// **`SetModelScale` and `SetPosition` CANCEL on the perspective leg** (camera-law §11.4): the + /// authored eye and target are carried through the very root transform the geometry is drawn + /// through, so the picture is invariant to both. This is the property the leg is built on, and + /// the failure mode it guards is the one wow-re's own §5 pair got backwards — applying the + /// root to the geometry but not to the camera, which is wrong by `1/s`. + /// + /// The check is on the pixels, not on the matrices: a model-local point's clip-space `x/w` + /// and `y/w` must be identical at any scale and any offset. + #[test] + fn scale_and_position_cancel_on_the_perspective_leg() { + use bevy::camera::CameraProjection; + + let cam = human_male_cam1(); + let aspect: f32 = 318.0 / 224.0; + // Points spread over a character-sized body, in model space. + let probes = [ + Vec3::ZERO, + benilla_assets::coords::wow_to_bevy([0.0, 0.0, 1.8]), + benilla_assets::coords::wow_to_bevy([0.3, -0.4, 1.0]), + benilla_assets::coords::wow_to_bevy([-0.2, 0.5, 0.2]), + ]; + let ndc = |req: &TileRequest| -> Vec { + let leg = perspective_rig(&cam, req, aspect); + let clip = leg.projection.get_clip_from_view() * leg.camera.to_matrix().inverse(); + probes + .iter() + .map(|p| { + let c = clip * leg.root.to_matrix() * p.extend(1.0); + Vec2::new(c.x / c.w, c.y / c.w) + }) + .collect() + }; + let base = ndc(&persp_req(UVec2::new(318, 224), 1.0, Vec3::ZERO, 0.0)); + for (label, req) in [ + ( + "SetModelScale(3)", + persp_req(UVec2::new(318, 224), 3.0, Vec3::ZERO, 0.0), + ), + ( + "SetModelScale(0.25)", + persp_req(UVec2::new(318, 224), 0.25, Vec3::ZERO, 0.0), + ), + ( + "SetPosition(0.4, -0.3, 0.9)", + persp_req(UVec2::new(318, 224), 1.0, Vec3::new(0.4, -0.3, 0.9), 0.0), + ), + ( + "both at once", + persp_req(UVec2::new(318, 224), 2.5, Vec3::new(-1.0, 2.0, 0.5), 0.0), + ), + ] { + for (a, b) in base.iter().zip(ndc(&req)) { + assert!( + (a.x - b.x).abs() < 2e-4 && (a.y - b.y).abs() < 2e-4, + "{label}: {a:?} vs {b:?} — the camera must ride the same root the model does" + ); + } + } + + // …and the ORTHOGRAPHIC leg is the opposite: its scale is a pixel ladder with no camera + // to cancel against, so `px_per_unit` is exactly what the model's size is measured in. + // (Stated as the contrast, so nobody carries the cancellation across the fork.) + assert_ne!( + persp_req(UVec2::new(318, 224), 1.0, Vec3::ZERO, 0.0).px_per_unit, + 0.0 + ); + } + + /// **`SetFacing` cancels too** — the half of the leg that had to be settled at the bytes + /// before it could be built (wow-re `modelframe-facing-cancels.md`, a §5 round commissioned + /// by this work; `modelframe-render-law.md` §2's "only `SetFacing`/`SetRotation` show" is + /// scoped to ``'s FROZEN camera and does not hold here). + /// + /// The reason is the up vector: `0x7ac640` builds it as `(0, −sin(roll), cos(roll))` in model + /// space out of `CCamera` fields the publish never writes, and at `roll = 0` — every camera a + /// `` pane can name — that is model-space `+Z`, the very axis `SetFacing` turns about. + /// Eye, target, geometry and up all turn together, so the image does not move. + /// + /// A re-implementation that rotated the model without rotating its camera would spin the + /// model; one that rotated both but kept a screen-space up would tilt it. Both are wrong, and + /// both look plausible, which is why this is pinned on the pixels. + #[test] + fn facing_cancels_on_the_perspective_leg_too() { + use bevy::camera::CameraProjection; + + let cam = human_male_cam1(); + let aspect: f32 = 318.0 / 224.0; + let probes = [ + benilla_assets::coords::wow_to_bevy([0.0, 0.0, 1.8]), + benilla_assets::coords::wow_to_bevy([0.3, -0.4, 1.0]), + benilla_assets::coords::wow_to_bevy([-0.2, 0.5, 0.2]), + ]; + let ndc = |facing: f32| -> Vec { + let req = persp_req(UVec2::new(318, 224), 1.0, Vec3::ZERO, facing); + let leg = perspective_rig(&cam, &req, aspect); + let clip = leg.projection.get_clip_from_view() * leg.camera.to_matrix().inverse(); + probes + .iter() + .map(|p| { + let c = clip * leg.root.to_matrix() * p.extend(1.0); + Vec2::new(c.x / c.w, c.y / c.w) + }) + .collect() + }; + let base = ndc(0.0); + for facing in [0.61, 1.0, std::f32::consts::PI, -2.4] { + for (a, b) in base.iter().zip(ndc(facing)) { + assert!( + (a.x - b.x).abs() < 2e-4 && (a.y - b.y).abs() < 2e-4, + "facing {facing}: {a:?} vs {b:?} — the camera turns with the model" + ); + } + } + // The ORTHO leg is where a facing shows: there the model turns in the screen plane and + // nothing turns with it. Same widget field, opposite outcome — the fork is the point. + let turned = wow_to_screen() + * Quat::from_rotation_y(std::f32::consts::FRAC_PI_2) + * benilla_assets::coords::wow_to_bevy([1.0, 0.0, 0.0]); + assert!((turned - Vec3::Y).length() < 1e-5, "{turned}"); + } + + /// The camera's eye lands where the record says, in Bevy space, and the view looks down the + /// eye→target axis with the model's up (WoW `+Z`) up — the `lookAt` half of the leg, checked + /// against `HumanMale`'s camera 1: the eye is 4.02 model units in front of the target on the + /// model's own `+X`, at chest height. + #[test] + fn the_perspective_camera_is_the_records_lookat() { + let cam = human_male_cam1(); + let leg = perspective_rig( + &cam, + &persp_req(UVec2::new(318, 224), 1.0, Vec3::ZERO, 0.0), + 318.0 / 224.0, + ); + assert!( + (leg.camera.translation - cam.eye).length() < 1e-5, + "the eye" + ); + let fwd = leg.camera.forward().as_vec3(); + let want = (cam.target - cam.eye).normalize(); + assert!((fwd - want).length() < 1e-5, "{fwd:?} vs {want:?}"); + // The distance the record authors — the whole mechanism behind "the pane looks + // normalized" (camera-law §7: Blizzard authored a per-model distance). + let d = (cam.target - cam.eye).length(); + assert!((d - 4.0234).abs() < 1e-3, "authored eye distance {d}"); + // Up is the model's own up, not the camera's roll-free default in some other frame. + assert!(leg.camera.up().as_vec3().dot(Vec3::Y) > 0.9); + } + /// The axis fix is a proper rotation that puts WoW `+X` right, `+Y` up, `+Z` toward the /// viewer. #[test] diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index 1364f627..174379d5 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -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(); diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs index 424e93d1..07dfc4db 100644 --- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs +++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs @@ -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()") diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 67c0f830..827a5de8 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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 `` 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, diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index 14cfd069..6438449d 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -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::(&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] diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 9bc68271..9d3a89fd 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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::() + .map_or(1.0, |c| c.0); + let h = { + let mut q = world.query_filtered::<&Window, With>(); + q.single(world).map_or(0.0, Window::height) + }; + let Some(atlas) = world.get_resource::() 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::() .is_none_or(crate::cvars::CvarPersist::addon_version_check) }); + // **The VM's font engine, before the first `` 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); diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index a2c5a5b1..b93935e1 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -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!( diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 2a8f89b3..3397aca6 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -3964,3 +3964,114 @@ fn the_action_bars_page_toggles_the_real_bars() { assert_eq!(s.eval::("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 (`` → `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 ``, after every `` 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()); +} diff --git a/crates/benilla-app/src/ui_script/panel_tests.rs b/crates/benilla-app/src/ui_script/panel_tests.rs index 50b33eb1..3539c4f6 100644 --- a/crates/benilla-app/src/ui_script/panel_tests.rs +++ b/crates/benilla-app/src/ui_script/panel_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index d05e54c0..7aef340d 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -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, }, ); } diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index 749063c5..1aee597c 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -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::() + .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 { + 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::() + .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::() + .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::(); + + 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); +} diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs index bd066597..fc7cd114 100644 --- a/crates/benilla-app/src/ui_script/world_map_tests.rs +++ b/crates/benilla-app/src/ui_script/world_map_tests.rs @@ -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, }, ); } diff --git a/crates/benilla-app/src/ui_social/feed.rs b/crates/benilla-app/src/ui_social/feed.rs index a7eff9ae..bb89dbc4 100644 --- a/crates/benilla-app/src/ui_social/feed.rs +++ b/crates/benilla-app/src/ui_social/feed.rs @@ -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 = 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 = 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 { 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 { + let mut rows: Vec = 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 { }) } -/// 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 = 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. diff --git a/crates/benilla-app/src/ui_social/mod.rs b/crates/benilla-app/src/ui_social/mod.rs index ebdd9596..03aa4675 100644 --- a/crates/benilla-app/src/ui_social/mod.rs +++ b/crates/benilla-app/src/ui_social/mod.rs @@ -100,8 +100,10 @@ pub(crate) struct SocialState { /// The last `/who` answer, and the server's true match total. who: Vec, 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() { diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index e9701dd0..3ccfc0e7 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -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, + /// The **whole camera table**, in file order — the raw index space a `` 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, /// 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 `` 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>, +} + +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 `` widget's `SetCamera(n)` space (2027). + let cameras: Vec = 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, diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 85a4ec4b..5798c8e8 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -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 `` 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(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 7c1de280..e75636ea 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -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 `` 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)?, diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 5bb7ecce..369b5a17 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/models/records.rs b/crates/benilla-formats/src/models/records.rs index db171531..36e55930 100644 --- a/crates/benilla-formats/src/models/records.rs +++ b/crates/benilla-formats/src/models/records.rs @@ -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 `` 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 `` 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>, +} + +/// 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 { + 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() +} diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 0aba2e7d..c46a06d8 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -56,6 +56,32 @@ impl Loader<'_> { } } } + // The model pane's own fog attributes and `` 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 `` 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::().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::().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::() { self.call(wrapper, "SetID", n, dbg); diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index ef32e5e2..e1a54fb0 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -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, }; diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 72b93dde..d2c4fc93 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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, diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index 63bcc606..ca659347 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -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 { + 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 = (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 = it.map(|v| num(&v)).collect(); - with_model(lua, &this, |m| m.light = Some(nums)) + let a: Vec = 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::>(); + let l = with_model(lua, &this, |m| m.light)?; + let mut out: Vec = 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 `` 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, diff --git a/crates/benilla-ui/src/script/social.rs b/crates/benilla-ui/src/script/social.rs index 1ecefc20..3642cd70 100644 --- a/crates/benilla-ui/src/script/social.rs +++ b/crates/benilla-ui/src/script/social.rs @@ -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::().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::().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::("return fired").unwrap(), 0); + + s.run(r#"SortWho("name")"#).unwrap(); + assert_eq!(s.eval::("return fired").unwrap(), 1); + assert_eq!( + s.eval::("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::(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::("return order").unwrap(), "Erdrin,Galas,"); + + s.run(r#"SortWho("name")"#).unwrap(); + assert_eq!( + s.eval::("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::("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::("return order").unwrap(), + "Galas,Erdrin,", + "a key promoted from behind keeps the direction it was left in" + ); + assert_eq!( + s.eval::("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::("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::("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. diff --git a/crates/benilla-ui/src/script/tests/model_clock.rs b/crates/benilla-ui/src/script/tests/model_clock.rs index 33e3473a..82633f9b 100644 --- a/crates/benilla-ui/src/script/tests/model_clock.rs +++ b/crates/benilla-ui/src/script/tests/model_clock.rs @@ -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::().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, }, ); }; diff --git a/crates/benilla-ui/src/script/tests/modelframe.rs b/crates/benilla-ui/src/script/tests/modelframe.rs index 18ef588c..747d88ab 100644 --- a/crates/benilla-ui/src/script/tests/modelframe.rs +++ b/crates/benilla-ui/src/script/tests/modelframe.rs @@ -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 = s.eval("return { MLight:GetLight() }").unwrap(); assert_eq!( - s.eval::("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 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 = 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 ``'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 = 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 = 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 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)); +} diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 910bbf1a..340424b5 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -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]))) diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index e57d7e01..e90f1dd3 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -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, + /// 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, + /// 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, }, /// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture). Texture { diff --git a/crates/benilla-ui/src/script/who_sort.rs b/crates/benilla-ui/src/script/who_sort.rs new file mode 100644 index 00000000..39ca8dc1 --- /dev/null +++ b/crates/benilla-ui/src/script/who_sort.rs @@ -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); + } +} diff --git a/crates/benilla-ui/src/script/worldmap_arrow.rs b/crates/benilla-ui/src/script/worldmap_arrow.rs index a619d79b..fb584a4b 100644 --- a/crates/benilla-ui/src/script/worldmap_arrow.rs +++ b/crates/benilla-ui/src/script/worldmap_arrow.rs @@ -352,6 +352,7 @@ mod tests { looping: true, }], bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]), + cameras: 0, }, ); } diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 02cee06d..15c001bb 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -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 ` (decision 2027). + pub camera_pending: Option, + /// 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, /// `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>, + /// 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 `` 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 +/// `` widget has (wow-re `modelframe-render-law.md` §5.1/§5.2). +/// +/// **A plain ``'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. ``'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 `` 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 `` (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 `` 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::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 + // `` 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 { + 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 diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 3303f619..bca275ea 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -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, }; // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-world/src/clouds/mod.rs b/crates/benilla-world/src/clouds/mod.rs index a3def438..91c8a04c 100644 --- a/crates/benilla-world/src/clouds/mod.rs +++ b/crates/benilla-world/src/clouds/mod.rs @@ -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. diff --git a/crates/benilla-world/src/entity_shade.rs b/crates/benilla-world/src/entity_shade.rs index 7640122d..e21d3cf9 100644 --- a/crates/benilla-world/src/entity_shade.rs +++ b/crates/benilla-world/src/entity_shade.rs @@ -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, + ), Without, >, // 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, + )>, // 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. diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 89f5daa4..8c6dbbcb 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -948,7 +948,19 @@ impl Plugin for FfxGlowPlugin { ExtractResourcePlugin::::default(), ExtractResourcePlugin::::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; }; diff --git a/crates/benilla-world/src/interior.rs b/crates/benilla-world/src/interior.rs index 2c8cb194..99a72259 100644 --- a/crates/benilla-world/src/interior.rs +++ b/crates/benilla-world/src/interior.rs @@ -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); } diff --git a/crates/benilla-world/src/lighting/blob.rs b/crates/benilla-world/src/lighting/blob.rs index 63eedb67..3cea4bad 100644 --- a/crates/benilla-world/src/lighting/blob.rs +++ b/crates/benilla-world/src/lighting/blob.rs @@ -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 `` 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 } diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 09acaf0e..626b20a8 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -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::() .init_resource::() .init_resource::() @@ -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::(); + 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| p.0 += 1).in_set(LightingConsumeSet), + (|mut p: ResMut| 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." + ); + } +} diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs index 9edffabc..50d7b873 100644 --- a/crates/benilla-world/src/liquid/mod.rs +++ b/crates/benilla-world/src/liquid/mod.rs @@ -106,6 +106,40 @@ pub(crate) use surface::{ spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource, LiquidSurface, }; +/// `WOW_FORCE_SUB=` — **hold the camera-eye verdict submerged for the first `` +/// 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=`). +/// +/// 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, mut frame: Local) { + 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); diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs index ce91ed28..df20ebe4 100644 --- a/crates/benilla-world/src/mesh_tag.rs +++ b/crates/benilla-world/src/mesh_tag.rs @@ -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 diff --git a/crates/benilla-world/src/sky.rs b/crates/benilla-world/src/sky.rs index 3f6a95ca..c327adb0 100644 --- a/crates/benilla-world/src/sky.rs +++ b/crates/benilla-world/src/sky.rs @@ -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 diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index dad3d6e2..dac4e0cd 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -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). diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index 93989aba..3eeb4230 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -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. diff --git a/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs b/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs index 01951081..49f7534c 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/prop_light.rs @@ -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).