the zone counters line up, and the ballista shine holds still

- the world-state readout — the top-centre counters a battleground or Silithus keeps up — takes the reference's real geometry: every icon those rows name is a sprite authored into the upper-left corner of a power-of-two canvas (`AllianceTower` is 16×16 of ink at (3,2) in a 32×32 file), nothing anywhere crops it, and the reference compensates geometrically — a 42×42 slot hung 6 units off the row's left edge with the label seated 10 above its centreline, taken as one system; our snug 20×20 box stripped exactly that compensation, so the art drew at half size floating away from its own `0/200` label — and an empty icon column now reserves its rect instead of sliding the text left, which straightens the readout's left edge
- the dynamic icon is a second slot, not a swap: `Icon` and `DynamicIcon` feed two different regions and shipped content never mixes them, so the flag is a 32×32 button off the row's right edge with an ADD-blend flash overlay pulsing 0.5 s in / 0.5 s out, lit only at `uiState == 2` — the flag-taken value; before, any non-zero state replaced the static art, which fires on the ordinary state 1 and hid a faction shield that should never have moved, and the pulse's alpha ramp is now pinned by a test, not just its art
- the dev cost pill yields the band: the readout is the game, the pill is scaffolding, and a stock 1.12 UI has no free corner to move house to — so it steps below whatever the readout draws, reading `WorldStateAlwaysUpFrame:GetBottom()` on its own 4 Hz cadence and only while it is drawing, rather than mirroring the frame's row pitch in Rust; the probe's cost is stated at the site (an edge read settles the layout — up to one graph solve, ~47 µs, four times a second)
- the Hearthglen ballista's bolt heads and shields stop flickering: a shine layer is a second batch over its base's own triangles, and ours split the pair across lanes — the base rode the retained lane's f64 CPU bake while the env-mapped, additive, no-depth-write shine stayed on the entity path's f32 GPU rotate, and a few ULPs between coplanar surfaces turns the depth test into a per-pixel coin flip re-rolled by every camera move; a batch now names the M2 skin section it draws, neither member of a shared section diverts into a consolidator (depths equal by construction), and a corpus census bounds the cost — 1,080 of 9,691 models draw a section more than once, 264 such sections across 221 `World\` doodads — with toggling pixels at the pin dropping 0.26% to 0.14%, matching the consolidators-off arm bit-for-bit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:07:21 +07:00
co-authored by Claude Fable 5
parent 1da3fb044f
commit 3697e85525
12 changed files with 767 additions and 85 deletions
+153 -53
View File
@@ -2,8 +2,8 @@
<!--
The always-up world-state readout — report B190's second half: the alliance/horde progress
line an outdoor-PvP zone or a battleground shows at the top of the screen. In Eastern
Plaguelands that is the two tower counters ("Towers Controlled: 3") plus the capture-point
progress; in Warsong Gulch the flag captures; in Arathi Basin bases and resources.
Plaguelands that is the two tower counters ("Towers Controlled: 3"); in Warsong Gulch the
flag captures; in Arathi Basin bases and resources.
The SPEC is the reference's two C bindings, byte-carved (wow-re
system/ui/scratch/worldstate-ui-law.md, 0x4c5a40 / 0x4c5a70) and re-expressed here:
@@ -25,18 +25,46 @@
has joined the zone-dependent DEFENSE chat channel. Leave LocalDefense and the readout goes
away while the map icons stay.
`uiState` picks which of the two icons a row draws: the reference's dynamic pair is a state
that toggles (Warsong Gulch's "the enemy flag has been picked up"), so a row with a
dynamicIcon shows it while its state is live and the static icon otherwise. A row with no
StateVariable of its own answers uiState = 1, which is why the plain counters draw their
static icon and never blink.
THE GEOMETRY IS THE REFERENCE'S, and it is not what you would guess (wow-re
worldstate-ui-law.md §12, carved 2026-08-25; decision 1604 folds it back). The first pass
of this frame invented a snug 20x20 icon in a 22px row, and that was wrong in a way that
showed: EVERY icon these rows name is a sprite authored into the UPPER-LEFT corner of a
power-of-two canvas with the rest transparent — AllianceTower/HordeTower are a 16x16 sprite
at (3,2) of a 32x32 file, UI-PVP-Alliance/Horde a ~40x40 crest in a 64x64 one. Squeeze that
whole canvas into a snug box and the visible art floats up and to the left of the box,
away from its label. NOTHING IS CROPPED to fix that — there is no <TexCoords> on any of
these regions, and a texture with none samples the full 0..1 quad (CSimpleTexture's ctor
0x76fc40 stamps it). The compensation is geometric, and these three numbers are one tuned
system: the icon slot is OVERSIZED to 42x42 (a 32px file upscaled 1.3125x), hung SIX UNITS
OFF the row's left edge, and the text is seated TEN UNITS ABOVE the slot's centreline. The
upper-left ink then lands as a ~21x21 square whose centre sits just under the text's, which
is what "icon next to its label" is supposed to look like.
THERE ARE TWO ICON SLOTS, fed by two different DBC columns, and shipped content never puts
the same art in both:
Icon (+0x0c) -> the 42x42 slot below. AllianceTower, HordeTower,
UI-PVP-Alliance, UI-PVP-Horde.
DynamicIcon (+0x64) -> a SEPARATE 32x32 button off the row's right edge, shown only
while uiState == 2. HordeFlag/AllianceFlag, Warsong Gulch only.
So the dynamic icon does NOT replace the static one — both are on screen together, which is
the Warsong Gulch look: the faction shield with its capture count, and a flashing enemy
flag to its right while a carrier is running. `uiState == 2` is the flag-taken value; the
first pass swapped the art on `uiState ~= 0`, which fired on the ordinary state 1 and
replaced a shield that should never have moved.
OUT, named: the CAPTUREPOINT extended UI — the sliding bar over
Interface\WorldStateFrame\WorldState-CaptureBar that the one Eastern Plaguelands progress
row drives. Its two states come through the binding already (extUIState1/2 carry the
alliance/horde fractions), so it is a widget to add, not a seam to change; the row itself
renders as its text line until then, so the progress number is on screen either way. Also
out: the battleground scoreboard (the Type 2 rows), which the reference builds from the
alliance/horde fractions), so it is a widget to add, not a seam to change. NOW KNOWN, and
worth writing down before someone builds it: in the reference that row never reaches this
frame at all — the update path branches on a non-empty extendedUI FIRST, and id 138 is the
only always-up-class row with one, so EPL's real readout is two text rows here plus a
separate bar anchored under the MinimapCluster. Until that bar exists we keep showing the
row as its text line (decision 1590 §7's stand-in, unchanged) so the progress number is on
screen; it costs a third line the reference doesn't draw, and it goes when the bar lands.
Also out: the battleground scoreboard (the Type 2 rows), which the reference builds from the
same DBC through a DIFFERENT handler and a different pair of bindings whose bail leg is not
even the same shape — GetNumBattlefieldStats/GetBattlefieldStatInfo, a separate slice.
-->
@@ -44,11 +72,30 @@
<![CDATA[
NUM_ALWAYS_UP_ROWS = 0;
-- Vertical pitch between rows, and the icon's box. Ours, not measured from the reference:
-- this frame is being written rather than transcribed, and the numbers below are what make
-- a two- or three-row readout sit legibly under the top edge.
WORLD_STATE_ROW_HEIGHT = 22;
WORLD_STATE_ICON_SIZE = 20;
-- The reference's own numbers (worldstate-ui-law.md §12). The row is an anchor node, not a
-- box: its 42-wide icon slot hangs off its left edge and its text runs past its right, so
-- none of these are "the readout's size" — they are the offsets that seat corner-authored
-- art against a label. The pitch is the template height, because rows chain TOP to the
-- previous row's BOTTOM with no gap.
WORLD_STATE_ROW_HEIGHT = 24;
-- The dynamic-icon flash: 0.5 s in, 0.5 s out, indefinitely.
WORLD_STATE_FLASH_HALF = 0.5;
-- The flash overlay's ramp. The art (…Flash.blp) is DXT1 with no alpha channel — black IS
-- the transparency — which is why it draws ADD; the pulse is the region's own alpha.
function WorldStateDynamicIcon_OnUpdate(elapsed)
local t = (this.flashTime or 0) + elapsed;
local period = 2 * WORLD_STATE_FLASH_HALF;
while ( t >= 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 @@
]]>
</Script>
<!-- One row: an icon and its line of text, with the row's tooltip on hover. Grown on demand
into WorldStateAlwaysUpFrame by the update above. -->
<!-- One row: the oversized icon slot, its line of text, and the dynamic-icon button that only
Warsong Gulch ever lights. Grown on demand into WorldStateAlwaysUpFrame by the update
above. Every offset here is the reference's — see the header for why they are not the
snug numbers you would pick by eye. -->
<Frame name="WorldStateAlwaysUpTemplate" virtual="true" enableMouse="true">
<Size><AbsDimension x="220" y="20"/></Size>
<Size><AbsDimension x="45" y="24"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon">
<Size><AbsDimension x="20" y="20"/></Size>
<Size><AbsDimension x="42" y="42"/></Size>
<Anchors>
<Anchor point="LEFT"/>
<Anchor point="LEFT">
<Offset><AbsDimension x="-6" y="0"/></Offset>
</Anchor>
</Anchors>
</Texture>
<FontString name="$parentText" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentIcon" relativePoint="RIGHT">
<Offset><AbsDimension x="4" y="0"/></Offset>
<Offset><AbsDimension x="-12" y="10"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentDynamicIcon" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="RIGHT">
<Offset><AbsDimension x="40" y="9"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTexture">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="$parentFlash" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnUpdate>
WorldStateDynamicIcon_OnUpdate(arg1);
</OnUpdate>
<OnEnter>
if ( this.tooltip and this.tooltip ~= "" ) then
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetText(this.tooltip);
GameTooltip:Show();
end
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnEnter>
if ( this.tooltip and this.tooltip ~= "" ) then
@@ -156,12 +254,14 @@
<!-- The container. Top-centre of the screen, which is where an outdoor-PvP zone's counters and
a battleground's score belong: the minimap owns the top-right and the readout must not
fight it. Hidden until a scope admits a row. -->
<Frame name="WorldStateAlwaysUpFrame" parent="UIParent" hidden="true">
<Size><AbsDimension x="220" y="20"/></Size>
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. -->
<Frame name="WorldStateAlwaysUpFrame" parent="UIParent" toplevel="true" hidden="true">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="UIParent" relativePoint="TOP">
<Offset><AbsDimension x="0" y="-15"/></Offset>
<Offset><AbsDimension x="-5" y="-15"/></Offset>
</Anchor>
</Anchors>
<Scripts>
+105 -18
View File
@@ -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<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHu
}
/// Advance the HUD's 4 Hz snapshot — its own system (1453), kept separate so the sampling cadence
/// never depends on whether anything drew this frame.
/// never depends on whether anything drew this frame. It also re-asks who else is using the pill's
/// band ([`top_centre_claimed`]), on the same cadence and for the same reason: once per view, not
/// once per frame.
pub(super) fn refresh_hud_snapshot(
mut hud: ResMut<PerfHud>,
stats: Res<FrameStats>,
time: Res<Time<Real>>,
script: Option<NonSend<UiScript>>,
windows: Query<&Window, With<PrimaryWindow>>,
) {
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::<f64>(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<UiQuad>,
}
@@ -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);
}
}
+5
View File
@@ -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;
@@ -775,6 +775,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
rgb_anim: None,
rgb_seq: None,
wmo_batch: None,
section: None,
}
}
@@ -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::<f64>("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}"
);
}
}
@@ -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<String> = 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<u16, (usize, usize)> = 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(())
}
@@ -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
+1
View File
@@ -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,
)
@@ -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<WmoBatchClass>,
/// 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<u16>,
/// 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,
}
}
}
@@ -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!(
@@ -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 `<repo>/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<Option<u16>> = 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"
);
}
@@ -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<Option<Entity>> = 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::<Vec<_>>(),
);
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<Aabb>) -> Option<Aabb> {
}
}
/// 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<u16>]) -> Vec<bool> {
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]
);
}
}