diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index b9a266b7..b02cee24 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -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 + '_ 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 `` +/// 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, 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 `` 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 diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index ced11920..9629d7c7 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -110,6 +110,15 @@ fn linear_to_srgb(c: vec3) -> vec3 { return select(higher, lower, c <= vec3(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) -> vec3 { + let higher = pow((max(c, vec3(0.0)) + 0.055) / 1.055, vec3(2.4)); + let lower = c / 12.92; + return select(higher, lower, c <= vec3(0.04045)); +} + @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { let t = textureSample(quad_texture, quad_sampler, in.uv); @@ -157,7 +166,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { if t.a * c.a < alpha_ref { discard; } - return vec4(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(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 diff --git a/crates/benilla-assets/src/blp.rs b/crates/benilla-assets/src/blp.rs index 35de062b..b2f3368d 100644 --- a/crates/benilla-assets/src/blp.rs +++ b/crates/benilla-assets/src/blp.rs @@ -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_` 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) -> 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) -> 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) -> Image { diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 2a524585..f6435edf 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -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]) { + 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> = (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::*; diff --git a/crates/benilla-formats/examples/mm_seam_probe.rs b/crates/benilla-formats/examples/mm_seam_probe.rs new file mode 100644 index 00000000..103ef3e9 --- /dev/null +++ b/crates/benilla-formats/examples/mm_seam_probe.rs @@ -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, + midz: f32, +} + +fn main() -> anyhow::Result<()> { + let args: Vec = 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 = 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 = 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::() / 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 = Vec::new(); + let mut cache: BTreeMap)>> = 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_.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>> = 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 = 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(()) +} diff --git a/crates/benilla-formats/examples/mm_water_pin.rs b/crates/benilla-formats/examples/mm_water_pin.rs new file mode 100644 index 00000000..8c337ee1 --- /dev/null +++ b/crates/benilla-formats/examples/mm_water_pin.rs @@ -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 = 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::::new(); + let mut gated_hist = std::collections::BTreeMap::::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 = 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 { + 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) +} diff --git a/crates/benilla-formats/examples/png_disc.rs b/crates/benilla-formats/examples/png_disc.rs new file mode 100644 index 00000000..1c673603 --- /dev/null +++ b/crates/benilla-formats/examples/png_disc.rs @@ -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 -- [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 = 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(()) +} diff --git a/crates/benilla-formats/src/liquid.rs b/crates/benilla-formats/src/liquid.rs index 14c913c2..a41fc14b 100644 --- a/crates/benilla-formats/src/liquid.rs +++ b/crates/benilla-formats/src/liquid.rs @@ -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, + /// 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, /// 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 { diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index a1db6b13..648aa2fa 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -334,6 +334,9 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option Option Option