Files
benilla-octo/crates/benilla-formats/examples/mm_seam_probe.rs
T
SamandClaude Fable 5 a76530ab62 the minimap's black lines close out, and shared water cells draw once
- the Stormwind/Orgrimmar minimap lines are gone, at the cause: a group's boundary tile is authored 1.0 yd larger on that side alone, so touching groups overlap by 2 yd — without the bleed a LINEAR-filtered silhouette loses ≈0.38 texel at every abutment and the black clear reads through as a dashed line; with it, disc black goes 4.49% → 3.69% and no lines, inside the reference's own band
- the tile sampler stays LINEAR but filters in gamma space (`GL_SKIP_DECODE_EXT`, as the reference's trace has it) — a terrain underlay and a NEAREST sampler both reproduced the symptom and were both retracted as wrong once wow-re refuted them
- the canal-surface line in the world was a second defect: Stormwind's canal groups genuinely overlap (14,416 lattice points claimed wet twice), so the translucent sheet composited twice — the MLIQ `0x80` shared-tile gate now resolves each shared cell to one owner at load, pixel-identical and zero double-claimed points, not one wet point lost
- the interior composite is probeable headless now: `WOW_MM_PROBE` synthesises the minimap slot server-less, and an offline rebuilder reproduces the defect and the fix from the shipped art

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:01:55 +07:00

688 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TEMP (B141): rasterise the WMO-interior minimap **offline**, from the shipped tiles and the
//! client's own placement + alpha test, so "where do the black lines come from" is a measurement on
//! the data rather than a reading of a screenshot.
//!
//! Writes three PNGs: `colour` (what the composite would show), `cover` (white where SOME tile
//! passes the 224/255 test), and `any` (white where some tile has alpha > 0 at all). A line that is
//! black in `cover` but white in `any` is the alpha test cutting art that exists; black in both is
//! a genuine hole in the bake — or a placement that never covers that spot.
use std::collections::BTreeMap;
use std::io::Cursor;
const MAP_CENTER: f32 = 17066.666;
const YD_PER_TEXEL: f32 = 0.5;
const TILE_SPAN_YD: f32 = 128.0;
const ALPHA_REF: u8 = 224;
fn next_pow2(n: u32) -> u32 {
if n <= 1 {
1
} else {
1u32 << (32 - (n - 1).leading_zeros())
}
}
/// `benilla_assets::minimap_grid::group_axis_grid`, inlined so this example needs no bevy dep.
fn group_axis_grid(extent: f32) -> (u32, f32) {
let texels = (extent / YD_PER_TEXEL).max(1.0);
let px = next_pow2(texels.ceil() as u32).clamp(32, 256);
(
((extent / TILE_SPAN_YD).ceil().max(1.0)) as u32,
px as f32 * YD_PER_TEXEL,
)
}
fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
let (rx, ry, rz) = (
rot_deg[0].to_radians(),
rot_deg[1].to_radians(),
rot_deg[2].to_radians(),
);
let rot_x = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
};
let rot_y = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
};
let rot_z = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
};
let p = rot_x(p, rz - std::f32::consts::FRAC_PI_2);
let p = rot_z(p, -rx);
let p = rot_y(p, ry - std::f32::consts::PI);
rot_x(p, std::f32::consts::FRAC_PI_2)
}
/// Inverse of [`model_to_world`] (the rotation is orthonormal, so: same angles, reversed order).
fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
let (rx, ry, rz) = (
rot_deg[0].to_radians(),
rot_deg[1].to_radians(),
rot_deg[2].to_radians(),
);
let rot_x = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
};
let rot_y = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
};
let rot_z = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
};
let p = rot_x(p, -std::f32::consts::FRAC_PI_2);
let p = rot_y(p, -(ry - std::f32::consts::PI));
let p = rot_z(p, rx);
rot_x(p, -(rz - std::f32::consts::FRAC_PI_2))
}
struct Tile {
gi: usize,
/// The drawn rect (grid cell + outer-edge bleed).
x0: f32,
y0: f32,
tw: f32,
th: f32,
w: u32,
h: u32,
rgba: Vec<u8>,
midz: f32,
}
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let pin = [
args.first().map_or(-8796.83, |s| s.parse().unwrap()),
args.get(1).map_or(744.25, |s| s.parse().unwrap()),
args.get(2).map_or(97.83, |s| s.parse().unwrap()),
];
let half: f32 = args.get(3).map_or(90.0, |s| s.parse().unwrap());
let out = std::env::var("MM_OUT").unwrap_or_else(|_| ".".into());
let data = benilla_formats::wow_data().expect("no WoW install");
let mut chain = benilla_formats::open_chain(&data)?;
let trs = benilla_formats::load_minimap_translate(&mut chain)?;
// Find the placement whose model bbox contains the pin, over every WMO placed in this ADT.
let (tc, tr) = benilla_formats::world_to_tile(pin[0], pin[1]);
let adt_name = format!("World\\Maps\\Azeroth\\Azeroth_{tc}_{tr}.adt");
let bytes = chain.read_file(&adt_name)?;
let benilla_adt::ParsedAdt::Root(adt) = benilla_adt::parse_adt(&mut Cursor::new(&*bytes))?;
let mut best: Option<(String, [f32; 3], [f32; 3], f32)> = None;
for p in &adt.wmo_placements {
let Some(model) = adt.wmos.get(p.name_id as usize) else {
continue;
};
let o = [
MAP_CENTER - p.position[2],
MAP_CENTER - p.position[0],
p.position[1],
];
let d = (o[0] - pin[0]).hypot(o[1] - pin[1]);
if best.as_ref().is_none_or(|b| d < b.3) {
best = Some((model.replace('/', "\\"), o, p.rotation, d));
}
}
let (model, origin, rot, _) = best.expect("no WMO placement in this ADT");
// MM_MODEL="x,y[,z]" pins in the WMO's MODEL space instead — how a captured reference frame
// states the player's position, so a capture can be reproduced exactly.
let pm = match std::env::var("MM_MODEL") {
Ok(v) => {
let n: Vec<f32> = v.split(',').map(|t| t.trim().parse().unwrap()).collect();
[n[0], n[1], *n.get(2).unwrap_or(&0.0)]
}
Err(_) => world_to_model(
rot,
[pin[0] - origin[0], pin[1] - origin[1], pin[2] - origin[2]],
),
};
let back = model_to_world(rot, pm);
eprintln!("model {model}\n origin {origin:?} rot {rot:?}\n pin in model space: {pm:?}\n round-trip: {back:?} vs {:?}",
[pin[0] - origin[0], pin[1] - origin[1], pin[2] - origin[2]]);
let stem = model
.trim_end_matches(".wmo")
.trim_end_matches(".WMO")
.to_string();
let key_stem = stem
.to_ascii_lowercase()
.split_once("world\\")
.map(|(_, r)| r.to_string())
.unwrap();
let root = benilla_formats::parse_wmo_root(&chain.read_file(&format!("{stem}.wmo"))?)?;
let infos = root.group_infos().to_vec();
let (mut glo, mut ghi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
for i in &infos {
for k in 0..3 {
glo[k] = glo[k].min(i.bbox_min[k]);
ghi[k] = ghi[k].max(i.bbox_max[k]);
}
}
eprintln!("{} groups, model bbox {glo:?} .. {ghi:?}", infos.len());
// MM_AUDIT: our computed per-group tile grid against the one actually authored in the trs.
// A short grid leaves world the composite can never cover, whatever the group selection does.
if std::env::var("MM_AUDIT").is_ok() {
let (mut bad, mut tiles_ours, mut tiles_theirs) = (0usize, 0usize, 0usize);
for (gi, info) in infos.iter().enumerate() {
let (mut mc, mut mr, mut present) = (-1i32, -1i32, 0usize);
for c in 0..16u32 {
for r in 0..16u32 {
if trs
.get(&format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp"))
.is_some()
{
mc = mc.max(c as i32);
mr = mr.max(r as i32);
present += 1;
}
}
}
if present == 0 {
continue;
}
let (nx, twx) = group_axis_grid(info.bbox_max[0] - info.bbox_min[0]);
let (ny, twy) = group_axis_grid(info.bbox_max[1] - info.bbox_min[1]);
tiles_ours += (nx * ny) as usize;
tiles_theirs += present;
if nx as i32 != mc + 1 || ny as i32 != mr + 1 {
bad += 1;
if bad <= 20 {
println!(
" g{gi:3}: ours {nx}x{ny} (tile {twx}x{twy} yd), authored {}x{} ({present} tiles) extent {:.1} x {:.1}",
mc + 1, mr + 1,
info.bbox_max[0] - info.bbox_min[0], info.bbox_max[1] - info.bbox_min[1],
);
}
}
}
println!("AUDIT: {bad} groups with a grid mismatch; we ask for {tiles_ours} tiles, {tiles_theirs} are authored");
return Ok(());
}
// MM_BLP: what the tile art actually declares and holds — the alpha histogram of every tile
// near the pin, so "transparent" is a fact about the file rather than about our decoder.
// MM_FIT: the authored tile's PIXEL size against the group's world extent — i.e. what the bake's
// real yards-per-texel is, per group, instead of the 0.5 we assume.
if std::env::var("MM_FIT").is_ok() {
println!(" gi authored px grid extent yd yd/texel (x, y)");
let mut ratios: Vec<f32> = Vec::new();
for (gi, info) in infos.iter().enumerate() {
let (mut mc, mut mr) = (-1i32, -1i32);
let mut dim = (0u32, 0u32);
for c in 0..16u32 {
for r in 0..16u32 {
let key = format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp");
let Some(hashed) = trs.get(&key) else {
continue;
};
mc = mc.max(c as i32);
mr = mr.max(r as i32);
if c == 0 && r == 0 {
if let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{hashed}")) {
if let Ok(d) = benilla_blp::decode(&b) {
dim = (d.width, d.height)
}
}
}
}
}
if mc < 0 || dim.0 == 0 {
continue;
}
let (ex, ey) = (
info.bbox_max[0] - info.bbox_min[0],
info.bbox_max[1] - info.bbox_min[1],
);
// Tile art is transposed vs the model axes: image WIDTH runs along model Y, HEIGHT along X.
let (nx, ny) = ((mc + 1) as f32, (mr + 1) as f32);
let ypt_x = ex / (dim.1 as f32 * ny);
let ypt_y = ey / (dim.0 as f32 * nx);
ratios.push(ypt_x);
ratios.push(ypt_y);
if gi < 24 {
println!(
" g{gi:3} {:3}x{:<3} {}x{} {ex:7.1} x {ey:7.1} {ypt_x:.4} {ypt_y:.4}",
dim.0,
dim.1,
mc + 1,
mr + 1
);
}
}
ratios.sort_by(f32::total_cmp);
let mean = ratios.iter().sum::<f32>() / ratios.len() as f32;
println!(
"yd/texel over {} axes: min {:.4} median {:.4} max {:.4} mean {mean:.4}",
ratios.len(),
ratios[0],
ratios[ratios.len() / 2],
ratios[ratios.len() - 1]
);
return Ok(());
}
// MM_ANCHOR: where inside its power-of-two texture does a single-tile group's OPAQUE art sit,
// and how big is it — against the group's own extent at 0.5 yd/texel. This is what says whether
// the bake pads (art at one corner, transparent remainder) or stretches, and which corner.
if std::env::var("MM_ANCHOR").is_ok() {
println!(" gi tex opaque box (u0,u1)x(v0,v1) extent/0.5 texels fill");
for (gi, info) in infos.iter().enumerate().take(40) {
let key = format!("{key_stem}_{gi:03}_00_00.blp");
let Some(hashed) = trs.get(&key) else {
continue;
};
if trs.get(&format!("{key_stem}_{gi:03}_01_00.blp")).is_some()
|| trs.get(&format!("{key_stem}_{gi:03}_00_01.blp")).is_some()
{
continue;
}
let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{hashed}")) else {
continue;
};
let Ok(d) = benilla_blp::decode(&b) else {
continue;
};
let m = &d.mips[0];
let (mut u0, mut u1, mut v0, mut v1) = (u32::MAX, 0u32, u32::MAX, 0u32);
for v in 0..m.height {
for u in 0..m.width {
if m.rgba[((v * m.width + u) * 4 + 3) as usize] != 0 {
u0 = u0.min(u);
u1 = u1.max(u);
v0 = v0.min(v);
v1 = v1.max(v);
}
}
}
if u0 == u32::MAX {
continue;
}
let (ex, ey) = (
info.bbox_max[0] - info.bbox_min[0],
info.bbox_max[1] - info.bbox_min[1],
);
println!(" g{gi:3} {:3}x{:<3} u[{u0:3},{u1:3}] v[{v0:3},{v1:3}] X {:.1} tx, Y {:.1} tx {:.0}x{:.0}",
m.width, m.height, ex / YD_PER_TEXEL, ey / YD_PER_TEXEL, u1 - u0 + 1, v1 - v0 + 1);
}
return Ok(());
}
// MM_MULTI: the same anchor read on MULTI-tile groups, which is what says which name index runs
// along which model axis and where the power-of-two padding lands across a row of tiles.
if std::env::var("MM_MULTI").is_ok() {
let mut shown = 0;
for (gi, info) in infos.iter().enumerate() {
let mut cells = Vec::new();
for c in 0..8u32 {
for r in 0..8u32 {
if let Some(h) = trs.get(&format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp")) {
cells.push((c, r, h.to_string()));
}
}
}
if cells.len() < 2 {
continue;
}
let (ex, ey) = (
info.bbox_max[0] - info.bbox_min[0],
info.bbox_max[1] - info.bbox_min[1],
);
println!(
"g{gi} extent X {ex:.1} ({:.1} tx) Y {ey:.1} ({:.1} tx), {} tiles",
ex / 0.5,
ey / 0.5,
cells.len()
);
for (c, r, h) in &cells {
let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{h}")) else {
continue;
};
let Ok(d) = benilla_blp::decode(&b) else {
continue;
};
let m = &d.mips[0];
let (mut u0, mut u1, mut v0, mut v1) = (u32::MAX, 0u32, u32::MAX, 0u32);
for v in 0..m.height {
for u in 0..m.width {
if m.rgba[((v * m.width + u) * 4 + 3) as usize] != 0 {
u0 = u0.min(u);
u1 = u1.max(u);
v0 = v0.min(v);
v1 = v1.max(v);
}
}
}
println!(
" {c}_{r}: {:3}x{:<3} opaque u[{u0:3},{u1:3}] v[{v0:3},{v1:3}]",
m.width, m.height
);
}
shown += 1;
if shown >= 6 {
break;
}
}
return Ok(());
}
if std::env::var("MM_BLP").is_ok() {
for gi in 0..infos.len() {
for c in 0..4u32 {
for r in 0..4u32 {
let key = format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp");
let Some(hashed) = trs.get(&key) else {
continue;
};
let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{hashed}")) else {
continue;
};
let hdr = (
u32::from_le_bytes(b[8..12].try_into().unwrap()), // compression
b[12],
b[13],
b[14],
b[15], // alphaDepth, alphaType, hasMips, ?
);
let Ok(d) = benilla_blp::decode(&b) else {
continue;
};
let m = &d.mips[0];
let zero = m.rgba.chunks(4).filter(|p| p[3] == 0).count();
let full = m.rgba.chunks(4).filter(|p| p[3] == 255).count();
let n = m.rgba.len() / 4;
println!("g{gi:3} {c}_{r} {}x{} hdr(comp={} aDepth={} aType={} mips={}) alpha0 {:.1}% alpha255 {:.1}% other {}",
m.width, m.height, hdr.0, hdr.1, hdr.2, hdr.3,
100.0 * zero as f32 / n as f32, 100.0 * full as f32 / n as f32, n - zero - full);
if gi > 60 {
return Ok(());
}
}
}
}
return Ok(());
}
let (lo, hi) = ([pm[0] - half, pm[1] - half], [pm[0] + half, pm[1] + half]);
let mut tiles: Vec<Tile> = Vec::new();
let mut cache: BTreeMap<String, Option<(u32, u32, Vec<u8>)>> = BTreeMap::new();
for (gi, info) in infos.iter().enumerate() {
let (nx, twx) = group_axis_grid(info.bbox_max[0] - info.bbox_min[0]);
let (ny, twy) = group_axis_grid(info.bbox_max[1] - info.bbox_min[1]);
for col in 0..nx {
for row in 0..ny {
// MEASURED tile layout (MM_ANCHOR / MM_MULTI, read off the shipped art): image u
// runs along model +X with u_global = 0 at the group's bbox X-MIN, column index
// advancing with +X; image v runs along model Y INVERTED, the grid anchored at its
// BOTTOM so the last row's last texel sits at the group's Y anchor.
// The verified rect: cells stride `tw` and share their interior edges, and a cell
// on the grid's boundary is grown 1.0 yd on that side alone (wow-re
// `wmo-interior-no-adt-underlay.md` §8). `MM_NOBLEED` drops the bleed, which is
// what B141 was: without it two groups' art abuts instead of overlapping by 2 yd.
let bleed = if std::env::var("MM_NOBLEED").is_ok() {
0.0
} else {
1.0
};
let x0 = info.bbox_min[0] + col as f32 * twx - if col == 0 { bleed } else { 0.0 };
let x1 = info.bbox_min[0]
+ (col + 1) as f32 * twx
+ if col + 1 == nx { bleed } else { 0.0 };
let y0 = info.bbox_min[1] + row as f32 * twy - if row == 0 { bleed } else { 0.0 };
let y1 = info.bbox_min[1]
+ (row + 1) as f32 * twy
+ if row + 1 == ny { bleed } else { 0.0 };
if x0 > hi[0] || x1 < lo[0] || y0 > hi[1] || y1 < lo[1] {
continue;
}
let key = format!("{key_stem}_{gi:03}_{col:02}_{row:02}.blp");
let dec = cache.entry(key.clone()).or_insert_with(|| {
let hashed = trs.get(&key)?;
let b = chain
.read_file(&format!("textures\\Minimap\\{hashed}"))
.ok()?;
let d = benilla_blp::decode(&b).ok()?;
let m = d.mips.first()?;
Some((m.width, m.height, m.rgba.clone()))
});
let Some((w, h, rgba)) = dec.clone() else {
continue;
};
tiles.push(Tile {
gi,
x0,
y0,
tw: x1 - x0,
th: y1 - y0,
w,
h,
rgba,
midz: 0.5 * (info.bbox_min[2] + info.bbox_max[2]),
});
}
}
}
let hits = cache.values().filter(|v| v.is_some()).count();
eprintln!(
"{} tiles overlap the window ({} keys probed, {hits} resolved)",
tiles.len(),
cache.len()
);
for k in cache.iter().filter(|(_, v)| v.is_none()).take(4) {
eprintln!(" MISS {}", k.0);
}
// Same order the app draws in: by group bbox Z-mid relative to the player, ascending.
tiles.sort_by(|a, b| {
(a.midz - pm[2])
.abs()
.total_cmp(&(b.midz - pm[2]).abs())
.reverse()
});
let px = (half * 2.0 / YD_PER_TEXEL) as u32;
let mut colour = vec![0u8; (px * px * 3) as usize];
let mut cover = vec![0u8; (px * px) as usize];
let mut anyv = vec![0u8; (px * px) as usize];
let mut owner = vec![0u8; (px * px * 3) as usize];
let bilinear = std::env::var("MM_BILINEAR").is_ok();
// OPTIONAL UNDERLAY: the outdoor ADT minimap tiles (`Azeroth\\map<x>_<y>.blp`), sampled through
// the placement so they land under the WMO tiles in the same frame. `MM_TERRAIN=1`.
if std::env::var("MM_TERRAIN").is_ok() {
let mut adt_cache: BTreeMap<(u32, u32), Option<Vec<u8>>> = BTreeMap::new();
for iy in 0..px {
for ix in 0..px {
let my = pm[1] + half - (ix as f32 + 0.5) * YD_PER_TEXEL;
let mx = pm[0] + half - (iy as f32 + 0.5) * YD_PER_TEXEL;
let w = model_to_world(rot, [mx, my, pm[2]]);
let (wx, wy) = (origin[0] + w[0], origin[1] + w[1]);
let (tx, ty) = benilla_formats::world_to_tile(wx, wy);
let e = adt_cache.entry((tx, ty)).or_insert_with(|| {
let key = format!("Azeroth\\map{tx}_{ty}.blp");
let hashed = trs.get(&key)?;
let b = chain
.read_file(&format!("textures\\Minimap\\{hashed}"))
.ok()?;
let d = benilla_blp::decode(&b).ok()?;
Some(d.mips.first()?.rgba.clone())
});
let Some(rgba) = e else { continue };
let (ox, oy) = benilla_formats::tile_to_world(tx, ty);
let u = (((oy - wy) / 533.3333) * 256.0) as u32;
let v = (((ox - wx) / 533.3333) * 256.0) as u32;
let s = ((v.min(255) * 256 + u.min(255)) * 4) as usize;
let o = (iy * px + ix) as usize;
cover[o] = 255;
colour[o * 3..o * 3 + 3].copy_from_slice(&rgba[s..s + 3]);
}
}
}
for t in &tiles {
let gcol = [
(t.gi * 97 % 251) as u8,
(t.gi * 57 % 241) as u8,
(t.gi * 151 % 233) as u8,
];
for iy in 0..px {
for ix in 0..px {
// Screen: +x is model −y, +y (down) is model −x (the app's `to_target`).
let my = pm[1] + half - (ix as f32 + 0.5) * YD_PER_TEXEL;
let mx = pm[0] + half - (iy as f32 + 0.5) * YD_PER_TEXEL;
if mx < t.x0 || mx >= t.x0 + t.tw || my < t.y0 || my >= t.y0 + t.th {
continue;
}
// MM_BILINEAR reproduces the reference's LINEAR sampler (CLAMP_TO_EDGE), so the
// alpha the 224/255 test sees is the interpolated one — which is what decides how
// far a tile's opaque art actually reaches past its last opaque texel CENTRE
// (0.122 of a texel at ref 224, against nearest's half-texel to the texel EDGE).
// The client samples `[0.5/W, 1−0.5/W]` across the drawn rect, so the W texel
// CENTRES span it exactly: step = rect / (W−1), texel 0's centre on the rect edge.
let fu = (mx - t.x0) / (t.tw / (t.w as f32 - 1.0));
let fv = (t.y0 + t.th - my) / (t.th / (t.h as f32 - 1.0));
let (u, v) = (
(fu + 0.5).max(0.0).min(t.w as f32 - 1.0) as u32,
(fv + 0.5).max(0.0).min(t.h as f32 - 1.0) as u32,
);
let s = ((v * t.w + u) * 4) as usize;
let a = if bilinear {
let (i0, j0) = (fu.floor(), fv.floor());
let (wx, wy) = (fu - i0, fv - j0);
let at = |i: f32, j: f32| -> f32 {
let i = (i.max(0.0) as u32).min(t.w - 1);
let j = (j.max(0.0) as u32).min(t.h - 1);
f32::from(t.rgba[((j * t.w + i) * 4 + 3) as usize])
};
let top = at(i0, j0) * (1.0 - wx) + at(i0 + 1.0, j0) * wx;
let bot = at(i0, j0 + 1.0) * (1.0 - wx) + at(i0 + 1.0, j0 + 1.0) * wx;
(top * (1.0 - wy) + bot * wy).round() as u8
} else {
t.rgba[s + 3]
};
let o = (iy * px + ix) as usize;
if a > 0 {
anyv[o] = 255
}
if a >= ALPHA_REF {
cover[o] = 255;
colour[o * 3..o * 3 + 3].copy_from_slice(&t.rgba[s..s + 3]);
owner[o * 3..o * 3 + 3].copy_from_slice(&gcol);
}
}
}
}
// A HAIRLINE is an uncovered run at most 4 px wide with coverage on BOTH sides — a seam
// between two tiles, as opposed to the large unauthored gaps (streets, the exterior group)
// that are simply not part of any interior group's bake.
let mut seam = colour.clone();
let mut hair = 0usize;
let mut runs: BTreeMap<usize, usize> = BTreeMap::new();
for axis in 0..2 {
for a in 0..px {
let mut i = 0u32;
while i < px {
let at = |k: u32| -> usize {
if axis == 0 {
(a * px + k) as usize
} else {
(k * px + a) as usize
}
};
if cover[at(i)] == 0 {
let mut j = i;
while j < px && cover[at(j)] == 0 {
j += 1
}
let len = (j - i) as usize;
if len <= 4 && i > 0 && j < px {
hair += len;
*runs.entry(len).or_default() += 1;
for k in i..j {
let o = at(k) * 3;
seam[o] = 255;
seam[o + 1] = 0;
seam[o + 2] = 255;
}
}
i = j;
} else {
i += 1
}
}
}
}
println!("hairline px (uncovered run <=4 with coverage both sides): {hair} run-length histogram {runs:?}");
image::save_buffer(
format!("{out}/mm_seam.png"),
&seam,
px,
px,
image::ColorType::Rgb8,
)?;
// The VISIBLE DISC — what the blit actually shows: radius `MM_DISC` yards about the player.
// The reference capture reports 95.84% painted / 4.16% clear over exactly this.
if let Ok(v) = std::env::var("MM_DISC") {
let rd: f32 = v.parse().unwrap();
let c0 = px as f32 * 0.5;
let (mut inside, mut painted) = (0usize, 0usize);
for iy in 0..px {
for ix in 0..px {
let d = ((ix as f32 + 0.5 - c0).powi(2) + (iy as f32 + 0.5 - c0).powi(2)).sqrt()
* YD_PER_TEXEL;
if d > rd {
continue;
}
inside += 1;
if cover[(iy * px + ix) as usize] != 0 {
painted += 1
}
}
}
println!(
"DISC r={rd} yd: {:.2}% painted, {:.2}% on the clear",
100.0 * painted as f32 / inside as f32,
100.0 * (inside - painted) as f32 / inside as f32
);
}
let holes = cover.iter().filter(|&&c| c == 0).count();
let recoverable = cover
.iter()
.zip(&anyv)
.filter(|(&c, &a)| c == 0 && a > 0)
.count();
println!("{px}x{px} px @ {YD_PER_TEXEL} yd: {holes} uncovered ({:.2}%), of which {recoverable} have art the alpha test cut ({:.1}%)",
100.0 * holes as f32 / (px * px) as f32, 100.0 * recoverable as f32 / holes.max(1) as f32);
image::save_buffer(
format!("{out}/mm_colour.png"),
&colour,
px,
px,
image::ColorType::Rgb8,
)?;
image::save_buffer(
format!("{out}/mm_cover.png"),
&cover,
px,
px,
image::ColorType::L8,
)?;
image::save_buffer(
format!("{out}/mm_any.png"),
&anyv,
px,
px,
image::ColorType::L8,
)?;
image::save_buffer(
format!("{out}/mm_owner.png"),
&owner,
px,
px,
image::ColorType::Rgb8,
)?;
eprintln!("wrote {out}/mm_{{colour,cover,any,owner}}.png");
Ok(())
}