diff --git a/crates/benilla-app/assets/ui/WorldStateFrame.xml b/crates/benilla-app/assets/ui/WorldStateFrame.xml index bb26e6a9..7a9f5f85 100644 --- a/crates/benilla-app/assets/ui/WorldStateFrame.xml +++ b/crates/benilla-app/assets/ui/WorldStateFrame.xml @@ -2,8 +2,8 @@ @@ -44,11 +72,30 @@ = period ) do + t = t - period; + end + this.flashTime = t; + local level = t / WORLD_STATE_FLASH_HALF; + if ( level > 1 ) then + level = 2 - level; + end + getglobal(this:GetName() .. "Flash"):SetAlpha(level); + end -- Rebuild the readout from the host's list. Cheap enough to run whole on every fire: the -- list is at most a handful of rows, and the events are a zone's worth apart. @@ -56,13 +103,19 @@ local numUI = GetNumWorldStateUI(); -- Grow the row pool on demand and never shrink it, the way every pooled list here - -- does: the tail from a busier zone is parked hidden rather than destroyed. + -- does: the tail from a busier zone is parked hidden rather than destroyed. Row 1 + -- hangs off the container; every later row chains to the one above it, so the pitch is + -- the template's height and lives in exactly one place. if ( numUI > NUM_ALWAYS_UP_ROWS ) then for i = NUM_ALWAYS_UP_ROWS + 1, numUI do local row = CreateFrame("Frame", "WorldStateAlwaysUpFrame" .. i, WorldStateAlwaysUpFrame, "WorldStateAlwaysUpTemplate"); - row:SetPoint("TOP", WorldStateAlwaysUpFrame, "TOP", 0, - -(i - 1) * WORLD_STATE_ROW_HEIGHT); + if ( i == 1 ) then + row:SetPoint("TOP", WorldStateAlwaysUpFrame, "TOP", -23, -20); + else + row:SetPoint("TOP", getglobal("WorldStateAlwaysUpFrame" .. (i - 1)), + "BOTTOM", 0, 0); + end end NUM_ALWAYS_UP_ROWS = numUI; end @@ -73,40 +126,42 @@ row:Hide(); else local uiState, text, icon, dynamicIcon, tooltip, dynamicTooltip = GetWorldStateUIInfo(i); - local iconTexture = getglobal(row:GetName() .. "Icon"); - local textString = getglobal(row:GetName() .. "Text"); + local name = row:GetName(); - -- The dynamic icon replaces the static one while the row's own state is live. - -- A row with neither draws no icon at all and the text takes the whole width; - -- the reference's own data has several such rows (the progress line). - local shown = icon; + -- The static slot takes its column verbatim. An EMPTY column is not a special + -- case: SetTexture("") clears the art and draws nothing while the authored + -- 42x42 rect — and so everything anchored to it — stays exactly where it was. + -- The slot is reserved and blank, never collapsed; a row that slid its text + -- left into the empty slot would ragged the readout's left edge, and the + -- reference does no such thing. + getglobal(name .. "Icon"):SetTexture(icon); + getglobal(name .. "Text"):SetText(text); row.tooltip = tooltip; - if ( dynamicIcon ~= "" and uiState ~= 0 ) then - shown = dynamicIcon; - if ( dynamicTooltip ~= "" ) then - row.tooltip = dynamicTooltip; - end - end - -- ClearAllPoints first: SetPoint ADDS an anchor, and the template already - -- seated the text against the icon. - textString:ClearAllPoints(); - if ( shown == "" ) then - iconTexture:Hide(); - textString:SetPoint("LEFT", row, "LEFT", 0, 0); + + -- The dynamic slot is a SEPARATE button off the row's right edge, and it is + -- the flag-taken state alone that shows it. + local dynamic = getglobal(name .. "DynamicIcon"); + if ( dynamicIcon ~= "" and uiState == 2 ) then + getglobal(dynamic:GetName() .. "Texture"):SetTexture(dynamicIcon); + getglobal(dynamic:GetName() .. "Flash"):SetTexture(dynamicIcon .. "Flash"); + dynamic.tooltip = dynamicTooltip; + dynamic.flashTime = 0; + dynamic:Show(); else - iconTexture:SetTexture(shown); - iconTexture:Show(); - textString:SetPoint("LEFT", iconTexture, "RIGHT", 4, 0); + dynamic:Hide(); end - textString:SetText(text); row:Show(); end end - -- The container is only as tall as the rows it shows, and disappears entirely when - -- there are none — this frame has no chrome of its own to leave behind. + -- OURS, and non-visual: the reference's container is a bare 10x10 anchor node it never + -- resizes, so its own bottom says nothing about how far the rows reach. We keep the + -- authored size but grow the height to the rows' real extent, so "how much of the top + -- of the screen is this using?" is a question the frame can answer — the dev cost pill + -- asks it, and seats itself below (crates/benilla-app/src/perf/hud.rs). Rows anchor to + -- the container's TOP, which is fixed, so nothing moves. if ( numUI > 0 ) then - WorldStateAlwaysUpFrame:SetHeight(numUI * WORLD_STATE_ROW_HEIGHT); + WorldStateAlwaysUpFrame:SetHeight(20 + numUI * WORLD_STATE_ROW_HEIGHT); WorldStateAlwaysUpFrame:Show(); else WorldStateAlwaysUpFrame:Hide(); @@ -119,27 +174,70 @@ ]]> - + - + - + - + + + - + + + + if ( this.tooltip and this.tooltip ~= "" ) then @@ -156,12 +254,14 @@ - - + fight it. The reference declares it 10x10 and never touches it again — it is an anchor + node, and rows overflow it freely (see the update for the one thing we do differently, and + why). Hidden until a scope admits a row. --> + + - + diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index 3c2d48fa..5f0f39cc 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -25,7 +25,7 @@ //! panel, and this one is `#[cfg(feature = "dev")]`, so anything a player must reach cannot live //! here at all. The measurement knob the checkbox actually existed for is `$WOW_NOVSYNC=1`. -use benilla_ui::script::{JustifyH, JustifyV, Outline}; +use benilla_ui::script::{JustifyH, JustifyV, Outline, UiScript}; use bevy::prelude::*; use bevy::time::Real; use bevy::window::{PrimaryWindow, Window}; @@ -48,8 +48,10 @@ const Z_PILL: u64 = u64::MAX; /// The quad pill's font height (logical px) and box padding. const PILL_QUAD_PX: f32 = 12.0; const PILL_PAD: Vec2 = Vec2::new(9.0, 4.0); -/// Top-center offset. +/// Top-center offset — where the pill sits when nothing else claims that band. const PILL_TOP: f32 = 8.0; +/// The gap the pill leaves under whatever game UI it is stepping below. +const PILL_YIELD_GAP: f32 = 4.0; /// How often the drawn snapshot advances. Fast enough that the numbers read as live and a latched /// badge appears within a perceptual beat; slow enough that between refreshes the pill's cached @@ -77,6 +79,10 @@ pub(crate) struct PerfHud { /// The clock `snap` was taken at. Spike ages are computed against this, not the live clock, /// so they hold still with the rest of the view — and it doubles as the refresh timer. snap_at: f32, + /// How far down the screen the top-centre band is already spoken for (window logical px from + /// the top edge, `0.0` when it is clear) — see [`top_centre_claimed`]. The pill seats itself + /// below this. + top_claimed: f32, } impl Default for PerfHud { @@ -86,17 +92,27 @@ impl Default for PerfHud { snap: FrameStats::default(), // −∞, so the very first frame refreshes rather than drawing an empty snapshot. snap_at: f32::NEG_INFINITY, + top_claimed: 0.0, } } } impl PerfHud { - /// Advance the snapshot if the current one is older than [`HUD_REFRESH_SECS`]. - fn maybe_refresh(&mut self, stats: &FrameStats, now: f32) { - if now - self.snap_at >= HUD_REFRESH_SECS { - self.snap = stats.clone(); - self.snap_at = now; + /// Advance the snapshot if the current one is older than [`HUD_REFRESH_SECS`]; `true` when it + /// did (the caller's gate for the rest of the refresh — see [`refresh_hud_snapshot`]). + fn maybe_refresh(&mut self, stats: &FrameStats, now: f32) -> bool { + if now - self.snap_at < HUD_REFRESH_SECS { + return false; } + self.snap = stats.clone(); + self.snap_at = now; + true + } + + /// Where the pill's top edge goes: its usual seat, pushed below anything already occupying the + /// top-centre band. + fn pill_top(&self) -> f32 { + PILL_TOP.max(self.top_claimed + PILL_YIELD_GAP) } } @@ -110,16 +126,62 @@ pub(super) fn toggle_hud(keys: Res>, mut hud: ResMut, stats: Res, time: Res>, + script: Option>, + windows: Query<&Window, With>, ) { - if hud.visible { - let now = time.elapsed_secs(); - hud.maybe_refresh(&stats, now); + if !hud.visible { + return; } + let now = time.elapsed_secs(); + if !hud.maybe_refresh(&stats, now) { + return; + } + hud.top_claimed = match (script, windows.single()) { + (Some(script), Ok(win)) => top_centre_claimed(&script, win.height()), + _ => 0.0, + }; +} + +/// How far down the top-centre band the **game UI** already reaches, in window logical px from the +/// top edge (`0.0` = clear). +/// +/// The pill has sat at the top centre since 1453 because that band was empty. It is not empty +/// everywhere: the always-up world-state readout (`WorldStateAlwaysUpFrame` — the tower counters +/// and battleground scores, decision 1590) is anchored to the top centre too, and in Eastern +/// Plaguelands or a battleground the two drew straight through each other. **The dev instrument is +/// the one that yields** — the readout is the game, the pill is scaffolding — and it yields by +/// stepping below it rather than by moving house, because a 1.12 UI has no corner a standing +/// overlay can claim outright (player/target frames own the top left, buffs and the minimap the top +/// right, the action bar the bottom, the chat dock the bottom left). +/// +/// **Asked of the frame, not recomputed from its numbers.** Its row pitch and anchor live in +/// `assets/ui/WorldStateFrame.xml`; mirroring them here would be two copies to keep in step, so +/// this reads the resolved edge the layout actually produced (`GetBottom` — local units, y-up, +/// and benilla has no `uiScale` CVar so UIParent's are screen px). One tiny chunk at 4 Hz, only +/// while the HUD is drawing — the same shape as [`crate::hover_log`]'s tooltip probe. +/// +/// **Its own cost, stated (1370's rule for this overlay):** an edge read settles the layout, so on +/// a frame where something has written anchors since the last resolve this pays one graph solve +/// (~47 µs on a 200-frame tree — `layout_methods::settle`'s own measurement), four times a second. +/// Every other frame it is a chunk load and a table lookup. +pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 { + const CHUNK: &str = r#" + local f = WorldStateAlwaysUpFrame + if f and f:IsVisible() and f:GetBottom() then return f:GetBottom() end + return -1 + "#; + let bottom: f32 = script.eval::(CHUNK).unwrap_or(-1.0) as f32; + if bottom < 0.0 { + return 0.0; + } + (win_h - bottom).clamp(0.0, win_h) } /// The pill as ~20 quads on the player-UI pass (decision 1453). The append lane is rebuilt every @@ -140,7 +202,9 @@ pub(super) fn pill_quads( return; // headless: nothing draws, nothing to price }; let win_w = win.width(); - let stale = !matches!(&*cache, Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w); + let top = hud.pill_top(); + let stale = + !matches!(&*cache, Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w && c.top == top); if stale { let cpu = hud.snap.cpu.mean(); let fps = hud.snap.fps(); @@ -180,8 +244,8 @@ pub(super) fn pill_quads( bounds.max.x + PILL_PAD.x, bounds.max.y + PILL_PAD.y, ); - // Measured about y=0; seat the whole strip at PILL_TOP. - let dy = PILL_TOP - strip.min.y; + // Measured about y=0; seat the whole strip at the pill's top edge. + let dy = top - strip.min.y; let shift = |r: Rect| Rect::new(r.min.x, r.min.y + dy, r.max.x, r.max.y + dy); let strip = shift(strip); let mut out = vec![UiQuad { @@ -197,6 +261,7 @@ pub(super) fn pill_quads( *cache = Some(PillCache { snap_at: hud.snap_at, win_w, + top, quads: out, }); } @@ -204,10 +269,12 @@ pub(super) fn pill_quads( quads.overlays.extend(c.quads.iter().cloned()); } -/// [`pill_quads`]' cache: the laid-out pill, valid for one snapshot tick at one window width. +/// [`pill_quads`]' cache: the laid-out pill, valid for one snapshot tick at one window width and +/// one seat (the seat moves when the game UI takes the band — [`top_centre_claimed`]). pub(super) struct PillCache { snap_at: f32, win_w: f32, + top: f32, quads: Vec, } @@ -225,7 +292,7 @@ mod tests { let t = live.feed_frames(&frames, 0.0, 1.0 / 60.0); let mut hud = PerfHud::default(); - hud.maybe_refresh(&live, t); + assert!(hud.maybe_refresh(&live, t)); assert_eq!( hud.snap.wall.len(), 60, @@ -234,18 +301,38 @@ mod tests { assert_eq!(hud.snap_at, t); let t2 = live.feed_frames(&[(40.0, 20.0)], t, 1.0 / 60.0); - hud.maybe_refresh(&live, t2); + assert!( + !hud.maybe_refresh(&live, t2), + "inside the interval, no refresh" + ); assert_eq!( hud.snap.wall.len(), 60, "one frame later — inside the interval — the view holds still" ); - hud.maybe_refresh(&live, t + HUD_REFRESH_SECS); + assert!(hud.maybe_refresh(&live, t + HUD_REFRESH_SECS)); assert_eq!( hud.snap.wall.len(), 61, "past the interval the view catches up" ); } + + /// The pill's seat: its usual place while the top-centre band is clear, and below the game UI + /// the moment something claims it — the always-up world-state readout is the one frame that + /// shares this band (decision 1590), and before this the two drew through each other. + #[test] + fn the_pill_steps_below_whatever_claims_the_top_centre() { + let mut hud = PerfHud::default(); + assert_eq!(hud.pill_top(), PILL_TOP, "clear band — the usual seat"); + + // The readout is up and reaches 81 px down (its anchor plus three rows). + hud.top_claimed = 81.0; + assert_eq!(hud.pill_top(), 81.0 + PILL_YIELD_GAP); + + // A claim shallower than the pill's own seat moves nothing. + hud.top_claimed = 1.0; + assert_eq!(hud.pill_top(), PILL_TOP); + } } diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index 88b72771..b30b46af 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -54,6 +54,11 @@ use bevy::render::diagnostic::RenderDiagnosticsPlugin; pub(crate) use clock::{process_cpu_secs, system_cpu_ticks}; pub(crate) use gpu::GpuMsShared; +/// The pill's "who else is using this band?" probe, reached by the world-state frame's own tests +/// (`ui_script::world_state_tests`) so the two systems that share the top centre are pinned against +/// each other rather than each against its own idea of the geometry. +#[cfg(test)] +pub(crate) use hud::top_centre_claimed; pub(crate) use hud::PerfHud; pub(crate) use journal::FpsJournalPlugin; diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index 9ea3bae5..9b705470 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -775,6 +775,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh { rgb_anim: None, rgb_seq: None, wmo_batch: None, + section: None, } } diff --git a/crates/benilla-app/src/ui_script/world_state_tests.rs b/crates/benilla-app/src/ui_script/world_state_tests.rs index ce1c44f8..def96945 100644 --- a/crates/benilla-app/src/ui_script/world_state_tests.rs +++ b/crates/benilla-app/src/ui_script/world_state_tests.rs @@ -150,11 +150,14 @@ fn leaving_the_zone_clears_the_readout() { ); } -/// The dynamic icon: a row whose own state is live draws the alternate art (Warsong Gulch's enemy -/// flag) instead of its static icon, and reverts when the state drops. `uiState` is what decides, -/// which is the whole reason the first return is a number and not the text. +/// The dynamic icon is a SECOND slot, not a swap (wow-re `worldstate-ui-law.md` §12; decision +/// 1604). The `Icon` column and the `DynamicIcon` column feed two different regions — a 42x42 +/// static slot and a 32x32 button off the row's right edge — and only `uiState == 2`, the +/// flag-taken value, lights the second one. The first pass replaced the static art whenever the +/// state was non-zero, which fired on the ordinary state 1 and hid the faction shield that should +/// never have moved; both halves of that are pinned here. #[test] -fn the_dynamic_icon_follows_the_rows_own_state() { +fn the_dynamic_icon_is_a_second_slot_lit_only_by_the_taken_state() { let mut s = harness(); s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]); @@ -168,21 +171,69 @@ fn the_dynamic_icon_follows_the_rows_own_state() { ..Default::default() }; - push(&mut s, vec![flag_row(0)]); + for quiet in [0, 1, 3] { + push(&mut s, vec![flag_row(quiet)]); + let art = textures(&mut s); + assert!( + art.iter().any(|p| p.contains("UI-PVP-Alliance")), + "state {quiet} — the faction shield never moves: {art:?}" + ); + assert!( + !art.iter().any(|p| p.contains("HordeFlag")), + "state {quiet} is not the flag-taken state, so nothing lights: {art:?}" + ); + } + + push(&mut s, vec![flag_row(2)]); let art = textures(&mut s); assert!( art.iter().any(|p| p.contains("UI-PVP-Alliance")), - "state down — the static icon: {art:?}" + "taken — the shield is STILL there, beside the flag: {art:?}" ); - assert!(!art.iter().any(|p| p.contains("HordeFlag"))); - - push(&mut s, vec![flag_row(1)]); - let art = textures(&mut s); assert!( - art.iter().any(|p| p.contains("HordeFlag")), - "state up — the enemy flag replaces it: {art:?}" + art.iter() + .any(|p| p == "Interface\\WorldStateFrame\\HordeFlag"), + "taken — the enemy flag lights up: {art:?}" ); - assert!(!art.iter().any(|p| p.contains("UI-PVP-Alliance"))); + assert!( + art.iter() + .any(|p| p == "Interface\\WorldStateFrame\\HordeFlagFlash"), + "…with its ADD-blend flash overlay, whose path is the icon's plus `Flash`: {art:?}" + ); + + // The pulse runs: the flash overlay's alpha ramps up across the first half-second and back + // down across the second, and the handler that does it raises nothing. + let flash_alpha = |s: &mut UiScript| { + s.resolve(); + s.extract() + .iter() + .find(|q| { + matches!(&q.content, QuadContent::Texture { path: Some(p), .. } + if p.ends_with("HordeFlagFlash")) + }) + .map(|q| q.alpha) + .expect("the flash overlay is on screen") + }; + let at_zero = flash_alpha(&mut s); + s.tick(0.25); + let quarter = flash_alpha(&mut s); + s.tick(0.25); + let half = flash_alpha(&mut s); + s.tick(0.5); + let full = flash_alpha(&mut s); + assert!( + at_zero < quarter && quarter < half, + "the flash ramps in over its first half-second: {at_zero}, {quarter}, {half}" + ); + assert!( + full < half, + "…and back out over the second: {half} then {full}" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + + // And it goes out again when the flag is returned. + push(&mut s, vec![flag_row(1)]); + assert!(!textures(&mut s).iter().any(|p| p.contains("HordeFlag"))); } /// A row with no icon at all (the Eastern Plaguelands progress line) still draws its text — the @@ -261,3 +312,174 @@ fn the_bindings_answer_the_reference_shape() { 7 ); } + +/// The readout and the dev cost pill both want the top centre, and before this they drew straight +/// through each other (the pill is anchored 8 px down; the readout's first row starts at 15). The +/// pill asks the frame itself how far down the band is spoken for +/// ([`crate::perf::top_centre_claimed`]) and seats itself below that — so this pins the probe +/// against the shipped XML: nothing claimed while the readout is empty, and the readout's real +/// resolved bottom once it is up. +#[test] +fn the_readout_tells_the_dev_pill_how_much_of_the_top_it_uses() { + const SCREEN_H: f32 = 768.0; + let mut s = harness(); + s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]); + s.resolve(); + assert_eq!( + crate::perf::top_centre_claimed(&s, SCREEN_H), + 0.0, + "hidden readout claims nothing — the pill keeps its usual seat" + ); + + push( + &mut s, + vec![ + row( + "Interface\\WorldStateFrame\\AllianceTower", + "Towers Controlled: 3", + "Alliance Towers Controlled", + ), + row( + "Interface\\WorldStateFrame\\HordeTower", + "Towers Controlled: 1", + "Horde Towers Controlled", + ), + ], + ); + s.resolve(); + let claimed = crate::perf::top_centre_claimed(&s, SCREEN_H); + assert!( + claimed > 8.0, + "two rows reach past the pill's own seat, so the pill must move: {claimed}" + ); + // The frame's own geometry, read back the way the probe reads it: the container's top offset + // plus one row per pushed row. Asserted against the XML rather than restated as constants. + let expected: f32 = s + .eval::("return 20 + WORLD_STATE_ROW_HEIGHT * 2 + 15") + .expect("the frame's own numbers") as f32; + assert!( + (claimed - expected).abs() < 0.5, + "the claim is the readout's resolved bottom: {claimed} vs {expected}" + ); + + push(&mut s, Vec::new()); + s.resolve(); + assert_eq!( + crate::perf::top_centre_claimed(&s, SCREEN_H), + 0.0, + "and it hands the band back when the scope empties" + ); +} + +/// The alpha bounding box of a shipped icon, in texel coordinates (`x0, y0, x1, y1`, y down). +fn ink_box(chain: &mut benilla_formats::Chain, path: &str) -> (f32, f32, f32, f32, f32, f32) { + let bytes = chain.read_file(path).expect("read icon"); + let (w, h, rgba) = benilla_formats::blp_to_rgba(&bytes).expect("decode icon"); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32); + for y in 0..h { + for x in 0..w { + if rgba[((y * w + x) * 4 + 3) as usize] > 8 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + ( + x0 as f32, + y0 as f32, + (x1 + 1) as f32, + (y1 + 1) as f32, + w as f32, + h as f32, + ) +} + +/// **Where the ink actually lands** — the test the first pass of this frame needed and did not +/// have. Every icon the readout names is a sprite authored into the UPPER-LEFT corner of a +/// power-of-two canvas (`AllianceTower` fills 16x16 of 32x32; `UI-PVP-Alliance` ~40x40 of 64x64), +/// nothing is cropped, and the reference compensates *geometrically* — a 42x42 slot hung 6 units +/// off the row's left edge with the label seated 10 above its centreline (wow-re +/// `worldstate-ui-law.md` §12). Pin the OUTCOME rather than the constants: whatever the numbers, +/// the visible art must sit beside its label and share its line. A snug icon box — the obvious +/// thing to write, and what we shipped — puts the ink up and to the left of its own slot, which is +/// exactly what the director saw. Skips without client data. +#[test] +fn the_visible_ink_sits_beside_its_label_not_adrift_of_it() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("chain"); + + for (icon, label) in [ + ( + "Interface\\WorldStateFrame\\AllianceTower", + "Towers Controlled: 3", + ), + ("Interface\\TargetingFrame\\UI-PVP-Alliance", "0/200"), + ] { + let mut s = harness(); + s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]); + push(&mut s, vec![row(icon, label, "tip")]); + // The label is auto-sized, so its box only exists once the host has measured it — the app + // measures every frame; an unmeasured FontString has no height of its own and falls back to + // its owner's, which would put the label on the row's centreline rather than its anchor's. + s.resolve(); + let answers: Vec<(u32, f32, f32, u64)> = s + .fontstrings_needing_measure() + .into_iter() + .map(|r| (r.id, r.text.chars().count() as f32 * 5.0, 10.0, r.key)) + .collect(); + s.set_measured_text_unwrapped(&answers); + s.resolve(); + + let quads = s.extract(); + let slot = quads + .iter() + .find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon)) + .and_then(|q| q.rect) + .expect("the icon slot resolved"); + let text = quads + .iter() + .find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == label)) + .and_then(|q| q.rect) + .expect("the label resolved"); + + // The whole canvas is drawn into the slot (no texcoords anywhere on these regions), so the + // ink's screen rect is its texel box scaled into it. Rects are y-UP. + let (_, iy0, ix1, iy1, tw, th) = ink_box(&mut chain, &format!("{icon}.blp")); + let sw = (slot.right - slot.left) / tw; + let sh = (slot.top - slot.bottom) / th; + let ink_right = slot.left + ix1 * sw; + let ink_mid_y = slot.top - (iy0 + iy1) * 0.5 * sh; + let label_mid_y = (text.top + text.bottom) * 0.5; + + let (row_top, row_bottom) = s + .eval::<(f64, f64)>( + "local r = WorldStateAlwaysUpFrame1 return r:GetTop(), r:GetBottom()", + ) + .expect("the row resolved"); + let row_h = (row_top - row_bottom) as f32; + + // 1 · The art reads at the row's scale. This is the one that bites: squeeze the whole + // canvas into a snug box and the 16-texel sprite inside a 32-texel file draws at half + // size — a speck beside its label, which is what the oversized slot exists to prevent. + let ink_h = (iy1 - iy0) * sh; + assert!( + ink_h >= 0.7 * row_h, + "{icon}: the visible art is {ink_h} tall in a {row_h} row — the slot is sized for the \ + canvas, not for the sprite inside it" + ); + // 2 · It ends just before its label rather than half a slot away. + let gap = text.left - ink_right; + assert!( + (0.0..=10.0).contains(&gap), + "{icon}: the ink must end just before its label — gap {gap}" + ); + // 3 · And the two share a line. + assert!( + (ink_mid_y - label_mid_y).abs() <= 4.0, + "{icon}: the ink and its label must share a line — ink centre {ink_mid_y}, \ + label centre {label_mid_y}" + ); + } +} diff --git a/crates/benilla-formats/examples/m2_shared_section.rs b/crates/benilla-formats/examples/m2_shared_section.rs new file mode 100644 index 00000000..d372f24a --- /dev/null +++ b/crates/benilla-formats/examples/m2_shared_section.rs @@ -0,0 +1,98 @@ +//! **Which M2 batches share their triangles with another batch** — the coplanar-sibling census: +//! `cargo run -p benilla-formats --example m2_shared_section -- [path-substring]`. +//! +//! An M2 skin *section* is a run of triangles; a *batch* is one draw of one section. When two +//! batches name the SAME section they rasterize the SAME triangles — a base layer and a shine / +//! reflect layer authored on top of it (`ARMORREFLECT3`, `BALISTASHINE02`). The reference draws +//! both from one vertex array under depth-write + LEQUAL, so the second always wins the tie +//! exactly (wow-re `m2-depth-blend-state`). +//! +//! We only match that if both batches take the SAME vertex-transform path. They do not when a +//! consolidator (`static_gx`, `terrain_stream::merge`) admits one and refuses the other: the +//! retained lane bakes world positions on the CPU and the entity lane rotates on the GPU, and the +//! two agree only to the last few ULPs — enough, on coplanar triangles, to turn the depth test +//! into a per-pixel coin flip that re-rolls whenever the camera moves. That is the ballista's +//! flickering bolt heads and shields. +//! +//! So this prints, per model, every multi-batch section and whether its batches DISAGREE on the +//! facts the consolidators exclude on (env-mapped UVs, render flags `0x10` no-depth-write / `0x08` +//! no-depth-test, additive blend 3/4). A `SPLIT` line is a model that can z-fight against itself. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use std::io::Cursor; + +/// The facts every consolidator excludes on — a batch answering `true` can never ride the +/// retained/merged lanes, so it stays on the entity path whatever its siblings do. +fn refusable(model: &benilla_m2::M2Model, batch: &benilla_m2::SkinBatch) -> bool { + let mat = model.materials.get(batch.material_index as usize); + let flags = mat.map_or(0, |m| m.flags.bits()); + let blend = mat.map_or(0, |m| m.blend_mode.bits()); + model.stage_is_env_mapped(batch, 0) + || flags & 0x10 != 0 + || flags & 0x08 != 0 + || matches!(blend, 3 | 4) +} + +fn main() -> anyhow::Result<()> { + let pat = std::env::args().nth(1).unwrap_or_default().to_lowercase(); + let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); + let mut chain = benilla_formats::open_chain(&data)?; + let names: Vec = chain + .list()? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_lowercase(); + l.ends_with(".m2") && l.contains(&pat) + }) + .collect(); + let (mut models, mut with_shared, mut with_split) = (0usize, 0usize, 0usize); + let (mut batches_total, mut batches_shared, mut batches_split) = (0usize, 0usize, 0usize); + for name in &names { + let Ok(bytes) = chain.read_file(name) else { + continue; + }; + let Ok(m2) = benilla_m2::parse_m2(&mut Cursor::new(bytes.as_slice())) else { + continue; + }; + let model = m2.model(); + let Ok(skin) = model.parse_embedded_skin(&bytes, 0) else { + continue; + }; + models += 1; + batches_total += skin.batches().len(); + // section index → (batch count, refusable count) + let mut per_section: std::collections::BTreeMap = Default::default(); + for b in skin.batches() { + let e = per_section.entry(b.skin_section_index).or_default(); + e.0 += 1; + e.1 += usize::from(refusable(model, b)); + } + let shared: Vec<_> = per_section.iter().filter(|(_, v)| v.0 > 1).collect(); + if shared.is_empty() { + continue; + } + with_shared += 1; + let mut split = false; + for (sec, (n, r)) in &shared { + batches_shared += n; + // Disagreement is the defect: some of this section's batches divert, some cannot. + if *r > 0 && *r < *n { + split = true; + batches_split += n; + println!("SPLIT {name} section {sec}: {n} batches, {r} refusable"); + } + } + if split { + with_split += 1; + } + } + println!( + "\n{models} models parsed, {batches_total} batches\n\ + {with_shared} models have a multi-batch section ({batches_shared} batches in one)\n\ + {with_split} models SPLIT one ({batches_split} batches) — coplanar siblings that land in \ + different lanes" + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 03f64d3f..48726f08 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -685,6 +685,10 @@ pub fn parse_m2_render_submeshes( sub.fog_policy = fog_policy; // render flag 0x02 / the per-blend fog table sub.skin_slot = skin_slot; // so a skin-less load can fill this batch at spawn sub.geoset_id = section.id; // the skinSectionId — character geoset selection keys on it + // …and the section INDEX, which is a different question: `geoset_id` says which + // geoset a compositor may show, this says which TRIANGLES this batch draws. Two + // batches sharing it are coplanar by construction — see `RenderSubmesh::section`. + sub.section = Some(batch.skin_section_index); sub.wrap_x = wrap_x; // texture record flags 0x1/0x2 — clamp is a silhouette decision sub.wrap_y = wrap_y; sub.char_slot = char_slot; // character runtime slot (body/hair) — filled per-player at spawn diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 1b29aa2d..aa42e676 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -108,6 +108,7 @@ fn remap_submesh( rgb_seq: None, // …ditto (1408) wmo_batch: None, // set by the WMO path from the MOGP batch-section counts env_map: false, // set by the M2 path from texture_unit_lookup[texCoordSet] > 2 + section: None, // set by the M2 path from the batch's skin_section_index }, globals, ) diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 1c9fc1f2..2a3e28c6 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -499,6 +499,18 @@ pub struct RenderSubmesh { /// The batch's MOBA section for WMO group batches ([`WmoBatchClass`] — TRANS / INT / EXT, the /// per-class lighting law of an interior group). `None` for every M2 batch. pub wmo_batch: Option, + /// The M2 **skin-section index** this batch draws (`SkinBatch::skin_section_index`) — `None` + /// for every WMO batch, which has no such concept. + /// + /// Two batches of one model that name the SAME section rasterize the **same triangles**: a base + /// layer and the shine/reflect layer authored over it (`ARMORREFLECT3` on the ballista's bolt + /// heads and shields, `BALISTASHINE02` on the loose bolts — 264 sections across 221 world + /// doodads, `m2_shared_section`). The reference draws both from one vertex array under + /// depth-write + LEQUAL, so the later one wins the coplanar tie **exactly** (wow-re + /// `m2-depth-blend-state`). We only match that while both take the same vertex-transform path, + /// which is why the consolidators refuse a batch whose section they cannot take whole — see + /// `terrain_stream::spawn::assemble`. + pub section: Option, /// This batch's texture coordinates are **GENERATED, not authored** — a sphere-map environment /// coordinate derived per frame from the view-space reflection vector, not the vertex UVs /// ([`benilla_m2::M2Model::stage_is_env_mapped`]: `texture_unit_lookup[texCoordSet] > 2`). @@ -601,6 +613,7 @@ impl Default for RenderSubmesh { rgb_seq: None, wmo_batch: None, env_map: false, + section: None, } } } diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs index 2c28624e..40514dc5 100644 --- a/crates/benilla-formats/tests/m2_billboard_card_face.rs +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -124,6 +124,7 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() { rgb_seq: None, wmo_batch: None, env_map: false, + section: None, }; assert!( diff --git a/crates/benilla-formats/tests/m2_shared_section.rs b/crates/benilla-formats/tests/m2_shared_section.rs new file mode 100644 index 00000000..fa05f6cb --- /dev/null +++ b/crates/benilla-formats/tests/m2_shared_section.rs @@ -0,0 +1,89 @@ +//! Difftest **`RenderSubmesh::section`** against real art — the fact the consolidator gate in +//! `terrain_stream::spawn::assemble` rests on: two batches naming one M2 skin section draw the +//! SAME triangles, so they are coplanar and must never be split across vertex-transform lanes. +//! +//! `Ballista.m2` is the positive, and it is the model the defect was reported on: its bolt head +//! (section 7) and its shields (section 10) each carry a base batch **plus** an +//! `ARMORREFLECT3` shine batch — additive, render flag `0x10` (no depth write), env-mapped. Every +//! one of those three facts is a consolidator exclusion, so before the gate the base rode the +//! retained/merged lane while the shine stayed on the entity path, and the pair's depths agreed +//! only to a few ULPs: a per-pixel coin flip that re-rolled with the camera. +//! +//! `HumanTentMedium.m2` is the discriminator — a doodad whose every section is drawn once, which a +//! parse that answered "shared" everywhere would fail. +//! +//! Skips (passes) when the client isn't present at `/WoW/Data`. + +use benilla_formats::{load_m2_mesh, open_chain, RenderSubmesh}; + +/// The union of the facts the consolidators exclude on: `static_gx::divert` refuses env-mapped and +/// depth-flagged batches, the merge lane refuses additive ones. A batch answering `true` can never +/// leave the entity path, whatever its siblings do. +fn refusable(s: &RenderSubmesh) -> bool { + s.env_map || s.no_depth_write || s.no_depth_test || s.additive +} + +#[test] +fn the_ballista_authors_a_coplanar_shine_over_its_bolt_head_and_shields() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let subs = load_m2_mesh( + &mut chain, + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Ballista\\Ballista.m2", + ) + .expect("load Ballista"); + + // Every M2 batch names its section; the field exists to be compared, so `None` here would make + // the gate a silent no-op on the whole M2 population. + assert!( + subs.iter().all(|s| s.section.is_some()), + "every M2 batch carries its skin-section index" + ); + + // The two shared sections, and the split inside each: one plain base, one refusable shine. + let mut shared = 0; + for section in [7u16, 10] { + let batches: Vec<&RenderSubmesh> = + subs.iter().filter(|s| s.section == Some(section)).collect(); + assert_eq!( + batches.len(), + 2, + "Ballista section {section} is drawn by a base batch and a shine batch" + ); + assert_eq!( + batches.iter().filter(|s| refusable(s)).count(), + 1, + "exactly one of section {section}'s two batches is a consolidator exclusion — that \ + asymmetry is the defect the assemble gate closes" + ); + // The shine layer's own three facts, so a parse regression on any one of them red-bars + // here rather than silently re-opening the split. + let shine = batches.iter().find(|s| refusable(s)).unwrap(); + assert!(shine.env_map, "the shine batch generates its texcoords"); + assert!(shine.no_depth_write, "…carries render flag 0x10"); + assert!(shine.additive, "…and blends additively (blend mode 4)"); + shared += 1; + } + assert_eq!(shared, 2); +} + +#[test] +fn a_plain_doodad_draws_every_section_once() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let subs = load_m2_mesh( + &mut chain, + "World\\Generic\\Buildings\\HumanTentMedium\\HumanTentMedium.m2", + ) + .expect("load HumanTentMedium"); + + let mut seen: Vec> = subs.iter().map(|s| s.section).collect(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!( + seen.len(), + before, + "the tent draws each of its sections exactly once — nothing here is coplanar with itself" + ); +} diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index ea674cc2..eed8e4f9 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -198,6 +198,31 @@ pub fn spawn_model_entities( // The per-batch slot map (see [`SpawnedModel::by_batch`]) — every `continue` in the loop // below leaves a `None` in place instead of shifting later batches. let mut by_batch: Vec> = vec![None; submeshes.len()]; + // **Coplanar siblings ride ONE lane** — a batch whose M2 skin section is drawn by another + // batch of this same model never diverts into a consolidator, and neither does that sibling. + // + // Two batches naming one section rasterize the SAME triangles ([`RenderSubmesh::section`]): a + // base layer and the shine/reflect layer authored over it. The reference draws both from one + // vertex array under depth-write + LEQUAL, so the later batch wins that coplanar tie EXACTLY + // (wow-re `m2-depth-blend-state`). The consolidators exclude on per-batch facts — env-mapped + // UVs and the `0x10`/`0x08` depth flags (`static_gx::divert`), additive order (`merge`) — and a + // shine layer carries all of them, so the pair got split: base into the retained/merged lane, + // shine onto the entity path. Those two lanes reach the same world vertex by different + // arithmetic (the bake resolves it on the CPU in f64 and re-centres; the entity shader rotates + // it in f32 at local magnitude — `static_gx::bake`'s own note: "what remains against the entity + // path is the GPU's own rotate-at-local-magnitude rounding, which no bake can undercut"). A few + // ULPs is nothing between separate surfaces and everything between coplanar ones: the shine's + // reverse-Z GreaterEqual test against its own base becomes a per-pixel coin flip that re-rolls + // on every camera move — the ballista's bolt heads and shields dithering exactly the way B38's + // awning did (decision 0680). Keeping the section whole is the only place the two depths are + // equal by CONSTRUCTION; a depth bias would merely pick a winner for a tie we should never have + // created. It costs 264 sections across 221 world doodads (`m2_shared_section`). + let shared_geometry = shared_geometry( + &submeshes + .iter() + .map(|s| s.geometry.section) + .collect::>(), + ); for (batch_idx, sub) in submeshes.iter().enumerate() { // The batch's app-built render form (decision 0834). Callers gate spawning on the forms // being complete, so a miss here is a broken contract — skip the batch rather than panic. @@ -378,7 +403,7 @@ pub fn spawn_model_entities( // exterior FADER prop stays on the entity path (the exile protocol has no prop // shape — that keep is today's default look) and is TALLIED, never silent. if let Some((gx, site)) = staticgx.as_mut() { - let facts = if !crate::static_gx::enabled() { + let facts = if !crate::static_gx::enabled() || shared_geometry[batch_idx] { None } else { match site { @@ -474,6 +499,7 @@ pub fn spawn_model_entities( // falls through and spawns individually. if let Some((merge, site)) = merge.as_mut() { if crate::terrain_stream::merge::merge_enabled() + && !shared_geometry[batch_idx] && class.merges() // The fader lane is OPT-IN (`WOW_MERGE_FADERS=1`, decision 1423): a fader — // neither never-fade nor a steady interior prop — spawns per-entity by @@ -789,6 +815,20 @@ fn cull_bound(stat: Option<&Aabb>, anim: Option) -> Option { } } +/// Which batches share their triangles with another batch of the same model — the consolidator +/// gate's predicate, over the batches' [`benilla_formats::RenderSubmesh::section`] indices in batch +/// order. `true` at `i` means batch `i` names a section some other batch also names. +/// +/// `None` (every WMO batch — no section concept) never shares: the WMO lanes keep their own +/// MOBA-order law and are not what this closes. The list is a model's batch count long — tens at +/// the very most — so the quadratic scan is cheaper than any map. +fn shared_geometry(sections: &[Option]) -> Vec { + sections + .iter() + .map(|s| s.is_some() && sections.iter().filter(|o| *o == s).count() > 1) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -838,4 +878,25 @@ mod tests { assert!(Vec3::from(b.min()).abs_diff_eq(Vec3::splat(-1.0), 1e-4)); assert!(Vec3::from(b.max()).abs_diff_eq(Vec3::splat(1.0), 1e-4)); } + + /// The ballista's own shape: section 10 drawn twice (base + shine) and section 4 drawn once. + /// Both members of the pair are flagged, not just the refusable one — the whole point is that + /// they travel together, so gating only the shine would leave the base in the retained lane + /// and the split exactly where it was. + #[test] + fn both_batches_of_a_shared_section_are_flagged() { + let flags = shared_geometry(&[Some(4), Some(10), Some(10), Some(7)]); + assert_eq!(flags, vec![false, true, true, false]); + } + + /// WMO batches carry no section, and `None == None` would otherwise make every WMO batch in a + /// group "shared" with every other — pulling the whole WMO population out of the retained lane. + #[test] + fn section_less_batches_never_share() { + assert_eq!(shared_geometry(&[None, None, None]), vec![false; 3]); + assert_eq!( + shared_geometry(&[None, Some(0), Some(0), None]), + vec![false, true, true, false] + ); + } }