Files
benilla-octo/crates/benilla-world/src/liquid/real_data.rs
T
SamandClaude Fable 5 bfb3e3addc the player build: one binary that carries its content
- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module
- the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder
- the glue screens get a capture shutter, and the engine stops depending on the protocol crate

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

434 lines
19 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 liquid subsystem against the **real 1.12.1 client files** — the tests that need actual ADT
//! and WMO bytes rather than a synthetic quad, so they live apart from either half's unit tests and
//! reach across both: they build surfaces the way `super::surface`'s spawn paths do and then ask
//! `super::query` the same questions the running client asks.
//!
//! Every one skips when the client isn't present (the repo never carries Blizzard data).
use bevy::prelude::*;
use super::query::{
liquid_at, submersion_at, wet_footprint, LiquidClaim, LiquidSource, WaterChunkInfo, WmoPool,
};
use crate::wmo_portal::WmoRoom;
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
use benilla_formats::{parse_wmo_root, wmo_group_liquid_mesh, LiquidMesh, Submersion};
/// Every loaded liquid surface covering a position's tile neighbourhood, world-placed and
/// **owner-tagged** — the ADT's own MCLQ chunks and every WMO placement's MLIQ groups, i.e. the same
/// candidate set the running client's `WaterChunkInfo` query sees, scoped the same way.
///
/// Each WMO placement gets a synthetic instance id (its index here) standing in for the
/// `WmoPortalInstance` entity the app spawns; `containing_room` then answers the question the app's
/// interior down-ray answers at runtime — *which* placement the subject is standing in — so a test
/// can pose the real query with the real claim.
struct LiquidScene {
surfaces: Vec<WaterChunkInfo>,
/// Per placement: its synthetic instance id, model path, placement transform, and group
/// bounding boxes (WMO model space) — the containment test.
placements: Vec<Placement>,
}
struct Placement {
instance: Entity,
model: String,
transform: Transform,
group_boxes: Vec<([f32; 3], [f32; 3])>,
}
impl LiquidScene {
/// The room a world position stands in: the first placement one of whose group bounding boxes
/// contains it, and that group.
///
/// A coarser test than the app's down-ray (which races collision FACES and the terrain), and
/// deliberately so — a test that re-implemented the down-ray would be pinning the test's copy of
/// it. Group-box containment is enough to answer "which building is this position in", which is
/// the only thing these two sites turn on.
fn containing_room(&self, wow: [f32; 3]) -> Option<WmoRoom> {
self.placements.iter().find_map(|p| {
let local = bevy_to_wow(
p.transform
.compute_affine()
.inverse()
.transform_point3(wow_to_bevy(wow)),
);
let gi = p
.group_boxes
.iter()
.position(|(lo, hi)| (0..3).all(|i| local[i] >= lo[i] && local[i] <= hi[i]))?;
Some(WmoRoom {
instance: p.instance,
group: gi as u16,
})
})
}
/// Which model path a room's placement came from — so a test can state *which building* it
/// believes the player is in rather than trusting an index.
fn model_of(&self, room: WmoRoom) -> &str {
self.placements
.iter()
.find(|p| p.instance == room.instance)
.map_or("<none>", |p| p.model.as_str())
}
}
/// Build the [`LiquidScene`] around a position. Empty (`None`) when the client isn't present.
fn liquid_scene(map: &str, wow: [f32; 3]) -> Option<LiquidScene> {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let (cx, cy) = benilla_formats::world_to_tile(wow[0], wow[1]);
let mut scene = LiquidScene {
surfaces: Vec::new(),
placements: Vec::new(),
};
let mut seen_placements: Vec<u32> = Vec::new();
// A building as big as Blackrock is placed in every tile it straddles, so its MODF may sit
// in a neighbour of the tile the position falls in.
for dx in -1i32..=1 {
for dy in -1i32..=1 {
let (tx, ty) = ((cx as i32 + dx) as u32, (cy as i32 + dy) as u32);
let Ok(tile) = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty) else {
continue;
};
for lq in tile.chunks.iter().flat_map(|c| c.liquids.iter()) {
scene.surfaces.push(wet_footprint(
lq,
&Transform::IDENTITY,
LiquidSource::AdtChunk,
));
}
for w in &tile.wmos {
// A straddling building appears in each tile's MODF — spawn it once, as the
// streamer does (it dedups on the same `unique_id`).
if seen_placements.contains(&w.unique_id) {
continue;
}
seen_placements.push(w.unique_id);
let transform = Transform {
translation: wow_to_bevy(w.position),
rotation: placement_rotation(w.rotation),
scale: Vec3::ONE,
};
let root_path = w.model.to_ascii_lowercase();
let Ok(bytes) = chain.read_file(&root_path) else {
continue;
};
let Ok(root) = parse_wmo_root(&bytes) else {
continue;
};
let instance =
Entity::from_raw_u32(scene.placements.len() as u32).expect("valid entity id");
scene.placements.push(Placement {
instance,
model: root_path.clone(),
transform,
group_boxes: root
.group_infos()
.iter()
.map(|g| (g.bbox_min, g.bbox_max))
.collect(),
});
let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path);
for gi in 0..root.group_count() {
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
continue;
};
if let Some(lq) = wmo_group_liquid_mesh(&gb) {
scene.surfaces.push(wet_footprint(
lq_ref(&lq),
&transform,
// Built through the APP's own constructor, not a copy of the rule —
// a test that re-derived the floor would pin its own version of 0701.
LiquidSource::WmoGroup(WmoPool::new(
Some(WmoRoom {
instance,
group: gi as u16,
}),
&transform,
root.group_infos().get(gi as usize),
)),
));
}
}
}
}
}
(!scene.surfaces.is_empty()).then_some(scene)
}
fn lq_ref(lq: &LiquidMesh) -> &LiquidMesh {
lq
}
/// The verdict at a position, and the highest wet vertex among the surfaces that claim it —
/// i.e. what the query answers now, beside what the chunk-maximum rule used to answer.
fn verdict(map: &str, wow: [f32; 3], claim: LiquidClaim) -> Option<(f32, f32)> {
let all = liquid_scene(map, wow)?.surfaces;
let hit = liquid_at(all.iter(), wow, claim)?;
let old = all
.iter()
.filter(|w| w.surface_z_at(wow[0], wow[1]).is_some())
.map(|w| w.chunk_max_z())
.fold(f32::MIN, f32::max);
Some((hit.surface_z, old))
}
/// **Blackrock Mountain's lava** at the director's `.go xyz -7531.21 -1123.64 172.58` (indoors,
/// `blackrock.wmo` group 038 — a 55×82 magma grid running 167.29 → 175.00 under a ~7° yaw
/// placement). The old chunk-maximum answered 175.00, i.e. 2.42 yd OVER the feet and well past
/// the 1.52 yd swim line, on a staircase whose lava is metres below.
#[test]
fn blackrock_lava_is_below_the_feet_not_above_it() {
let feet = [-7531.21_f32, -1123.64, 172.58];
// Blackrock's own placement owns the magma, and the player is in it.
let scene = liquid_scene("Azeroth", feet);
let claim = scene
.as_ref()
.and_then(|s| s.containing_room(feet))
.map_or(LiquidClaim::Unknown, LiquidClaim::inside);
let Some((surface, old_max)) = verdict("Azeroth", feet, claim) else {
eprintln!("skipping: no WoW client data");
return;
};
assert!(
(old_max - 175.00).abs() < 0.05,
"the chunk maximum this replaces (got {old_max})"
);
assert!(
(surface - 168.45).abs() < 0.05,
"the lava under the feet is the cell's own height (got {surface})"
);
assert!(
surface < feet[2],
"surface {surface} must be UNDER the feet {} — standing on the stairs, not swimming",
feet[2]
);
}
/// **Felfire Hill's river** at the director's `.go xyz 1983.97 -2875.84 98.00` (outdoors, one
/// MCNK's 9×9 MCLQ falling 95.78 → 99.56 across the chunk). The old chunk-maximum answered
/// 99.56 — 1.56 yd over the feet, just past the 1.52 yd swim line — while the player stands on
/// the bank with the water at their soles.
#[test]
fn felfire_hill_river_does_not_swim_on_the_bank() {
let feet = [1983.97_f32, -2875.84, 98.00];
let Some((surface, old_max)) = verdict("Kalimdor", feet, LiquidClaim::Outdoors) else {
eprintln!("skipping: no WoW client data");
return;
};
assert!(
(old_max - 99.56).abs() < 0.05,
"the chunk maximum this replaces (got {old_max})"
);
assert!(
surface < feet[2],
"surface {surface} must be UNDER the feet {} — standing on the bank",
feet[2]
);
assert!(
(feet[2] - surface) < 1.0,
"…but only just: the water is at the player's soles (got {surface})"
);
}
/// **B85 — Uldaman reads as UNDERWATER** (director repro, `.go xyz -6152.73 -2969.59 213.73`; the
/// `/liquid` line read `VERDICT Still surface z 399.64 (+185.91 over feet)`, `WmoGroup … WET-CELL`).
///
/// The claiming pool is **another building's**. At these feet the player stands in
/// `kz_uldaman_a.wmo` (WMOAreaTable id 1218) group 22 — which carries no MLIQ over this XY at all —
/// while the surface answering `+185.91` is group 1 of a nearby `md_mushroomcave.wmo` placement,
/// whose every group box excludes the player: their feet sit at local z −191.15 under a pool that
/// runs local z −5.65…0.00, i.e. 186 yd overhead in a cave they have never entered.
///
/// A footprint has no floor, so before 0696 "indoors" (a bare bool) admitted every MLIQ surface on
/// the map and the lowest one won. The two arms below are the bug and the fix on the same bytes.
#[test]
fn uldaman_is_not_submerged_in_a_mushroom_caves_pool() {
let feet = [-6152.73_f32, -2969.59, 213.73];
let Some(scene) = liquid_scene("Azeroth", feet) else {
eprintln!("skipping: no WoW client data");
return;
};
// The bug, on the shipped files: the pool B85 reported really is over this XY and really is
// 185.91 yd up. Read off the surfaces with NO delegation at all — the reported number is a
// property of the files, so the control must not run through any rule this test also judges.
let reported = scene
.surfaces
.iter()
.filter_map(|w| w.surface_z_at(feet[0], feet[1]))
.filter(|z| *z > feet[2])
.min_by(f32::total_cmp)
.expect("the mushroom cave's pool is what B85 reported");
assert!(
(reported - 399.64).abs() < 0.05,
"the surface B85 reported (got {reported})"
);
assert!(reported - feet[2] > 185.0, "…and 186 yd overhead");
// Since 0701 that pool is rejected TWICE over, and the second bound is independent of the
// first: even a subject with no interior claim at all is out of the cave's room, 186 yd under
// its floor. B85 would not have needed the owner half.
assert!(
liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Unknown).is_none(),
"the cave's pool is below-the-floor rejected even for an unclassified subject"
);
// The fix: the player's room is Uldaman's own placement, and Uldaman has no pool here.
let room = scene
.containing_room(feet)
.expect("the player stands inside a placement");
assert!(
scene.model_of(room).contains("uldaman"),
"the containing placement is Uldaman, not the cave (got {})",
scene.model_of(room)
);
assert!(
liquid_at(scene.surfaces.iter(), feet, LiquidClaim::inside(room)).is_none(),
"in Uldaman, no liquid — the cave's pool belongs to a building the player is not in"
);
}
/// **The Undercity STOREY bug** (decision 0701) — the same wrong-underwater-filter as B60, one
/// storey further in, and the half owner scoping could not reach. Live repro at
/// `.go xyz 1732.68 187.01 -65.70`, where `WOW_FOG_DUMP` read:
///
/// ```text
/// [submerged] Slime claim Inside(WmoRoom { instance: …, group: 182 }) eye [1731.9 187.0 -63.59]
/// over-xy Slime z -64.48 g182, Slime z 51.98 g10, Slime z 51.98 g7
/// ```
///
/// The eye's own room (group 182) holds slime at −64.48, *below* the eye and so not submerging it.
/// What turned the screen green were groups 7 and 10 — Undercity's upper channels at z 51.98,
/// **115 yd overhead** — which owner scoping admits because they are the same placement.
///
/// The server agrees the eye is dry there: `.gps` reports `Liquid level: -64.478561`, i.e. 0.9 yd
/// *under* the eye.
#[test]
fn undercitys_upper_channels_do_not_submerge_the_rooms_below() {
let eye = [1732.68_f32, 187.01, -63.59];
let Some(scene) = liquid_scene("Azeroth", eye) else {
eprintln!("skipping: no WoW client data");
return;
};
// The control, straight off the files with no delegation: the pools that were claiming the eye
// really are up at 51.98, and really are over this XY.
let overhead: Vec<f32> = scene
.surfaces
.iter()
.filter_map(|w| w.surface_z_at(eye[0], eye[1]))
.filter(|z| *z > eye[2])
.collect();
assert!(
!overhead.is_empty() && overhead.iter().all(|z| (z - 51.98).abs() < 0.05),
"the surfaces over the eye are Undercity's upper channels at 51.98 (got {overhead:?})"
);
// …and they belong to the eye's OWN placement, which is why 0696's owner scoping let them
// through: this is a storey bug, not a building bug.
let room = scene
.containing_room(eye)
.expect("the eye stands inside a placement");
assert!(
scene.model_of(room).contains("undercity"),
"the containing placement is Undercity (got {})",
scene.model_of(room)
);
// The fix, asked of the rule the SCREEN runs: a pool never claims below its own room's floor,
// so nothing over the eye submerges it and the filter stays off.
assert_eq!(
submersion_at(scene.surfaces.iter(), eye, LiquidClaim::inside(room)),
Submersion::Dry,
"a pool 115 yd overhead, in another storey of the same building, must not submerge the eye"
);
// And the room's OWN slime is untouched — step down into it and the screen goes green properly.
let inside_the_slime = [eye[0], eye[1], -66.0];
assert_eq!(
submersion_at(
scene.surfaces.iter(),
inside_the_slime,
LiquidClaim::inside(room)
),
Submersion::Slime,
"the floor bounds the pool to its room; it does not cost the room its own swim"
);
let hit = liquid_at(
scene.surfaces.iter(),
inside_the_slime,
LiquidClaim::inside(room),
)
.expect("…and the surface itself still answers");
assert!(
(hit.surface_z - -64.48).abs() < 0.05,
"…at the height the server reports (got {})",
hit.surface_z
);
}
/// **B60 — Undercity's Rogues' Quarter under Tirisfal's water** (director repro,
/// `.go xyz 1414.08 53.00 -62.26`; the `/liquid` line read `AdtChunk Still WET-CELL surface z 32.93
/// (+95.19 over feet)` while the player's own VERDICT was already `none`).
///
/// The ADT chunk's lake covers the XY 95 yd overhead and the rooms are cut into the rock beneath it.
/// The player's query had delegated since 0634 and read dry; the **camera-eye** probe and the
/// **per-unit** swim marker had not, so the screen took the underwater filter and the NPCs swam on
/// dry stone. All three subjects run this one predicate now, so the assertion below is what each of
/// them asks.
#[test]
fn the_rogues_quarter_is_not_under_tirisfals_lake() {
let feet = [1414.08_f32, 53.00, -62.26];
let Some(scene) = liquid_scene("Azeroth", feet) else {
eprintln!("skipping: no WoW client data");
return;
};
// The control: the ADT surface really is there, really covers this XY, and really is 95 yd up —
// an un-delegated subject (the pre-0696 camera and creature marker) is submerged in it.
let unscoped = liquid_at(scene.surfaces.iter(), feet, LiquidClaim::Unknown)
.expect("Tirisfal's water covers this XY");
assert!(
(unscoped.surface_z - 32.93).abs() < 0.05
&& (unscoped.surface_z - feet[2] - 95.19).abs() < 0.05,
"the surface B60 reported (got {})",
unscoped.surface_z
);
// The fix: a subject standing in Undercity reads Undercity's own liquid — and there is none
// over this XY, in any of its 38 liquid groups.
let room = scene
.containing_room(feet)
.expect("the player stands inside a placement");
assert!(
scene.model_of(room).contains("undercity"),
"the containing placement is Undercity (got {})",
scene.model_of(room)
);
assert!(
liquid_at(scene.surfaces.iter(), feet, LiquidClaim::inside(room)).is_none(),
"indoors, the ADT lake overhead is not this subject's liquid"
);
}
/// **The gradient the swim law is sized against** (decision 0644): Felwood's Felfire Hill
/// channel, along the run the live probe swam. A liquid surface is a heightfield, and *how far
/// from flat* is exactly what decides whether the swim latch's 1/36-yd hysteresis band can
/// absorb travelling along it — so the slope `player::swim`'s regression test drives is pinned
/// here against the shipped ADT instead of living as a constant someone can only take on faith.
#[test]
fn the_felfire_channel_falls_about_a_tenth_of_a_yard_per_yard() {
let (downstream, upstream) = ([1953.97_f32, -2866.84, 0.0], [2013.97_f32, -2866.84, 0.0]);
let Some(all) = liquid_scene("Kalimdor", downstream).map(|s| s.surfaces) else {
eprintln!("skipping: no WoW client data");
return;
};
let z = |at: [f32; 3]| {
liquid_at(all.iter(), at, LiquidClaim::Outdoors)
.unwrap_or_else(|| panic!("no river at {at:?}"))
.surface_z
};
let slope = (z(upstream) - z(downstream)) / (upstream[0] - downstream[0]);
assert!(
(slope - 0.099).abs() < 0.005,
"the channel's gradient over 60 yd (got {slope})"
);
}