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 @@
]]>
-
+
-
+
-
+
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WorldStateDynamicIcon_OnUpdate(arg1);
+
+
+ if ( this.tooltip and this.tooltip ~= "" ) then
+ GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
+ GameTooltip:SetText(this.tooltip);
+ GameTooltip:Show();
+ end
+
+
+ GameTooltip:Hide();
+
+
+
+
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]
+ );
+ }
}