- 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>
99 lines
4.4 KiB
Rust
99 lines
4.4 KiB
Rust
//! **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(())
|
|
}
|