Files
benilla-octo/crates/benilla-formats/examples/liquid_depth_census.rs
T
SamandClaude Fable 5.1 fd1bb10261 a refused login says why, the deep ocean darkens, and the blade trails
- a refused character login left the player on a loading screen with no world behind it and no way off — reported as sitting at character select with no message, and the symptom was worse than the report: `SMSG_CHARACTER_LOGIN_FAILED` (0x41) had a name and no parse arm, so the client never heard it, and it was not at character select when the refusal landed, because the IO thread sends `CMSG_PLAYER_LOGIN` and announces the connection in the same breath (the entry's head start, 0777), so `ClientState::InWorld` was already set and the loading cover was up waiting for a snap the server had just declined to send; the wire half parses the opcode into `ServerPacket::CharacterLoginFailed` / `SessionEvent::CharacterLoginFailed` and carries the byte RAW, since vmangos sends a bare `1` and mangos-classic its own `CharLoginFailReasons`, and a wire layer that interpreted it would be picking a server; the refusal is an entry being REVOKED, not a failure to enter — `Cycle::LoginRefused` runs the same `SessionEnd::LoggedOut` teardown a `/logout` does, the cover cuts to black, `back_on_login_refused` takes the select screen back with the relist's roster underneath, and clearing `pending_pick` is load-bearing: it is 0065's reconnect memory and the relist is auto-answered with it, so keeping it would re-enter the refused character forever from behind a black screen; the byte is a 1-BASED REASON INDEX, not a status code — wow-re read `OnCharLoginResult` (`0x5aad70`): `dec eax; cmp eax,5; ja` into a six-entry jump table mapping 1..=6 onto `CHAR_LOGIN_*` 0x3e/0x3f/0x40/0x42/0x43/0x44 and SKIPPING 0x41 `CHAR_LOGIN_FAILED`, reachable only as the default, so the tempting `0x3d + byte` is right for four rows and wrong for the rest; `char_login_refusal_text` is that table, resolved against the player's own `GlueStrings.lua` and asserted against it (vmangos's `1` therefore reads "World server is down" — the server's dialect, and what the reference would say); the select screen runs the shared `login::drive_dialog` rather than a second copy of the reference's one `GlueDialog`, which found two holes — `to_select_on_roster` closed ANY dialog on an arriving roster while the refusal's own relist produces one a second later, so it would have wiped the message (the original bug one layer up; now scoped to status/queued), and `preflight`'s descriptor latch outlived the session that armed it, announcing "the avatar never streamed in" from character select, now disarmed on any disconnect; a live probe against vmangos pins the three facts this rests on — the byte arrives as 0x01, the roster still serves after it, a retry enters the world on the same socket — and `WOW_REFUSE_LOGIN=1` refuses the first pick of a run by sending a non-player guid, the one guard that needs no server state, so a path no real account can stage by hand is walkable on demand (2071)
- the ocean's depth ramp was never a placeholder, and its deep row is darker than we drew it: four files called the ADT ocean's `V = byte/255` a stand-in "pending its own RE" — the director's report had the sea fading to deep colour about 6x too slowly — on the claim that ocean takes a non-LUT UV path via `0x68d890`, which is the MAGMA vert-fill, as `benilla_adt::LiquidVertex::texcoords` had said by that same address all along; the ocean fill is `0x68d690` and it reads a depth LUT exactly as the river fill `0x68d790` does, `0x68c4c0` building both side by side (`0xc81768 = min(d/42, 1)` river, `0xc7fcd8 = min(d/255, 1)` ocean), the two fills differing in exactly 3 of 246 bytes — one `fld` displacement — so there is no second ocean-only depth term, and wow-re's cross-check found `/255` bit-exact for all 256 entries; the divisors differ because the authored bytes do — a census over every MCLQ block on Azeroth + Kalimdor, each wet vertex's depth byte against its own geometric depth, reads a river at 8.96 byte/yd (reproducing the independently-measured ≈8.5, the control) and the sea at 1.72, 5.2x gentler, so `/255` saturates at ~148 yd and `/42` at ~4.7 yd, the same ramp in yards and not a 6x slower one, while 83.4% of ocean vertices carry byte 255 outright, so the open sea is authored fully deep and the ramp is only a shore band; a test pins both divisors against the raw MCLQ bytes (swapping the ocean onto `/42` fails it), `examples/liquid_depth_census` is the measurement with named shore-band pins, `WOW_OCEAN_DEPTH_DIV` is the A/B, and the alpha-ramp deferral closes with it — `0xc7fbc0`'s `1.6·(i/63)^8` is the sky glare falloff, bound at `0x685257` inside a branch proven dead, so the linear `127 + 2·row` alpha shipped was right; and the finding nobody asked for: `0x68a830` fills the 8×64 swatch as an exact integer accumulator in BYTE space, `row(i) = c0 + floor(i·(c1−c0)/64)`, so the ramp never reaches the deep endpoint (row 63 is ~98.4% of the way), and an ocean-only tail `[0x68a9c0, 0x68aa36)` — gated on last row AND selector 0, river being selector 1 — runs RGB→HSV, `V ×= 0.9` (`0x3f666666` at `0x8102ec`), HSV→RGB and forces that row's alpha to 255, which reduces to `floor(0.9·byte)` per channel (99.03% bit-exact over all 2^24 triples, max 1/255); byte 255 ⇒ `V = 1.0` ⇒ row 63 exactly, so the darkened row IS the open sea, and benilla had rendered deep ocean ~10% too bright over most of the water in the world — an error that survived every regression diff because no capture scenario holds any ocean; `liquid.wgsl`'s `swatch_row` / `swatch_at` port the accumulator, the LINEAR/LINEAR CLAMP sampling (so the darkening ramps in across the final 1/64 of V rather than stepping) and the ocean-only tail, on the ADT lane only — the WMO arms keep the plain lerp, their opacity being a different 256-entry ramp — pinned by a test against wow-re's published byte vectors (row 62 = 12,36,41; row 63 = 11,35,40, green one ABOVE the deep endpoint; after the tail 9,31,36) that also asserts the WGSL still carries the formula; rivers move at most 7/255 from the row quantization (2069, 2074)
- the weapon swing trail — `SpellVisualKit` CharProc type 8, which benilla had dropped on the floor with the rest of the dispatcher's unmodelled cases: 34 of 1772 kits carry it, 27 are reached from a live `Spell.dbc` chain, and 16 of those name NO emitter slot at all, so the ribbon is their entire visual — kit 324 alone reaches 218 spells (Heroic Strike, Overpower, Mortal Strike, Bloodthirst, Rend) and rendered nothing beyond the swing animation, with 506 (Revenge, Riposte, Mongoose Bite), 557 (Sunder Armor), 646 (Hamstring, Wing Clip), 399 (Sinister Strike), 822 (Garrote) and 823 (Feint, Disengage) in the same shape; the proc draws nothing — `0x60d80a` latches a packed colour and a duration on the unit, and the unit's next `PlayAnimation` (`0x5fe48e`, the fields' only reader) hands both to its two weapon-hand trail objects and clears the duration, so an arm fires exactly once and a second proc on a live trail RESETS its ring rather than stacking; each object rings 128 samples of its weapon model's `$WTB`/`$WTT` markers (455 of the 571 weapon models author both) and draws them as a strip that fades from opaque at the OLDEST end of the arc to ≈0 at the blade, the alpha being a clock and not just an opacity; `weapon_trail.rs` transcribes the arithmetic rather than approximating it — the truncating `_ftol` decode (not the small-int idiom every other integer param takes, which would yield colour 0 and no trail at all), `fadeStep`'s low-byte floor (`cmp al,1`, so a ≈768 ms hitch takes a floor of 1 instead of its own huge value), the alpha-derived termination, and the ring's writer/reader modulus mismatch (`& 127` stored, `% 127` read — shipped 5875 behaviour, reachable only by Whirlwind's 10 000 ms spin); the draw state was dispatched rather than guessed and the guess was wrong in three of seven places: the callback's six EGxRs writes say `0x07 = 2` (ordinary SRC_ALPHA / INV_SRC_ALPHA — additive is mode 3, the difference between a ribbon that whites out crossing itself and a coloured smear) and `0x10 = 0` (depth test OFF: the arc draws over the world and over the swinging character, never eaten by his own shoulder), so `EffectDrawSpec::no_depth_test` is a new pipeline-key bit on the shared effect lane with every other producer explicitly false, where the state had only existed as the global `WOW_PARTICLE_NODEPTH` debug lever; the third — lighting ON over a vertex format with no normal — is a verified state that is not yet a verified appearance and ships unlit as a named deviation with the question out; the consume edge is `AnimDriver::started_anim`, not the one-shot alone, because `0x5fe2f0` is the image's SINGLE animation entry point, locomotion included — a unit that simply starts running consumes the arm, which is how Charge (kit 44, 38 spells, anim id −1) trails at all; kit 324's 600 ms duration is on screen for 1250 ms at 60 Hz, since `fadeStep` is `trunc(dt·alpha/300)` and shrinks with the alpha it decays, twice wow-re's own estimate; the warm pass gets the depth-test-off pipeline as the one combination that ships (alpha, unlit, no rasterizer settle) rather than as another factor of the cross product — 36 more warm draws per camera for pipelines nothing asks for — so the first Heroic Strike does not compile one live; and `benilla-extract charprocs` grows the trail census that found the 34/27/16 split (2076)
- a zero injury column plays nothing: benilla walked down the wound-grunt family when the selected column was 0 — crushing to critical to ordinary — on a comment with no citation ("crushing rows are often 0 in data"), a true observation about the data and a false inference about the client; `0x623490` tests the selected id once (`0x6234e6 test ebx,ebx ; je 0x62350e`, the shared epilogue) and no fallback is even reachable — the row pointer dies at `0x6234e4`, so neither the bus pick `0x623b10` nor the play `0x458890` ever sees the row to re-read a column from, and the CGPlayer twin `0x62f880` is identical; it mattered because the class-2 chance roll had just landed beside it (2073): a crushing blow skips the roll (class 9 carries threshold 100) AND fell through to the ordinary kit, so it was louder than an ordinary hit on the same creature by two routes; `sound/combat.rs` now plays the selected column or nothing — crushing → column 5, critical → 4, ordinary → 3 — and a crushing blow on a creature with no crushing kit, which is most of the 406 rows (`Skeleton`, `Silithid`, `MountainGiant` among them), is silent, the reference's behaviour rather than an omission (2075)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:09:51 +07:00

355 lines
15 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.
//! What the shipped MCLQ **depth byte** actually is, per liquid kind — the instrument behind the
//! ocean depth-ramp settle:
//! `cargo run --release -p benilla-formats --example liquid_depth_census -- [map ...]`
//! (default: `Azeroth Kalimdor`, i.e. every ADT ocean and river in the vanilla world.)
//!
//! The reference indexes its water depth-swatch by this one byte through a per-kind LUT — river
//! `min(d/42, 1)`, ocean `min(d/255, 1)` — so the *look* of a sea is that LUT applied to whatever
//! the artists authored into the byte. A divisor argued from the binary alone can still be wrong
//! about the picture if the authored range never reaches it, so this measures the range, and the
//! byte→yard slope that turns it into a distance a swimmer can feel:
//!
//! - the **depth-byte histogram** per kind, over vertices that touch a wet cell (dry-cell verts
//! carry the FLT_MAX height sentinel and no meaningful byte);
//! - the **byte→yard slope**, least-squares through the origin against the real geometric depth
//! `surface − terrain` at each vertex, plus the correlation — which is also the check that byte 0
//! of the water/ocean union IS a depth at all;
//! - a handful of **shore-band pins** — world coords where the depth byte is mid-ramp, i.e. the only
//! places the divisor is visible at all. Everything else is either the pale edge or the pinned-deep
//! open sea, and an A/B aimed anywhere else photographs two identical pictures;
//! - **what each candidate divisor does to the picture**: the share of wet water that lands on the
//! deep swatch row (V ≥ 0.95), on the shallow row (V ≤ 0.05), and the mean V.
//!
//! Output is Blizzard-derived data — read it, pipe it to the scratchpad, never into the repo.
use std::collections::BTreeMap;
use benilla_formats::{terrain_height_at, LiquidKind, CHUNK_SIZE};
/// MCLQ vertex-grid pitch: the 9×9 grid spans one 8-cell chunk.
const UNIT: f32 = CHUNK_SIZE / 8.0;
/// MCLQ grid side (9 verts) and cell side (8).
const GRID: usize = 9;
const CELLS: usize = 8;
/// Cell-flag low nibble meaning "dry".
const DRY_NIBBLE: u8 = 0x0f;
/// Verts under no liquid carry FLT_MAX; `is_finite()` is true for it, so gate on magnitude.
const HEIGHT_SENTINEL: f32 = 1.0e9;
/// The per-kind accumulator: the byte histogram plus the paired (byte, yard) samples.
struct Kind {
/// Count per raw depth byte, 0..=255.
hist: Box<[u64; 256]>,
/// Vertices whose terrain height resolved, for the byte→yard fit.
paired: u64,
/// Σ byte·yd, Σ byte², Σ yd², Σ byte, Σ yd — enough for a through-origin slope and an r.
sum_by: f64,
sum_bb: f64,
sum_yy: f64,
sum_b: f64,
sum_y: f64,
/// Blocks seen.
blocks: u64,
/// A spread of mid-ramp sample points: `(zone, map, x, y, byte, yards)`, at most one per
/// 2000-yd neighbourhood, so the list names distinct coastlines instead of 200 vertices of one
/// bay. Named by `AreaTable`, because a pin you can walk to is worth more than a coordinate.
pins: Vec<(String, String, f32, f32, u8, f32)>,
/// Σ yards and count per depth byte — the empirical byte→depth curve, which the single
/// through-origin slope cannot show once a kind's byte saturates (the ocean's does, hard).
yd_sum: Box<[f64; 256]>,
yd_n: Box<[u64; 256]>,
}
impl Default for Kind {
fn default() -> Self {
Self {
hist: Box::new([0; 256]),
paired: 0,
sum_by: 0.0,
sum_bb: 0.0,
sum_yy: 0.0,
sum_b: 0.0,
sum_y: 0.0,
blocks: 0,
pins: Vec::new(),
yd_sum: Box::new([0.0; 256]),
yd_n: Box::new([0; 256]),
}
}
}
impl Kind {
fn push(&mut self, byte: u8, yards: Option<f32>) {
self.hist[byte as usize] += 1;
if let Some(y) = yards {
let (b, y) = (f64::from(byte), f64::from(y));
self.paired += 1;
self.yd_sum[byte as usize] += y;
self.yd_n[byte as usize] += 1;
self.sum_by += b * y;
self.sum_bb += b * b;
self.sum_yy += y * y;
self.sum_b += b;
self.sum_y += y;
}
}
fn total(&self) -> u64 {
self.hist.iter().sum()
}
/// Byte at the given share of the distribution.
fn percentile(&self, p: f64) -> u8 {
let target = (self.total() as f64 * p) as u64;
let mut seen = 0;
for (b, n) in self.hist.iter().enumerate() {
seen += n;
if seen >= target {
return b as u8;
}
}
255
}
/// Least-squares slope of `byte = k · yards` through the origin, and Pearson r of the pair.
fn slope_and_r(&self) -> Option<(f64, f64)> {
if self.paired < 32 || self.sum_yy <= 0.0 {
return None;
}
let n = self.paired as f64;
let slope = self.sum_by / self.sum_yy;
let cov = self.sum_by / n - (self.sum_b / n) * (self.sum_y / n);
let vb = self.sum_bb / n - (self.sum_b / n).powi(2);
let vy = self.sum_yy / n - (self.sum_y / n).powi(2);
let r = if vb > 0.0 && vy > 0.0 {
cov / (vb.sqrt() * vy.sqrt())
} else {
0.0
};
Some((slope, r))
}
/// Least-squares `byte = k · yards` through the origin over a byte window only. The ocean
/// pins 83 % of its vertices at 255, which drags a whole-range fit toward the pile and hides
/// the ramp that actually paints the shore; this reads the ramp where it varies.
fn slope_in(&self, lo: u8, hi: u8) -> Option<(f64, u64)> {
let (mut sby, mut syy, mut n) = (0.0, 0.0, 0u64);
for b in lo..=hi {
let cnt = self.yd_n[b as usize];
if cnt == 0 {
continue;
}
// Bucket mean stands for its members: Σ b·y and Σ y² over the bucket, using the mean y.
let ybar = self.yd_sum[b as usize] / cnt as f64;
sby += f64::from(b) * ybar * cnt as f64;
syy += ybar * ybar * cnt as f64;
n += cnt;
}
(n >= 32 && syy > 0.0).then(|| (sby / syy, n))
}
/// What a divisor does to the picture: (share on the deep row, share on the shallow row, mean V).
fn under(&self, div: f64) -> (f64, f64, f64) {
let total = self.total().max(1) as f64;
let (mut deep, mut shallow, mut sum) = (0.0, 0.0, 0.0);
for (b, &n) in self.hist.iter().enumerate() {
let v = (b as f64 / div).clamp(0.0, 1.0);
let n = n as f64;
if v >= 0.95 {
deep += n;
}
if v <= 0.05 {
shallow += n;
}
sum += v * n;
}
(deep / total, shallow / total, sum / total)
}
}
fn main() -> anyhow::Result<()> {
let maps: Vec<String> = {
let a: Vec<String> = std::env::args().skip(1).collect();
if a.is_empty() {
vec!["Azeroth".into(), "Kalimdor".into()]
} else {
a
}
};
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
// Names for the shore-band pins. Optional: a census still reads without a DBC.
let areas = benilla_formats::load_area_table_catalog(&mut chain).ok();
let mut kinds: BTreeMap<String, Kind> = BTreeMap::new();
for map in &maps {
let tiles = benilla_formats::MapTiles::load(&mut chain, map)?;
// Tile (32, 32) is world (0, 0); radius 32 covers the whole 64×64 grid.
let coords = tiles.existing_in_radius(0.0, 0.0, 32);
eprintln!("{map}: {} tiles", coords.len());
for (tx, ty) in coords {
let path = format!("World\\Maps\\{map}\\{map}_{tx}_{ty}.adt");
let Ok(bytes) = chain.read_file(&path) else {
continue;
};
// The mesh is only the TERRAIN reference here — the depth bytes come off the raw parse,
// so the census can never be a restatement of the divisor it is measuring.
let Ok(mesh) = benilla_formats::adt_to_tile_mesh(&bytes) else {
continue;
};
let mut cur = std::io::Cursor::new(bytes.as_slice());
let Ok(benilla_adt::ParsedAdt::Root(root)) = benilla_adt::parse_adt(&mut cur) else {
continue;
};
for mcnk in &root.mcnk_chunks {
let [wx, wy, _] = mcnk.header.position;
for block in &mcnk.liquids {
if block.vertices.len() < GRID * GRID {
continue;
}
// Kind from the majority WET cell nibble — the verified type source, and the
// same rule `build_liquid_mesh` uses, so the census bins as the renderer draws.
let mut counts = [0u32; 16];
let mut wet = [false; CELLS * CELLS];
for (c, w) in wet.iter_mut().enumerate() {
let nib = block.tile_flags[c] & 0x0f;
if nib == DRY_NIBBLE {
continue;
}
counts[nib as usize] += 1;
*w = true;
}
let Some((maj, _)) = counts.iter().enumerate().max_by_key(|(_, &c)| c) else {
continue;
};
if counts[maj] == 0 {
continue;
}
let Some(kind) = LiquidKind::from_adt_nibble(maj as u8) else {
continue;
};
// Magma spends the same union bytes on texture coords, not a depth.
if kind == LiquidKind::Magma {
continue;
}
let acc = kinds.entry(format!("{kind:?}")).or_default();
acc.blocks += 1;
for n in 0..GRID * GRID {
let (row, col) = (n / GRID, n % GRID);
// Only verts that corner a wet cell carry a real height and a real byte.
let touches = [
(row.wrapping_sub(1), col.wrapping_sub(1)),
(row.wrapping_sub(1), col),
(row, col.wrapping_sub(1)),
(row, col),
]
.into_iter()
.any(|(r, c)| r < CELLS && c < CELLS && wet[r * CELLS + c]);
if !touches {
continue;
}
let h = block.vertices[n].height;
if !h.is_finite() || h.abs() >= HEIGHT_SENTINEL {
continue;
}
let x = wx - row as f32 * UNIT;
let y = wy - col as f32 * UNIT;
// Nudge off the exact lattice line so the terrain lookup lands inside a
// triangle rather than on a chunk seam.
let ground = terrain_height_at(&mesh.chunks, [x - 0.01, y - 0.01, 0.0]);
let yards = ground.map(|g| h - g).filter(|d| *d > 0.0 && *d < 500.0);
let byte = block.vertices[n].depth_byte();
// One mid-ramp pin per tile: where the ramp is neither pinned deep nor at
// the pale edge, which is the only band an A/B can see.
if (40..=200).contains(&byte)
&& acc.pins.len() < 64
&& !acc.pins.iter().any(|p| {
p.1 == *map && (p.2 - x).abs() < 2000.0 && (p.3 - y).abs() < 2000.0
})
{
let zone = benilla_formats::area_id_at(&mesh.chunks, [x, y, 0.0])
.and_then(|id| areas.as_ref()?.name(id).map(str::to_string))
.unwrap_or_else(|| "?".into());
acc.pins.push((
zone,
map.clone(),
x,
y,
byte,
yards.unwrap_or(f32::NAN),
));
}
acc.push(byte, yards);
}
}
}
}
}
println!(
"\n=== MCLQ depth byte, over wet-cell vertices ({}) ===",
maps.join(" + ")
);
for (name, k) in &kinds {
let total = k.total();
println!(
"\n{name}: {} blocks, {total} wet verts ({} paired with terrain)",
k.blocks, k.paired
);
print!(" byte percentiles:");
for p in [0.01, 0.10, 0.25, 0.50, 0.75, 0.90, 0.99] {
print!(" p{:02}={:3}", (p * 100.0) as u32, k.percentile(p));
}
println!(" max={}", k.hist.iter().rposition(|&n| n > 0).unwrap_or(0));
match k.slope_and_r() {
Some((slope, r)) => println!(
" byte -> yards: {slope:.2} byte/yd (r={r:.3}) => byte 42 = {:.1} yd, byte 255 = {:.1} yd",
42.0 / slope,
255.0 / slope
),
None => println!(" byte -> yards: too few paired samples"),
}
for div in [42.0, 255.0] {
let (deep, shallow, mean) = k.under(div);
println!(
" V = byte/{div:<5.0} -> mean {mean:.3}, {:.1}% on the DEEP row (V>=.95), {:.1}% on the SHALLOW row (V<=.05)",
deep * 100.0,
shallow * 100.0
);
}
if let Some((slope, n)) = k.slope_in(1, 250) {
println!(
" UNSATURATED band (byte 1..250, {n} verts): {slope:.2} byte/yd => byte 42 = {:.2} yd, byte 255 = {:.2} yd",
42.0 / slope,
255.0 / slope
);
}
print!(" byte -> mean geometric depth (surface-terrain), yd:");
for b in [1u8, 8, 16, 32, 42, 64, 96, 128, 160, 192, 224, 254, 255] {
let n = k.yd_n[b as usize];
if n >= 8 {
print!(" {b}={:.1}", k.yd_sum[b as usize] / n as f64);
} else {
print!(" {b}=-");
}
}
println!();
if !k.pins.is_empty() {
println!(" shore-band pins (byte 40..200) — where the divisor is visible:");
for (zone, map, x, y, b, yd) in k.pins.iter().take(12) {
println!(" {map} {x:9.1} {y:9.1} byte {b:3} depth {yd:5.1} yd {zone}");
}
}
// The coarse shape, so a bimodal or pinned distribution shows without a plot.
print!(" histogram (16 buckets of 16):");
for b in 0..16 {
let n: u64 = k.hist[b * 16..(b + 1) * 16].iter().sum();
print!(" {:.1}%", n as f64 / total.max(1) as f64 * 100.0);
}
println!();
}
Ok(())
}