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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user