Files
benilla-octo/crates/benilla-app/src/minimap/interior.rs
T
SamandClaude Fable 5 23f23d36e0 there are three water renderers, and WMO water gets its two
- WMO water stops running the terrain-liquid program: the material key names WHICH renderer a pool takes, and the two WMO arms land — Blackfathom Deeps' interior pools take their authored per-vertex opacity and the material's own body colour (34, 38, 57 there — the dark blue-grey in the reference shot) instead of coming out opaque and daylit on the zone's river swatch
- the exterior WMO arm bands at sub-17, not sub-16 — teal, not olive — which is what the Stormwind canals actually read as
- a hole corner's authored height no longer counts as a height, and a corpus-wide instrument reports which WMO water arm every pool takes
- the ADT/Felwood golden sweep is bit-identical: open-world water is provably untouched

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

362 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The WMO-interior minimap's group selection — the portal flood-fill + the tile-name stem
//! (split from the tile renderer; the mechanism and its byte provenance are unchanged).
use bevy::math::{Affine3A, Vec3};
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::WmoModel;
/// MOGP group flags read by the interior flood-fill: `EXTERIOR` (an outdoor shell group — not part of
/// the interior tile flood) and `UNREACHABLE`/no-render (`0x80`) which also suppresses tile emit. The
/// client skips `& 0x8` at the flood level and `& 0x88` at the emit level (wow-re Sub-Q4b).
const GROUP_EXTERIOR: u32 = 0x8;
const GROUP_NO_EMIT: u32 = 0x88;
/// The `md5translate.trs` key stem for a WMO's interior minimap tiles, from its `.wmo` asset path.
/// The reference builds the tile name by stripping the `World\` prefix and the `.wmo` extension off
/// the model filename (verified: `wow-5875-re` minimap node, name builder `0x6da330`), e.g.
/// `World\wmo\KhazModan\Cities\Ironforge\Ironforge.wmo` → `wmo\khazmodan\cities\ironforge\ironforge`.
/// Backslash-separated + lowercased (the trs lookup is case-insensitive). `None` if not a
/// `World\…\*.wmo`.
pub(super) fn wmo_minimap_stem(wmo_path: &str) -> Option<String> {
let p = wmo_path.replace('/', "\\").to_ascii_lowercase();
let stem = p.split_once("world\\")?.1.strip_suffix(".wmo")?;
Some(stem.to_string())
}
/// The interior minimap's group selection: a **portal flood-fill** from the player's current group,
/// gated by a player-centred query box — the byte-verified client mechanism (wow-re minimap node
/// `wmo-interior-minimap.md` Sub-Q4b). Returns, per absolute group index, whether that group's tiles
/// should be drawn (reached through portals AND its bbox overlaps the view in XY). Replaces the naive
/// draw-every-group-in-the-window, which painted unreachable/far floors over the current one.
///
/// The query box (WoW **world** axes; the client builds it in the WMO model frame, but the overlap
/// decisions are frame-agnostic so we test in world space — the group bboxes and portal polygons are
/// transformed by the placement to match): XY = the view window grown to `±2·radius`; **Z is
/// asymmetric, `[player.z − 1.5·radius, player.z + radius]`** — mostly *below* the eye, so from a floor
/// you reach the storey just under it through a stairwell but not the whole tower. A group EMITS when
/// its bbox overlaps the box in **XY only** (no per-group Z test — the client has none). The **same
/// XY overlap gates the portal RECURSION** (wow-re Q3, VERIFIED): a group that fails the XY test
/// neither emits nor floods onward. For an XY-passing group, each portal is CROSSED when its polygon
/// is not fully outside any of the box's **6 planes** (a 3-D outcode cull *including Z* — the only
/// per-floor Z gate the client applies; stacked floors share an XY footprint, so an adjacent storey
/// passes its own XY gate and is reached through the stairwell portal). `EXTERIOR` (`0x8`) groups are
/// not interior-flooded; `0x88` groups emit no tiles.
pub(super) fn interior_group_selection(
model: &WmoModel,
world_from_local: &Affine3A,
player_pos: Vec3,
radius: f32,
seed: usize,
) -> Vec<bool> {
let n = model.group_nav.len();
let mut drawable = vec![false; n];
if seed >= n {
return drawable;
}
let pw = bevy_to_wow(player_pos);
let c = radius;
let box_min = [pw[0] - 2.0 * c, pw[1] - 2.0 * c, pw[2] - 1.5 * c];
let box_max = [pw[0] + 2.0 * c, pw[1] + 2.0 * c, pw[2] + c];
// A model-space point → world (WoW axes), the frame the query box lives in.
let to_world = |m: [f32; 3]| bevy_to_wow(world_from_local.transform_point3(wow_to_bevy(m)));
// A group's model bbox → its world AABB (8 transformed corners) — a conservative superset under a
// rotated placement, fine for the emit gate (an over-included far group is window-culled at draw).
let world_aabb = |bmin: [f32; 3], bmax: [f32; 3]| {
let (mut lo, mut hi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
for &x in &[bmin[0], bmax[0]] {
for &y in &[bmin[1], bmax[1]] {
for &z in &[bmin[2], bmax[2]] {
let w = to_world([x, y, z]);
for k in 0..3 {
lo[k] = lo[k].min(w[k]);
hi[k] = hi[k].max(w[k]);
}
}
}
}
(lo, hi)
};
let xy_overlap = |lo: [f32; 3], hi: [f32; 3]| {
lo[0] <= box_max[0] && hi[0] >= box_min[0] && lo[1] <= box_max[1] && hi[1] >= box_min[1]
};
// 6-plane outcode of a world point against the box (1 bit per face it is outside of).
let outcode = |w: [f32; 3]| {
let mut o = 0u8;
for k in 0..3 {
if w[k] < box_min[k] {
o |= 1 << (2 * k);
}
if w[k] > box_max[k] {
o |= 1 << (2 * k + 1);
}
}
o
};
let mut visited = vec![false; n];
let mut stack = vec![seed];
while let Some(g) = stack.pop() {
if g >= n || visited[g] {
continue;
}
visited[g] = true;
let gn = &model.group_nav[g];
if gn.flags & GROUP_EXTERIOR != 0 {
continue; // an outdoor shell group — not part of the interior flood
}
let (lo, hi) = world_aabb(gn.bbox_min, gn.bbox_max);
// XY-OVERLAP GATE (wow-re Q3, VERIFIED): a group whose model-XY bbox misses the query window
// emits NOTHING and does NOT flood onward — the gate blocks emit AND recursion both. Stacked
// storeys share an XY footprint, so an adjacent floor still passes it (and is reached via the
// stairwell portal's 3-D cull below).
if !xy_overlap(lo, hi) {
continue;
}
if gn.flags & GROUP_NO_EMIT == 0 {
drawable[g] = true;
}
// PORTAL RECURSION: cross a portal iff its polygon is not fully outside any box plane (3-D,
// Z included). This is the only place a floor above/below is gated out.
let start = gn.ref_start as usize;
let end = (start + gn.ref_count as usize).min(model.portal_refs.len());
for r in &model.portal_refs[start..end] {
let nb = r.group as usize;
if r.group == u16::MAX || nb >= n || visited[nb] {
continue;
}
let Some(info) = model.portal_infos.get(r.portal as usize) else {
continue;
};
let vs = info.start_vertex as usize;
let Some(poly) = model.portal_vertices.get(vs..vs + info.count as usize) else {
continue;
};
let mut and = 0xFFu8;
for v in poly {
and &= outcode(to_world(*v));
if and == 0 {
break; // not fully outside any one plane ⇒ the portal reaches into the box
}
}
if and == 0 {
stack.push(nb);
}
}
}
drawable
}
#[cfg(test)]
mod tests {
/// The interior flood-fill's two gates, on a synthetic 4-group building (all sharing one XY
/// footprint except `far`, which sits 1000 yd away):
/// `player(g0)` ──floor-hole portal──> `cellar(g1)` both under the query box ⇒ both drawn
/// `player(g0)` ──in-box portal─────-> `far(g2)` reached, bbox misses XY ⇒ NOT drawn
/// `far(g2)` ──portal────────────-> `behind(g3)` must NOT be reached: the XY gate that
/// rejected g2 also blocks flooding THROUGH it
/// The last leg is the wow-re Q3 correction (the XY gate blocks emit *and* recursion). `behind`'s
/// own bbox does overlap the box, so it would draw if recursion had leaked through `far`.
#[test]
fn interior_flood_fill_gates_on_xy_and_blocks_recursion_through_a_missed_group() {
use super::interior_group_selection;
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalInfo, WmoPortalRef};
use bevy::math::{Affine3A, Vec3};
let nav = |zmin: f32, zmax: f32, x: f32, ref_start: u16, ref_count: u16| WmoGroupNav {
flags: 0,
bbox_min: [x - 5.0, -5.0, zmin],
bbox_max: [x + 5.0, 5.0, zmax],
ref_start,
ref_count,
area_table_id: 0,
fog_indices: [0; 4],
group_liquid: benilla_formats::NO_GROUP_LIQUID,
};
// A 1-yd square portal polygon centred at (x, 0, z), lying in the plane x = const.
let poly = |x: f32, z: f32| {
[
[x, -0.5, z - 0.5],
[x, 0.5, z - 0.5],
[x, 0.5, z + 0.5],
[x, -0.5, z + 0.5],
]
};
let mut portal_vertices = Vec::new();
portal_vertices.extend(poly(0.0, 0.0)); // portal 0: g0<->g1, a floor hole at the player
portal_vertices.extend(poly(4.0, 1.0)); // portal 1: g0<->g2, well inside the query box
// portal 2: g2<->g3, deliberately placed INSIDE the query box, so the 3-D outcode cull would
// happily cross it. The ONLY thing that stops g3 being reached is g2 failing its own XY gate
// — which is exactly the Q3 behaviour under test (a box-outside portal would pass this test
// even with the pre-correction emit-only gate, and so would prove nothing).
portal_vertices.extend(poly(3.0, 1.0));
let info = |start: u16| WmoPortalInfo {
start_vertex: start,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
};
let pref = |portal: u16, group: u16, side: i16| WmoPortalRef {
portal,
group,
side,
};
let model = WmoModel {
wmo_id: 1,
submeshes: Vec::new(),
submesh_group: Vec::new(),
portal_vertices,
portal_infos: vec![info(0), info(4), info(8)],
// g0 refs portals 0,1 · g1 refs portal 0 · g2 refs portals 1,2 · g3 refs portal 2
portal_refs: vec![
pref(0, 1, 1),
pref(1, 2, 1),
pref(0, 0, -1),
pref(1, 0, -1),
pref(2, 3, 1),
pref(2, 2, -1),
],
group_nav: vec![
nav(0.0, 3.0, 0.0, 0, 2), // g0 the player's floor
nav(-10.0, -7.0, 0.0, 2, 1), // g1 cellar, stacked under g0
nav(0.0, 3.0, 1000.0, 3, 2), // g2 far away in XY
nav(0.0, 3.0, 0.0, 5, 1), // g3 overlaps XY, reachable only through g2
],
fogs: Vec::new(),
skybox: None,
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
group_collision_grids: Vec::new(),
collision_bounds: None,
collision: None,
collision_camera: None,
doodads: Vec::new(),
doodad_sets: Vec::new(),
lights: Vec::new(),
group_bounds: Vec::new(),
group_footprints: Vec::new(),
material_ground_type: Vec::new(),
material_diff_color: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
doodad_base: Default::default(),
doodad_owner: Default::default(),
doodad_groups: Default::default(),
};
let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0);
assert!(drawable[0], "the player's own group draws");
assert!(
drawable[1],
"the cellar is stacked in XY and its floor-hole portal sits inside the box's Z extent"
);
assert!(
!drawable[2],
"a reached group whose bbox misses the query window in XY must not emit tiles"
);
assert!(
!drawable[3],
"the XY gate that rejected g2 must also block flooding THROUGH it (wow-re Q3)"
);
}
/// The box's Z extent is what keeps a distant storey out: put the connecting portal far below the
/// box floor and the 3-D outcode cull stops the flood before it.
#[test]
fn interior_flood_fill_rejects_a_portal_outside_the_query_box_z_extent() {
use super::interior_group_selection;
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalInfo, WmoPortalRef};
use bevy::math::{Affine3A, Vec3};
let nav = |zmin: f32, zmax: f32, ref_start: u16, ref_count: u16| WmoGroupNav {
flags: 0,
bbox_min: [-5.0, -5.0, zmin],
bbox_max: [5.0, 5.0, zmax],
ref_start,
ref_count,
area_table_id: 0,
fog_indices: [0; 4],
group_liquid: benilla_formats::NO_GROUP_LIQUID,
};
// The connecting portal sits at z ≈ -300, far below the box floor (player.z - 1.5·25).
let model = WmoModel {
wmo_id: 1,
submeshes: Vec::new(),
submesh_group: Vec::new(),
portal_vertices: vec![
[0.0, -0.5, -300.5],
[0.0, 0.5, -300.5],
[0.0, 0.5, -299.5],
[0.0, -0.5, -299.5],
],
portal_infos: vec![WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
}],
portal_refs: vec![
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
},
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
},
],
group_nav: vec![nav(0.0, 3.0, 0, 1), nav(-303.0, -300.0, 1, 1)],
fogs: Vec::new(),
skybox: None,
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
group_collision_grids: Vec::new(),
collision_bounds: None,
collision: None,
collision_camera: None,
doodads: Vec::new(),
doodad_sets: Vec::new(),
lights: Vec::new(),
group_bounds: Vec::new(),
group_footprints: Vec::new(),
material_ground_type: Vec::new(),
material_diff_color: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
group_liquids: Vec::new(),
doodad_base: Default::default(),
doodad_owner: Default::default(),
doodad_groups: Default::default(),
};
let drawable = interior_group_selection(&model, &Affine3A::IDENTITY, Vec3::ZERO, 25.0, 0);
assert!(drawable[0]);
assert!(
!drawable[1],
"a portal below the query box's Z floor is not crossed, so the deep storey never draws"
);
}
#[test]
fn wmo_stem_strips_world_prefix_and_extension() {
use super::wmo_minimap_stem;
// The interior tile key stem = the .wmo path minus `World\` and `.wmo`, lowercased. Appending
// `_001_00_00.blp` reproduces the trs key `WMO\KhazModan\Cities\Ironforge\ironforge_001_00_00.blp`.
assert_eq!(
wmo_minimap_stem("World/wmo/KhazModan/Cities/Ironforge/Ironforge.wmo").as_deref(),
Some("wmo\\khazmodan\\cities\\ironforge\\ironforge"),
);
assert_eq!(
wmo_minimap_stem("World\\wmo\\Azeroth\\Buildings\\Stormwind\\Stormwind.wmo").as_deref(),
Some("wmo\\azeroth\\buildings\\stormwind\\stormwind"),
);
assert_eq!(wmo_minimap_stem("not/a/model.txt"), None);
}
}