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>
This commit is contained in:
Sam
2026-08-21 18:01:55 +07:00
co-authored by Claude Fable 5
parent 35a7c870ba
commit a76530ab62
10 changed files with 1190 additions and 18 deletions
+91 -17
View File
@@ -80,6 +80,14 @@ const ZOOM_CHUNKS: [f32; 6] = [14.0, 12.0, 10.0, 8.0, 6.0, 4.0];
/// the field through a computed pointer. Superseded in wow-re; do not "restore" a constant here.
const INTERIOR_ZOOM_RADIUS: [f32; 6] = [150.0, 120.0, 90.0, 60.0, 40.0, 25.0];
/// The **outer-edge bleed**: a minimap tile on the boundary of its group's grid is drawn 1.0 yd
/// larger on that side, so a group's art extends 1 yd past its bbox all the way round and two
/// groups whose boxes touch overlap by 2 yd. Interior cell edges are shared exactly. Byte-verified
/// (`0x6a549e`…`0x6a54db`, the constant `0xca8098` built in the emitter as `0.5 + 0.5`) and fitted
/// to the reference's captured quads with zero error — wow-re
/// `system/minimap/scratch/wmo-interior-no-adt-underlay.md` §8.
const EDGE_BLEED_YD: f32 = 1.0;
/// The client's half-texel UV inset, as the quad scale that reproduces it: a tile spanning
/// `extent` yards is baked at [`YD_PER_TEXEL`](benilla_assets::minimap_grid::YD_PER_TEXEL), so it
/// is `W = extent / 0.5` texels wide, and mapping texel *centres* to the quad's edges instead of
@@ -146,12 +154,37 @@ fn load_tile(asset_server: &AssetServer) -> impl Fn(&str) -> Handle<Image> + '_
asset_server.load_with_settings(
format!("mpq://textures/Minimap/{hash}"),
|s: &mut benilla_assets::BlpLoaderSettings| {
s.variant = benilla_assets::BlpVariant::Sprite;
s.variant = benilla_assets::BlpVariant::MapTile;
},
)
}
}
/// The **headless probe's** minimap widget. `WOW_MM_PROBE` drops the player inside a building in a
/// server-less capture ([`crate::capture`]), but the real slot comes from the FrameXML `<Minimap>`
/// extraction, which needs a logged-in UI — so without this the interior branch never runs and the
/// composite cannot be looked at offline. Under the probe (and only then) we synthesise the slot the
/// script would have published: a square in the top-right corner at the client's default zoom.
///
/// This is the instrument the B141 arc needed and did not have — the interior composite was being
/// argued about from screenshots because nothing could render it without a server.
fn probe_minimap_widget(mut widget: ResMut<MinimapWidget>, windows: Query<&Window>) {
if widget.0.is_some() || std::env::var("WOW_MM_PROBE").is_err() {
return;
}
let Ok(win) = windows.single() else { return };
let side = (win.height() * 0.22).min(win.width() * 0.22);
let margin = side * 0.15;
let min = Vec2::new(win.width() - side - margin, margin);
widget.0 = Some(MinimapSlot {
rect: Rect::from_corners(min, min + Vec2::splat(side)),
z: u64::MAX / 2,
zoom: 3,
inside_zoom: 3,
alpha: 1.0,
});
}
/// This frame's extracted `<Minimap>` widget slot, written by `ui_script::extract::drive_script` (the
/// `QuadContent::Minimap` arm) — `None` when no Minimap widget is visible (cluster hidden, no XML).
#[derive(Resource, Default)]
@@ -501,9 +534,36 @@ fn emit_minimap(
let mid_z = 0.5 * (gn.bbox_min[2] + gn.bbox_max[2]);
for col in 0..nx {
for row in 0..ny {
let mcx = gn.bbox_min[0] + (col as f32 + 0.5) * tw_x;
let mcy = gn.bbox_min[1] + (row as f32 + 0.5) * tw_y;
let tc = to_target([mcx, mcy, mid_z]);
// The tile's world rect: the grid cell PLUS the client's outer-edge BLEED. The
// cells themselves stride exactly `tw`, sharing their interior edges — but a
// cell on the grid's boundary is grown by 1.0 yd on that side alone
// (`0x6a549e`/`0x6a54ae`/`0x6a54be`/`0x6a54d1`, each an `fsub`/`fadd` of
// `0xca8098 = 0.5 + 0.5`; wow-re `wmo-interior-no-adt-underlay.md` §8, fitted
// to the reference's own captured quads with zero error on every bound). A
// 1×1 grid is therefore `tw + 2` across, an end cell `tw + 1`, an interior
// cell exactly `tw`.
//
// THIS is what makes the joints work. The bleed grows every group's art 1 yd
// past its bbox on each outer side, so two groups whose boxes touch OVERLAP by
// 2 yd. Without it their art merely abuts — and abutting art does not survive
// the composite's alpha test: `GEQUAL 224/255` on a LINEAR-filtered silhouette
// reaches only 0.122 of a texel past the last opaque texel centre (nearest
// would reach the texel edge, 0.5), so an abutting pair loses ~0.38 texel and
// the black clear reads through the strip for the length of the wall. That is
// B141's dashed hairline; a 2 yd overlap absorbs the same erosion with four
// texels to spare. In the reference's captured frame 16 of 220 inter-group
// tile contacts exist ONLY because of the bleed.
let x0 = gn.bbox_min[0] + col as f32 * tw_x
- if col == 0 { EDGE_BLEED_YD } else { 0.0 };
let x1 = gn.bbox_min[0]
+ (col + 1) as f32 * tw_x
+ if col + 1 == nx { EDGE_BLEED_YD } else { 0.0 };
let y0 = gn.bbox_min[1] + row as f32 * tw_y
- if row == 0 { EDGE_BLEED_YD } else { 0.0 };
let y1 = gn.bbox_min[1]
+ (row + 1) as f32 * tw_y
+ if row + 1 == ny { EDGE_BLEED_YD } else { 0.0 };
let tc = to_target([0.5 * (x0 + x1), 0.5 * (y0 + y1), mid_z]);
// Window cull: skip tiles whose centre lands well outside the TARGET (which
// holds 1.5× what the blit shows, so this is wider than the visible disc —
// deliberately: the client composites the same margin).
@@ -521,20 +581,17 @@ fn emit_minimap(
composite.tiles.push(composite::CompositeTile {
texture: handle.clone(),
center: tc,
// The client's HALF-TEXEL UV INSET (`0x4ebfca`–`0x4ec03b`: `du = 0.5/W`,
// `dv = 0.5/H`), expressed as the scale it is: sampling `[du, 1-du]` across
// a quad of size `S` is the same as sampling `[0, 1]` across a quad of
// `S·W/(W−1)` about the same centre, so the shared unit mesh keeps its UVs
// and the inset rides the Transform like everything else here. The quad
// edges then land on the outermost texel CENTRES, which stretches each
// tile's art outward by half a texel per side — and that is what closes
// the ~20% of group joints that butt rather than overlap. Without it a
// butt joint leaves a texel-wide strip that no tile covers, and the clear
// reads through it for the whole length of the wall (B141's residue after
// the alpha test alone).
// The client's HALF-TEXEL UV INSET, expressed as the scale it is: it
// samples `[0.5/W, 1−0.5/W]` across the rect above (verified on the
// captured quads — every one carries exactly that UV rect), and sampling
// that range across a quad of size `Q` is the same as sampling `[0, 1]`
// across `Q·W/(W−1)` about the same centre. So the shared unit mesh keeps
// its UVs and the inset rides the Transform like everything else here, and
// the texel centres land where the client's do. `W` comes from the TILE
// (`tw / 0.5`), never from the bled rect.
size: Vec2::new(
tw_x * units_per_yd * texel_stretch(tw_x),
tw_y * units_per_yd * texel_stretch(tw_y),
(x1 - x0) * units_per_yd * texel_stretch(tw_x),
(y1 - y0) * units_per_yd * texel_stretch(tw_y),
),
rotation,
order,
@@ -543,6 +600,19 @@ fn emit_minimap(
}
}
// `WOW_MM_STATS=1` reports what the interior branch actually put in the target this frame —
// how many groups the flood-fill kept out of how many, and how many tiles that came to. The
// reference's own Stormwind capture emitted 57 tiles at indoor zoom 3, which is the number
// this is here to be compared against (wow-re `wmo-interior-no-adt-underlay.md`).
if std::env::var("WOW_MM_STATS").is_ok() {
eprintln!(
"MM-STATS: radius {radius} yd, groups {}/{} selected, {} tiles composited",
drawable.iter().filter(|d| **d).count(),
drawable.len(),
composite.tiles.len(),
);
}
// THE BLIT: the target's middle two-thirds, which is what nets `1.0 · radius` on screen
// (the client's `0x4ec440` under EGxBlend 2). One quad, masked to the minimap circle — the
// round cut belongs HERE and not to the tiles, exactly as the reference's does. The target
@@ -871,6 +941,10 @@ impl Plugin for MinimapPlugin {
.add_systems(
Update,
(
// The headless probe's synthetic widget, ahead of the emit that reads it.
probe_minimap_widget
.in_set(UiQuadAppend)
.before(emit_minimap),
emit_minimap.in_set(UiQuadAppend),
// After the emit that fills it: the composite camera draws what THIS frame's
// interior branch asked for, so the target the blit quad samples is never a
+12 -1
View File
@@ -110,6 +110,15 @@ fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
return select(higher, lower, c <= vec3<f32>(0.0031308));
}
// sRGB → linear — the inverse of `linear_to_srgb`, for a texture uploaded UNDECODED so the hardware
// filters its authored bytes (the minimap tiles' `GL_SKIP_DECODE_EXT`). The conversion happens here,
// after the filter, which is exactly the order the reference's fixed-function pipe uses.
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
let higher = pow((max(c, vec3<f32>(0.0)) + 0.055) / 1.055, vec3<f32>(2.4));
let lower = c / 12.92;
return select(higher, lower, c <= vec3<f32>(0.04045));
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let t = textureSample(quad_texture, quad_sampler, in.uv);
@@ -157,7 +166,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
if t.a * c.a < alpha_ref {
discard;
}
return vec4<f32>(t.rgb * c.rgb, 1.0);
// The tile arrived as gamma bytes (SKIP_DECODE) and the composite target is un-encoded, so
// the conversion the sampler did not do happens HERE — after the filter, which is the point.
return vec4<f32>(srgb_to_linear(t.rgb) * c.rgb, 1.0);
}
let a = t.a * k;
// Premultiply in GAMMA (decision 0160's lesson, here for the UI): the hardware `SrcAlpha` factor
+28
View File
@@ -43,6 +43,9 @@ pub enum BlpVariant {
/// and the field becomes flickering speckle.
/// (wow-re `system/lighting/scratch/rf-snow-flake-render.md` §2.1/§6.)
PointSprite,
/// A **minimap tile** (ADT `map<X>_<Y>` or a WMO group's interior tile) — clamp, mip 0, linear,
/// and filtered in **gamma space**, the reference's `GL_SKIP_DECODE_EXT`. See [`map_tile_image`].
MapTile,
/// OS cursor image — `Rgba8UnormSrgb`, single mip.
Cursor,
}
@@ -82,6 +85,10 @@ impl AssetLoader for BlpImageLoader {
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
sprite_image(w, h, rgba)
}
BlpVariant::MapTile => {
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
map_tile_image(w, h, rgba)
}
BlpVariant::Effect => {
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
effect_image(w, h, rgba)
@@ -181,6 +188,27 @@ fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
image
}
/// A **minimap tile** — clamp, mip 0, **LINEAR**, and filtered in **GAMMA** space.
///
/// The reference binds one sampler object for every minimap tile it draws, interior and outdoor
/// alike, and the trace reads it whole: `CLAMP_TO_EDGE` both axes, `MAG_FILTER = MIN_FILTER =
/// GL_LINEAR` with no mip term, LOD bias 0, anisotropy 1, and — the part that is not the default —
/// **`GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`**, plus `GL_TEXTURE_MAX_LEVEL = 0` per tile.
/// (wow-re `system/minimap/scratch/wmo-interior-no-adt-underlay.md` §7, device-observed and matching
/// its own binary derivation; a positive control in the same trace emits `GL_NEAREST` for a
/// different sampler, so the layer would have shown point sampling had the client asked for it.)
///
/// SKIP_DECODE is the whole reason this is not [`sprite_image`]: it means the hardware **filters the
/// authored bytes**, not their linearisation. An `Rgba8UnormSrgb` upload decodes each texel to
/// linear before the filter weights it, so every blend across an edge lands on a different colour
/// than the reference's — darker, because averaging in linear space and re-encoding is darker than
/// averaging the gamma bytes. On a 1-bit-alpha bake whose edges sit against black that is a visible
/// dark fringe at every alpha boundary. So the tile arrives as gamma bytes ([`color_format`], no
/// decode) and the shader converts AFTER the filter.
fn map_tile_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
effect_image(width, height, rgba)
}
/// Gamma-lane effect sprite: `Rgba8Unorm` (the shader does WoW's byte-space combine), clamp,
/// **mip 0 only** — see [`BlpVariant::Effect`].
fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
+60
View File
@@ -701,6 +701,7 @@ impl AssetLoader for WmoModelLoader {
positions: cam_pos,
indices: cam_idx,
});
resolve_shared_liquid_cells(&mut group_liquids);
let portals = root.portals();
let (group_collision_bounds, collision_bounds) =
collision_tri_bounds(&group_collision_tris);
@@ -746,6 +747,65 @@ impl AssetLoader for WmoModelLoader {
}
}
/// Apply the MLIQ **shared-tile gate** across a model's groups: a cell flagged `0x80`
/// ([`LiquidMesh::shared`]) is claimed by TWO groups and authored in both, and exactly one of them
/// may draw it. Both drawing means the translucent sheet composites twice over the overlap band —
/// the visible line along the Stormwind canals (B141's water half).
///
/// The reference picks the winner by 2-colouring the portal graph on the depth parity of the flood
/// that reached each group, recomputed every frame (`0x6b41c0`/`0x6b4074`/`0x6b61a0`, wow-re
/// `terrain/scratch/wmo-liquid-shared-tile-gate.md`). We pick **the lowest group index**, which is
/// pixel-identical and needs no flood: the RE measured all 415 corners of Stormwind's 190 shared
/// cells and the two claimants agree exactly — the same per-vertex alpha byte (415/415) and heights
/// within 5e-6 yd. Which one draws cannot be seen; that two draw can.
///
/// Cells are matched on their XY centre quantised to a hundredth of a yard — the two authors share
/// the same MLIQ lattice, so the coordinates agree to float exactness and the quantisation is only
/// there to make them hashable.
fn resolve_shared_liquid_cells(group_liquids: &mut [Option<LiquidMesh>]) {
let key = |m: &LiquidMesh, c: usize| -> Option<(i32, i32)> {
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
let (xt, yt) = (cols.checked_sub(1)?, rows.checked_sub(1)?);
let (tx, ty) = (c % xt, c / xt);
if ty >= yt {
return None;
}
let a = m.positions.get(ty * cols + tx)?;
let b = m.positions.get((ty + 1) * cols + tx + 1)?;
#[allow(clippy::cast_possible_truncation)]
Some((
(0.5 * (a[0] + b[0]) * 100.0).round() as i32,
(0.5 * (a[1] + b[1]) * 100.0).round() as i32,
))
};
// First claimant wins, walking groups in index order.
let mut owner: bevy::platform::collections::HashMap<(i32, i32), usize> =
bevy::platform::collections::HashMap::default();
for (gi, slot) in group_liquids.iter().enumerate() {
let Some(m) = slot else { continue };
for c in 0..m.wet.len() {
if !m.wet[c] || !m.shared[c] {
continue;
}
if let Some(k) = key(m, c) {
owner.entry(k).or_insert(gi);
}
}
}
if owner.is_empty() {
return;
}
for (gi, slot) in group_liquids.iter_mut().enumerate() {
let Some(m) = slot else { continue };
let keys: Vec<Option<(i32, i32)>> = (0..m.wet.len()).map(|c| key(m, c)).collect();
let shared = m.shared.clone();
m.retain_cells(|c| !shared[c] || keys[c].is_none_or(|k| owner.get(&k) == Some(&gi)));
if m.indices.is_empty() {
*slot = None; // every cell this group held belonged to a neighbour
}
}
}
#[cfg(test)]
mod doodad_base_tests {
use super::*;
@@ -0,0 +1,687 @@
//! 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(())
}
@@ -0,0 +1,223 @@
//! TEMP (B141/N05): what the WMO's liquid actually looks like around a pin — which groups hold
//! water there, their MOGP flags/liquid type, their MLIQ base heights, and whether two surfaces
//! abut, overlap or step. Run: `cargo run -p benilla-formats --example mm_water_pin -- x y z [r]`.
use std::io::Cursor;
const MAP_CENTER: f32 = 17066.666;
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)
}
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))
}
fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().skip(1).collect();
let pin = [
a.first().map_or(-8796.83, |s| s.parse().unwrap()),
a.get(1).map_or(744.25, |s| s.parse().unwrap()),
a.get(2).map_or(97.83, |s| s.parse().unwrap()),
];
let r: f32 = a.get(3).map_or(60.0, |s| s.parse().unwrap());
let data = benilla_formats::wow_data().unwrap();
let mut chain = benilla_formats::open_chain(&data)?;
let (tc, tr) = benilla_formats::world_to_tile(pin[0], pin[1]);
let bytes = chain.read_file(&format!("World\\Maps\\Azeroth\\Azeroth_{tc}_{tr}.adt"))?;
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.unwrap();
let pm = world_to_model(
rot,
[pin[0] - origin[0], pin[1] - origin[1], pin[2] - origin[2]],
);
println!("{model} pin in model space {pm:?}");
let stem = model.trim_end_matches(".wmo").to_string();
let root = benilla_formats::parse_wmo_root(&chain.read_file(&format!("{stem}.wmo"))?)?;
let mut surfaces: Vec<(usize, benilla_formats::LiquidMesh, u32)> = Vec::new();
for (gi, info) in root.group_infos().iter().enumerate() {
if info.bbox_min[0] > pm[0] + r
|| info.bbox_max[0] < pm[0] - r
|| info.bbox_min[1] > pm[1] + r
|| info.bbox_max[1] < pm[1] - r
{
continue;
}
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
continue;
};
let Some(h) = benilla_formats::wmo_group_header(&gb) else {
continue;
};
let liq = benilla_formats::wmo_group_liquid_mesh(&gb);
let Some(m) = liq else { continue };
let wet = m.wet.iter().filter(|w| **w).count();
if wet == 0 {
continue;
}
let (mut zlo, mut zhi) = (f32::INFINITY, f32::NEG_INFINITY);
let (mut xlo, mut xhi, mut ylo, mut yhi) = (
f32::INFINITY,
f32::NEG_INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
);
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
for j in 0..rows.saturating_sub(1) {
for i in 0..cols.saturating_sub(1) {
if !m.wet[j * (cols - 1) + i] {
continue;
}
for (di, dj) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let p = m.positions[(j + dj) * cols + i + di];
zlo = zlo.min(p[2]);
zhi = zhi.max(p[2]);
xlo = xlo.min(p[0]);
xhi = xhi.max(p[0]);
ylo = ylo.min(p[1]);
yhi = yhi.max(p[1]);
}
}
}
surfaces.push((gi, m.clone(), h.flags));
let w = model_to_world(rot, [0.5 * (xlo + xhi), 0.5 * (ylo + yhi), zhi]);
println!(
" g{gi:3} flags 0x{:x}{} liq {:?} groupLiquid 0x{:x} grid {}x{} wet {wet}\n model x[{xlo:.1},{xhi:.1}] y[{ylo:.1},{yhi:.1}] z[{zlo:.2},{zhi:.2}] .go xyz {:.2} {:.2} {:.2}",
h.flags,
if h.flags & 0x8 != 0 { " EXTERIOR" } else if h.flags & 0x40 != 0 { " EXT_LIT" } else { " interior" },
m.kind, h.group_liquid, cols, rows,
origin[0] + w[0], origin[1] + w[1], origin[2] + w[2] + 2.0,
);
}
// How many DISTINCT groups claim each point as wet? Two coplanar surfaces over the same ground
// are drawn twice — a transparent water quad composited twice is visibly darker, and at equal
// depth they also z-fight. Sampled on a 0.25 yd lattice over the pin's window.
let step = 0.25f32;
let n = (2.0 * r / step) as i32;
let mut hist = std::collections::BTreeMap::<usize, usize>::new();
let mut gated_hist = std::collections::BTreeMap::<usize, usize>::new();
let mut worst: Option<(usize, [f32; 3])> = None;
for j in 0..n {
for i in 0..n {
let mx = pm[0] - r + (i as f32 + 0.5) * step;
let my = pm[1] - r + (j as f32 + 0.5) * step;
let mut k = 0;
let mut gated = 0;
let mut owner: Option<usize> = None;
for (gi, m, _) in &surfaces {
let Some(c) = cell_at(m, mx, my) else {
continue;
};
if !m.wet[c] {
continue;
}
k += 1;
// The gate: an unshared cell always draws; a `0x80` cell only for the first
// (lowest-index) claimant.
if !m.shared[c] {
gated += 1;
} else if owner.is_none_or(|o| o == *gi) {
owner = Some(*gi);
gated += 1;
}
}
*hist.entry(k).or_default() += 1;
*gated_hist.entry(gated).or_default() += 1;
if k >= 2 && worst.as_ref().is_none_or(|w| k > w.0) {
worst = Some((k, model_to_world(rot, [mx, my, -6.48])));
}
}
}
println!(
"\nwet-surface overlap over a {}x{} lattice at {step} yd: {hist:?}",
n, n
);
println!(" with the MLIQ 0x80 shared-tile gate (lowest claimant wins): {gated_hist:?}");
if let Some((k, w)) = worst {
println!(
" deepest overlap {k} surfaces at .go xyz {:.2} {:.2} {:.2}",
origin[0] + w[0],
origin[1] + w[1],
origin[2] + w[2] + 2.0
);
}
Ok(())
}
/// The cell index `(mx, my)` falls in, if it is inside the grid at all.
fn cell_at(m: &benilla_formats::LiquidMesh, mx: f32, my: f32) -> Option<usize> {
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
if cols < 2 || rows < 2 {
return None;
}
let o = m.positions[0];
let sx = (m.positions[1][0] - o[0]).abs().max(1e-6);
let sy = (m.positions[cols][1] - o[1]).abs().max(1e-6);
let (x0, y0) = (
o[0].min(m.positions[cols * rows - 1][0]),
o[1].min(m.positions[cols * rows - 1][1]),
);
let i = ((mx - x0) / sx).floor();
let j = ((my - y0) / sy).floor();
if i < 0.0 || j < 0.0 || i as usize >= cols - 1 || j as usize >= rows - 1 {
return None;
}
Some(j as usize * (cols - 1) + i as usize)
}
@@ -0,0 +1,32 @@
//! TEMP (B141): what fraction of a captured minimap disc is the composite's black clear.
//! `cargo run -p benilla-formats --example png_disc -- <png> [cx cy r]` — defaults to the whole
//! image's inscribed circle, which is what the `WOW_MM_PROBE` crop is.
fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().skip(1).collect();
let img = image::open(&a[0])?.to_rgb8();
let (w, h) = img.dimensions();
let cx = a.get(1).map_or(w as f32 * 0.5, |s| s.parse().unwrap());
let cy = a.get(2).map_or(h as f32 * 0.5, |s| s.parse().unwrap());
let r = a
.get(3)
.map_or(w.min(h) as f32 * 0.5 - 2.0, |s| s.parse().unwrap());
let (mut inside, mut black) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
if ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt() > r {
continue;
}
inside += 1;
let p = img.get_pixel(x, y).0;
if p[0] < 16 && p[1] < 16 && p[2] < 16 {
black += 1;
}
}
}
println!(
"{}: {:.2}% of the disc is clear-black ({black}/{inside})",
a[0],
100.0 * black as f32 / inside as f32
);
Ok(())
}
+51
View File
@@ -136,6 +136,21 @@ pub struct LiquidMesh {
/// truth for containment — a liquid grid is sparse (nibble `0xf` = hole), so its bounding box
/// routinely spans dry ground it never touches (decision 0635).
pub wet: Vec<bool>,
/// Per-**cell**, parallel to [`Self::wet`]: the MLIQ tile flag's **`0x80`** bit — "a neighbouring
/// group also claims this cell". Authored on **both** claimants, never on a cell only one group
/// covers (measured over `Stormwind.wmo`: 190 of 2685 wet cells, each claimed by exactly two
/// groups and flagged in both; zero overlap among the `0x80`-clear cells — wow-re
/// `terrain/scratch/wmo-liquid-shared-tile-gate.md`).
///
/// The reference's strip builder emits a tile iff `(f & 0xf) != 0xf` **and** (`f & 0x80 == 0`
/// **or** this group won the gate) — a per-frame 2-colouring of the portal graph that lets
/// exactly ONE of the two claimants draw. Without it both surfaces draw, coplanar, and the
/// overlap band composites the translucent water twice: the visible line across the Stormwind
/// canals (B141's water half). Resolved model-wide in `benilla_assets::wmo`, not here — a group
/// file cannot see its neighbours.
///
/// Always `false` on the ADT path: MCLQ has no such bit and no such overlap.
pub shared: Vec<bool>,
/// Vertex positions `[x, y, z]` in WoW yards — `cols·rows` entries, row-major.
pub positions: Vec<[f32; 3]>,
/// Texture UVs parallel to `positions`. No scroll for water.
@@ -324,6 +339,7 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option<
Some(LiquidMesh {
grid: [GRID as u32, GRID as u32],
shared: vec![false; wet.len()],
wet,
positions,
uvs,
@@ -335,6 +351,41 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option<
})
}
impl LiquidMesh {
/// Drop the cells `keep` rejects (indexed like [`Self::wet`]), rebuilding [`Self::indices`].
/// The vertex arrays are left alone — a vertex no triangle references costs nothing to keep and
/// the grid stays addressable by cell index.
///
/// This is how the MLIQ [`Self::shared`] gate is applied once the owning model can see every
/// group at once.
pub fn retain_cells(&mut self, keep: impl Fn(usize) -> bool) {
let (cols, rows) = (self.grid[0] as usize, self.grid[1] as usize);
let (xt, yt) = (cols.saturating_sub(1), rows.saturating_sub(1));
let mut dropped = false;
for c in 0..self.wet.len() {
if self.wet[c] && !keep(c) {
self.wet[c] = false;
dropped = true;
}
}
if !dropped {
return;
}
self.indices.clear();
for ty in 0..yt {
for tx in 0..xt {
if !self.wet[ty * xt + tx] {
continue;
}
let tl = (ty * cols + tx) as u32;
let (tr, bl) = (tl + 1, ((ty + 1) * cols + tx) as u32);
let br = bl + 1;
self.indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
}
}
}
}
#[cfg(test)]
mod tests {
@@ -334,6 +334,9 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
// the substitution below.
let mut indices = Vec::with_capacity(xt * yt * 6);
let mut wet = vec![false; xt * yt];
// The `0x80` "a neighbour also claims this cell" bit — kept per cell, gated model-wide later
// (see `LiquidMesh::shared`).
let mut shared = vec![false; xt * yt];
let mut drawn = vec![false; xv * yv];
for ty in 0..yt {
for tx in 0..xt {
@@ -341,6 +344,7 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
continue; // hole — no liquid on this tile
}
wet[ty * xt + tx] = true;
shared[ty * xt + tx] = lq.tile_flags[ty * xt + tx] & 0x80 != 0;
let tl = (ty * xv + tx) as u32;
let tr = tl + 1;
let bl = ((ty + 1) * xv + tx) as u32;
@@ -412,6 +416,7 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
Some(LiquidMesh {
grid: [xv as u32, yv as u32],
wet,
shared,
positions,
uvs,
depths,
+1
View File
@@ -1007,6 +1007,7 @@ mod tests {
LiquidMesh {
grid: [2, 2],
wet: vec![true],
shared: vec![false],
positions: vec![
[0.0, 0.0, z],
[10.0, 0.0, z],