Files
benilla-octo/crates/benilla-adt/src/lib.rs
T
SamandClaude Fable 5 d7f6a90bca the map's invisible walls are read from the bytes at last
- the terrain's impassable-chunk flag is parsed and fenced the way the binary fences it — all four sides, floored, rising only — so movement stops exactly where 1.12.1 stops
- a vertex normal authored as all zeros is legal content: the normalize is guarded, and the flat-black sleeves and skirts get their lighting back — a corpus sweep found 73 affected models nobody had filed
- Caverns of Time's asteroid belts turn: a rigid bone needs no rig

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:22:35 +07:00

776 lines
30 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.
//! An ADT terrain-tile reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-adt`
//! (decision 0021).
//!
//! Vanilla ADTs are monolithic, chunked (`IFF`-style: a 4-char magic — stored **reversed** on disk —
//! plus a `u32` size, then payload). The root file holds `MTEX` (textures), `MMDX`/`MMID` (M2 paths),
//! `MWMO`/`MWID` (WMO paths), `MDDF`/`MODF` (placements), and 256 `MCNK` terrain chunks. Each `MCNK` is
//! a 128-byte header followed by sub-chunks (`MCVT` heights, `MCNR` normals, `MCLY` layers, `MCAL`
//! alpha, `MCSH` shadow, `MCLQ` liquid), which we locate by **scanning** the chunk payload for their
//! magics (robust, and identical to the header-offset data for vanilla).
//!
//! The shape mirrors what the renderer's terrain/liquid mesher consumes; quirks the consumer
//! compensates for (the MCNR `[x,z,y]` byte order, the split predominant-texture/noEffectDoodad
//! grids) are preserved deliberately. Proven against `wow-adt` over real ADTs during the decision-0021
//! migration (oracle test in git history); the `benilla-formats` terrain golden tests pin the meshing
//! end-to-end on every run.
//!
//! Byte access goes through `benilla-bytes` (decision 0064): every read is bounds-checked and a
//! truncated chunk/record is a typed [`Error::Truncated`], never a panic.
use std::io::Cursor;
use benilla_bytes::{chunks, ByteExt};
/// A terrain vertex normal — stored on disk as `x, z, y` signed bytes.
#[derive(Debug, Clone, Copy)]
pub struct VertexNormal {
pub x: i8,
pub z: i8,
pub y: i8,
}
impl VertexNormal {
/// `[x/127, y/127, z/127]` — i.e. on-disk `[b0, b2, b1]` / 127 (kept bit-identical to wow-adt; the
/// consumer cancels the z/y swap).
pub fn to_normalized(&self) -> [f32; 3] {
[
f32::from(self.x) / 127.0,
f32::from(self.y) / 127.0,
f32::from(self.z) / 127.0,
]
}
}
/// MCVT — 145 heights (9×9 outer + 8×8 inner, stride-17 interleave).
#[derive(Debug, Clone)]
pub struct McvtChunk {
pub heights: Vec<f32>,
}
/// MCNR — one normal per MCVT vertex.
#[derive(Debug, Clone)]
pub struct McnrChunk {
pub normals: Vec<VertexNormal>,
}
/// MCLY per-layer flags (we only need the alpha-compression bit).
#[derive(Debug, Clone, Copy)]
pub struct MclyFlags {
pub value: u32,
}
impl MclyFlags {
/// Bit 9 (0x200): the layer's MCAL alpha map is RLE-compressed.
pub fn alpha_map_compressed(&self) -> bool {
self.value & 0x200 != 0
}
}
/// MCLY — one texture layer (16 bytes on disk).
#[derive(Debug, Clone)]
pub struct MclyLayer {
pub texture_id: u32,
pub flags: MclyFlags,
pub offset_in_mcal: u32,
pub effect_id: u32,
}
/// MCLY — the chunk's texture layers (0–4; layer 0 is the opaque base).
#[derive(Debug, Clone)]
pub struct MclyChunk {
pub layers: Vec<MclyLayer>,
}
/// MCAL — raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]).
#[derive(Debug, Clone)]
pub struct McalChunk {
pub data: Vec<u8>,
}
/// MCSH — a 64×64 1-bit baked shadow map (512 bytes).
#[derive(Debug, Clone)]
pub struct McshChunk {
pub shadow_map: Vec<u8>,
}
impl McshChunk {
/// Whether texel `(x, y)` (column, row; both 0..63) is shadowed.
pub fn is_shadowed(&self, x: usize, y: usize) -> bool {
if x >= 64 || y >= 64 {
return false;
}
let byte = self.shadow_map.get(y * 8 + x / 8).copied().unwrap_or(0);
(byte >> (x % 8)) & 1 != 0
}
}
/// One MCLQ liquid vertex (8 bytes: 4 union bytes + an absolute height).
///
/// The leading 4 bytes are a **union whose meaning is the block's liquid type** — water/ocean spend
/// them on a depth byte + flow/foam, magma/slime on a `u16` texture-coordinate pair. Both readers are
/// below; which one applies is the caller's decision, from the cell nibble.
#[derive(Debug, Clone, Copy)]
pub struct LiquidVertex {
pub union_data: [u8; 4],
pub height: f32,
}
impl LiquidVertex {
/// The depth byte (union byte 0) — drives the water depth-swatch on the render side. Water and
/// ocean blocks only; on a magma block these bytes are [`Self::texcoords`] instead.
pub fn depth_byte(&self) -> u8 {
self.union_data[0]
}
/// The authored `(s, t)` texture-coordinate pair — two little-endian `u16`s over the same 4
/// union bytes. **Magma blocks only**: the reference's lava vert-fill is single-stage and reads
/// its `tc0` straight from here, where water/ocean instead write a UV into the depth-ramp
/// texture (VERIFIED bit-exact `WoW.exe 0x68d890` `liquid_render_verts`, emulation-diffed in
/// wow-re `crates/terrain`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is
/// authored world-continuous — a chunk's east edge repeats its neighbour's west edge exactly —
/// so lava tiles seamlessly across MCNK borders.
pub fn texcoords(&self) -> [u16; 2] {
[
u16::from_le_bytes([self.union_data[0], self.union_data[1]]),
u16::from_le_bytes([self.union_data[2], self.union_data[3]]),
]
}
}
/// One MCLQ liquid **block** — a 9×9 absolute-height grid + an 8×8 cell-flag grid, `0x324` bytes.
///
/// An MCNK carries **one block per set liquid header bit** (bits 2–5), packed back to back and with
/// absent bits consuming nothing (VERIFIED wow-re `system/terrain/scratch/adt-format.md`, the cursor
/// walk at `0x6af7a3`–`0x6af7cb`). Most liquid chunks set exactly one bit; **28 shipped Azeroth
/// chunks set two** — a river *and* the sea, at a river mouth — and carry two blocks at different
/// heights (measured: `sizeMCLQ` 1616 = 8 + 2·`0x324`, block 0 the stream at z≈5.0, block 1 the
/// ocean at z=0). Reading only the first is how the sea goes missing there.
///
/// **No liquid type here on purpose.** The header-bit→type ordering (bit2 river / bit3 ocean /
/// bit4 magma / bit5 slime) is *INFERRED* upstream — never byte-proven — while the per-cell flag low
/// nibble IS the type, VERIFIED at both consumers (`0x6ba970` `and al,0xf`, `0x68d9b0`). So this
/// reader takes only the block *count* from the flags and leaves the type to whoever reads
/// [`Self::tile_flags`]. (The old `LiquidType::from_mcnk_flags` priority guess labelled all 28
/// two-bit chunks "ocean" while handing back the river block's bytes.)
#[derive(Debug, Clone)]
pub struct MclqChunk {
pub min_height: f32,
pub max_height: f32,
pub vertices: Vec<LiquidVertex>,
/// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x40` fishable,
/// `0x80` shared.
pub tile_flags: [u8; 64],
}
/// MCNK header flag **bit 1** — the chunk a mover may not enter (the "impassable" band that walls
/// Searing Gorge and the other mountain rims off from their neighbours).
///
/// The *producer* is VERIFIED per-instruction in wow-re (`system/terrain/scratch/adt-format.md`,
/// the MCNK header walk: `hdr+0x00` bit1 → `chunk+0xc |= 0x40`); the *name* is wow-re's INFERRED
/// label for that bit. Consuming it is decision 1266 / report B129.
pub const MCNK_IMPASSABLE: u32 = 0x2;
/// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/
/// `unknown_8bytes` mirror wow-adt's (mis)split of the predominant-texture + noEffectDoodad region,
/// which the consumer reconstructs — kept identical on purpose.
#[derive(Debug, Clone)]
pub struct McnkHeader {
pub flags: u32,
pub index_x: u32,
pub index_y: u32,
/// `AreaTable.dbc` id of this chunk (header +0x34) — the zone/subzone the player is standing
/// in; drives zone music/ambience/reverb selection (decision 0070).
pub area_id: u32,
pub holes_low_res: u16,
pub pred_tex: [u8; 8],
pub no_effect_doodad: [u8; 8],
pub unknown_8bytes: [u8; 8],
pub position: [f32; 3],
}
impl McnkHeader {
/// Is this chunk flagged [`MCNK_IMPASSABLE`]? The flag is a header bit, so its granularity is
/// the whole 33.333 yd chunk; in the shipped data the set chunks form contiguous ribbons along
/// zone rims (Searing Gorge's is 48 chunks of `Azeroth_33_44` alone).
pub fn impassable(&self) -> bool {
self.flags & MCNK_IMPASSABLE != 0
}
}
/// One MCNK terrain chunk.
#[derive(Debug, Clone)]
pub struct McnkChunk {
pub header: McnkHeader,
pub heights: Option<McvtChunk>,
pub normals: Option<McnrChunk>,
pub layers: Option<MclyChunk>,
pub alpha: Option<McalChunk>,
pub shadow: Option<McshChunk>,
/// The chunk's MCLQ liquid blocks — **one per set liquid header bit**, in on-disk order (see
/// [`MclqChunk`]). Empty on a dry chunk; two at a river mouth.
pub liquids: Vec<MclqChunk>,
}
/// MDDF — a placed M2 doodad (36 bytes).
#[derive(Debug, Clone)]
pub struct DoodadPlacement {
pub name_id: u32,
pub unique_id: u32,
pub position: [f32; 3],
pub rotation: [f32; 3],
pub scale: u16,
pub flags: u16,
}
/// MODF — a placed WMO (64 bytes).
#[derive(Debug, Clone)]
pub struct WmoPlacement {
pub name_id: u32,
pub unique_id: u32,
pub position: [f32; 3],
pub rotation: [f32; 3],
pub flags: u16,
pub doodad_set: u16,
/// `WMOAreaTable.NameSetID` selector — which naming/audio variant of the building this
/// placement is (Northshire Abbey vs Tyr's Hand Abbey are one WMO, different name sets).
pub name_set: u16,
}
/// A parsed vanilla (monolithic) root ADT.
#[derive(Debug, Clone)]
pub struct RootAdt {
pub textures: Vec<String>,
pub models: Vec<String>,
pub wmos: Vec<String>,
pub doodad_placements: Vec<DoodadPlacement>,
pub wmo_placements: Vec<WmoPlacement>,
pub mcnk_chunks: Vec<McnkChunk>,
}
/// Either a vanilla monolithic root ADT (all we produce) — kept as an enum to mirror the consumer's
/// `ParsedAdt::Root(..)` match.
#[derive(Debug, Clone)]
pub enum ParsedAdt {
Root(Box<RootAdt>),
}
/// ADT parse error.
#[derive(Debug)]
pub enum Error {
Truncated(&'static str),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Truncated(w) => write!(f, "truncated ADT: {w}"),
}
}
}
impl std::error::Error for Error {}
type Result<T> = std::result::Result<T, Error>;
/// Split a NUL-separated string blob into a `Vec<u32>` of start offsets paired with strings (used to
/// resolve MMDX/MWMO via MMID/MWID offsets).
fn cstring_at(blob: &[u8], offset: usize) -> String {
let end = blob[offset.min(blob.len())..]
.iter()
.position(|&c| c == 0)
.map(|p| offset + p)
.unwrap_or(blob.len());
String::from_utf8_lossy(&blob[offset.min(blob.len())..end]).into_owned()
}
/// Parse a vanilla monolithic ADT. (`cursor` position is ignored; the file starts at the first chunk.)
pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result<ParsedAdt> {
let b: &[u8] = cursor.get_ref();
let mut textures = Vec::new();
let mut mmdx = Vec::new();
let mut mmid: Vec<u32> = Vec::new();
let mut mwmo = Vec::new();
let mut mwid: Vec<u32> = Vec::new();
let mut doodad_placements = Vec::new();
let mut wmo_placements = Vec::new();
let mut mcnk_chunks = Vec::new();
for (magic, data) in chunks(b) {
match &magic {
b"XETM" => textures = split_strings(data), // MTEX
b"XDMM" => mmdx = data.to_vec(), // MMDX (blob)
b"DIMM" => mmid = read_u32_list(data, "MMID")?, // MMID (offsets)
b"OMWM" => mwmo = data.to_vec(), // MWMO (blob)
b"DIWM" => mwid = read_u32_list(data, "MWID")?, // MWID (offsets)
b"FDDM" => doodad_placements = read_mddf(data)?, // MDDF
b"FDOM" => wmo_placements = read_modf(data)?, // MODF
b"KNCM" => mcnk_chunks.push(read_mcnk(data)?), // MCNK
_ => {}
}
}
// Resolve model/WMO paths via the offset tables (the order MDDF/MODF `name_id` indexes).
let models = mmid
.iter()
.map(|&o| cstring_at(&mmdx, o as usize))
.collect();
let wmos = mwid
.iter()
.map(|&o| cstring_at(&mwmo, o as usize))
.collect();
Ok(ParsedAdt::Root(Box::new(RootAdt {
textures,
models,
wmos,
doodad_placements,
wmo_placements,
mcnk_chunks,
})))
}
/// Split a NUL-separated, NUL-terminated string blob, dropping empties.
fn split_strings(blob: &[u8]) -> Vec<String> {
blob.split(|&c| c == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect()
}
/// `name` tags the truncation error with the chunk this record list came from (MMID/MWID).
fn read_u32_list(data: &[u8], name: &'static str) -> Result<Vec<u32>> {
data.chunks_exact(4)
.map(|c| c.u32_at(0).ok_or(Error::Truncated(name)))
.collect()
}
fn read_mddf(data: &[u8]) -> Result<Vec<DoodadPlacement>> {
let mut out = Vec::new();
for r in data.chunks_exact(36) {
out.push(DoodadPlacement {
name_id: r.u32_at(0).ok_or(Error::Truncated("MDDF"))?,
unique_id: r.u32_at(4).ok_or(Error::Truncated("MDDF"))?,
position: [
r.f32_at(8).ok_or(Error::Truncated("MDDF"))?,
r.f32_at(12).ok_or(Error::Truncated("MDDF"))?,
r.f32_at(16).ok_or(Error::Truncated("MDDF"))?,
],
rotation: [
r.f32_at(20).ok_or(Error::Truncated("MDDF"))?,
r.f32_at(24).ok_or(Error::Truncated("MDDF"))?,
r.f32_at(28).ok_or(Error::Truncated("MDDF"))?,
],
scale: r.u16_at(32).ok_or(Error::Truncated("MDDF"))?,
flags: r.u16_at(34).ok_or(Error::Truncated("MDDF"))?,
});
}
Ok(out)
}
fn read_modf(data: &[u8]) -> Result<Vec<WmoPlacement>> {
let mut out = Vec::new();
for r in data.chunks_exact(64) {
out.push(WmoPlacement {
name_id: r.u32_at(0).ok_or(Error::Truncated("MODF"))?,
unique_id: r.u32_at(4).ok_or(Error::Truncated("MODF"))?,
position: [
r.f32_at(8).ok_or(Error::Truncated("MODF"))?,
r.f32_at(12).ok_or(Error::Truncated("MODF"))?,
r.f32_at(16).ok_or(Error::Truncated("MODF"))?,
],
rotation: [
r.f32_at(20).ok_or(Error::Truncated("MODF"))?,
r.f32_at(24).ok_or(Error::Truncated("MODF"))?,
r.f32_at(28).ok_or(Error::Truncated("MODF"))?,
],
// 0x20..0x38 = bounding box (6 f32); then:
flags: r.u16_at(56).ok_or(Error::Truncated("MODF"))?,
doodad_set: r.u16_at(58).ok_or(Error::Truncated("MODF"))?,
name_set: r.u16_at(60).ok_or(Error::Truncated("MODF"))?,
// 0x3E scale u16 — unused.
});
}
Ok(out)
}
/// Parse one MCNK: a 128-byte header, then sub-chunks located by scanning the payload for their
/// (reversed) magics.
fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
if data.len() < 128 {
return Err(Error::Truncated("MCNK header"));
}
let h = &data[..128];
let header = McnkHeader {
flags: h.u32_at(0x00).ok_or(Error::Truncated("MCNK header"))?,
index_x: h.u32_at(0x04).ok_or(Error::Truncated("MCNK header"))?,
index_y: h.u32_at(0x08).ok_or(Error::Truncated("MCNK header"))?,
area_id: h.u32_at(0x34).ok_or(Error::Truncated("MCNK header"))?,
holes_low_res: h.u16_at(0x3C).ok_or(Error::Truncated("MCNK header"))?,
pred_tex: h
.bytes_at(0x40, 8)
.ok_or(Error::Truncated("MCNK header"))?
.try_into()
.unwrap(),
no_effect_doodad: h
.bytes_at(0x48, 8)
.ok_or(Error::Truncated("MCNK header"))?
.try_into()
.unwrap(),
unknown_8bytes: h
.bytes_at(0x50, 8)
.ok_or(Error::Truncated("MCNK header"))?
.try_into()
.unwrap(),
position: [
h.f32_at(0x68).ok_or(Error::Truncated("MCNK header"))?,
h.f32_at(0x6C).ok_or(Error::Truncated("MCNK header"))?,
h.f32_at(0x70).ok_or(Error::Truncated("MCNK header"))?,
],
};
let mut chunk = McnkChunk {
header,
heights: None,
normals: None,
layers: None,
alpha: None,
shadow: None,
liquids: Vec::new(),
};
// Locate sub-chunks by the MCNK header offsets (the authoritative method — sequential scanning
// breaks when sub-chunks aren't contiguous, e.g. AhnQiraj). MCVT/MCNR offsets live in the
// `multipurpose_field` (0x14/0x18); the rest are explicit header fields. The offset base differs
// across sources (relative to the MCNK magic vs the MCNK data), so we auto-detect per sub-chunk by
// validating the (reversed) magic at both candidate positions. Returns the payload `(start, size)`.
// `ofs` comes straight off a (possibly corrupt) file's header, so both the "minus 8" candidate
// and the 8-byte header read use checked/bounds-checked arithmetic (`checked_sub`/`bytes_at`/
// `u32_at`) — a wrapped or out-of-range offset yields `None`, never a panic.
let locate = |ofs: u32, magic: &[u8; 4]| -> Option<(usize, usize)> {
if ofs == 0 {
return None;
}
let ofs = ofs as usize;
for hdr in [Some(ofs), ofs.checked_sub(8)].into_iter().flatten() {
if data.bytes_at(hdr, 4) == Some(magic.as_slice()) {
let size = data.u32_at(hdr + 4)?;
return Some((hdr + 8, size as usize));
}
}
None
};
// `start` always comes from a `locate` hit (bounds-proven above); `len` is attacker-controlled,
// so the end is saturating before clamping to the buffer.
let slice = |start: usize, len: usize| &data[start..start.saturating_add(len).min(data.len())];
if let Some((p, n)) = locate(
h.u32_at(0x14).ok_or(Error::Truncated("MCNK header"))?,
b"TVCM",
)
.filter(|&(_, n)| n > 0)
{
let sub = slice(p, n);
let mut heights = Vec::new();
for i in 0..145.min(sub.len() / 4) {
heights.push(sub.f32_at(i * 4).ok_or(Error::Truncated("MCVT"))?);
}
chunk.heights = Some(McvtChunk { heights });
}
if let Some((p, n)) = locate(
h.u32_at(0x18).ok_or(Error::Truncated("MCNK header"))?,
b"RNCM",
)
.filter(|&(_, n)| n > 0)
{
let sub = slice(p, n);
let mut normals = Vec::new();
for i in 0..145.min(sub.len() / 3) {
normals.push(VertexNormal {
x: sub.u8_at(i * 3).ok_or(Error::Truncated("MCNR"))? as i8,
z: sub.u8_at(i * 3 + 1).ok_or(Error::Truncated("MCNR"))? as i8,
y: sub.u8_at(i * 3 + 2).ok_or(Error::Truncated("MCNR"))? as i8,
});
}
chunk.normals = Some(McnrChunk { normals });
}
if let Some((p, n)) = locate(
h.u32_at(0x1C).ok_or(Error::Truncated("MCNK header"))?,
b"YLCM",
)
.filter(|&(_, n)| n > 0)
{
let mut layers = Vec::new();
for l in slice(p, n).chunks_exact(16) {
layers.push(MclyLayer {
texture_id: l.u32_at(0).ok_or(Error::Truncated("MCLY"))?,
flags: MclyFlags {
value: l.u32_at(4).ok_or(Error::Truncated("MCLY"))?,
},
offset_in_mcal: l.u32_at(8).ok_or(Error::Truncated("MCLY"))?,
effect_id: l.u32_at(12).ok_or(Error::Truncated("MCLY"))?,
});
}
chunk.layers = Some(MclyChunk { layers });
}
// MCAL's data length is the header's `size_alpha`, NOT the sub-chunk's declared size (they differ
// on some files, e.g. AhnQiraj — wow-adt reads `size_alpha`).
if let Some((p, _)) = locate(
h.u32_at(0x24).ok_or(Error::Truncated("MCNK header"))?,
b"LACM",
) {
let size_alpha = h.u32_at(0x28).ok_or(Error::Truncated("MCNK header"))? as usize;
chunk.alpha = Some(McalChunk {
data: slice(p, size_alpha).to_vec(),
});
}
if let Some((p, n)) = locate(
h.u32_at(0x2C).ok_or(Error::Truncated("MCNK header"))?,
b"HSCM",
)
.filter(|&(_, n)| n > 0)
{
let sub = slice(p, n);
let mut shadow_map = vec![0u8; 512];
let k = sub.len().min(512);
shadow_map[..k].copy_from_slice(&sub[..k]);
chunk.shadow = Some(McshChunk { shadow_map });
}
// MCLQ, like MCAL, takes its length from the MCNK header (`size_liquid`): the MCLQ sub-chunk's
// own declared size is always 0.
if let Some((p, _)) = locate(
h.u32_at(0x60).ok_or(Error::Truncated("MCNK header"))?,
b"QLCM",
) {
let size_liquid = h.u32_at(0x64).ok_or(Error::Truncated("MCNK header"))? as usize;
chunk.liquids = read_mclq_blocks(slice(p, size_liquid), chunk.header.flags);
}
Ok(chunk)
}
/// Bytes per MCLQ block: `{f32 min, f32 max, 81×8B verts, 64B cell flags, u32 nFlow, 2×40B flow}`.
const MCLQ_BLOCK: usize = 0x324;
/// Liquid header bits (2–5) — their **count** is the number of packed MCLQ blocks. Only the count is
/// read: the bit→type ordering is INFERRED, the cell nibble is VERIFIED (see [`MclqChunk`]).
const MCNK_LIQUID_BITS: u32 = 0x3c;
/// Read the packed MCLQ blocks — one per set liquid header bit, back to back (VERIFIED wow-re
/// `adt-format.md`). Stops early on a block the payload can't cover, so a truncated tail degrades to
/// the blocks that *are* whole rather than to nothing. A chunk with an MCLQ sub-chunk but no liquid
/// bit set still yields one block, matching the pre-multi-block reader.
fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec<MclqChunk> {
let want = (mcnk_flags & MCNK_LIQUID_BITS).count_ones().max(1) as usize;
let mut out = Vec::with_capacity(want);
for i in 0..want {
let Some(rest) = sub.get(i * MCLQ_BLOCK..) else {
break;
};
match read_mclq_block(rest) {
Some(b) => out.push(b),
None => break,
}
}
out
}
/// `None` (not an error) below the minimum size: too-short MCLQ data is treated as "no liquid",
/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. (The minimum is
/// the *read* span, short of the full [`MCLQ_BLOCK`]: the trailing flow vectors are unread.)
fn read_mclq_block(sub: &[u8]) -> Option<MclqChunk> {
const NEED: usize = 8 + 81 * 8 + 64;
if sub.len() < NEED {
return None;
}
let min_height = sub.f32_at(0)?;
let max_height = sub.f32_at(4)?;
let mut vertices = Vec::with_capacity(81);
for i in 0..81 {
let o = 8 + i * 8;
vertices.push(LiquidVertex {
union_data: sub.bytes_at(o, 4)?.try_into().ok()?,
height: sub.f32_at(o + 4)?,
});
}
let tile_flags = sub.bytes_at(8 + 81 * 8, 64)?.try_into().ok()?;
Some(MclqChunk {
min_height,
max_height,
vertices,
tile_flags,
})
}
mod combined_alpha;
pub use combined_alpha::CombinedAlphaMap;
#[cfg(test)]
mod tests {
use super::*;
/// Build one IFF chunk: reversed magic + `u32` size + payload.
fn chunk(magic: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(magic);
v.extend_from_slice(&(payload.len() as u32).to_le_bytes());
v.extend_from_slice(payload);
v
}
/// A 128-byte MCNK header with a single MCVT (145 heights) sub-chunk placed right after it,
/// referenced via the "offset relative to the MCNK magic" convention (multipurpose field @0x14
/// pointing at the MCVT chunk's own magic, 128 bytes in).
fn mcnk_with_mcvt() -> Vec<u8> {
let mut data = vec![0u8; 128];
data[0x14..0x18].copy_from_slice(&128u32.to_le_bytes());
let heights: Vec<u8> = (0..145u32).flat_map(|i| (i as f32).to_le_bytes()).collect();
data.extend(chunk(b"TVCM", &heights));
data
}
fn parse(bytes: &[u8]) -> Result<ParsedAdt> {
parse_adt(&mut Cursor::new(bytes))
}
#[test]
fn root_parses_a_minimal_well_formed_chunk_walk() {
let mtex = b"tex1.blp\0tex2.blp\0";
let mmdx = b"model.m2\0";
let mmid = 0u32.to_le_bytes();
let mwmo = b"world.wmo\0";
let mwid = 0u32.to_le_bytes();
let mut mddf = vec![0u8; 36];
mddf[32..34].copy_from_slice(&7u16.to_le_bytes()); // scale
let mut modf = vec![0u8; 64];
modf[58..60].copy_from_slice(&3u16.to_le_bytes()); // doodad_set
let mut b = chunk(b"XETM", mtex);
b.extend(chunk(b"XDMM", mmdx));
b.extend(chunk(b"DIMM", &mmid));
b.extend(chunk(b"OMWM", mwmo));
b.extend(chunk(b"DIWM", &mwid));
b.extend(chunk(b"FDDM", &mddf));
b.extend(chunk(b"FDOM", &modf));
b.extend(chunk(b"KNCM", &mcnk_with_mcvt()));
let ParsedAdt::Root(root) = parse(&b).expect("parses");
assert_eq!(root.textures, vec!["tex1.blp", "tex2.blp"]);
assert_eq!(root.models, vec!["model.m2"]);
assert_eq!(root.wmos, vec!["world.wmo"]);
assert_eq!(root.doodad_placements.len(), 1);
assert_eq!(root.doodad_placements[0].scale, 7);
assert_eq!(root.wmo_placements.len(), 1);
assert_eq!(root.wmo_placements[0].doodad_set, 3);
assert_eq!(root.mcnk_chunks.len(), 1);
let heights = &root.mcnk_chunks[0]
.heights
.as_ref()
.expect("MCVT parsed")
.heights;
assert_eq!(heights.len(), 145);
assert_eq!(heights[1], 1.0);
}
#[test]
fn partial_trailing_mddf_record_is_dropped_not_a_panic() {
// A record shorter than the 36-byte MDDF stride: `chunks_exact` drops the short remainder
// (a pre-existing, unchanged leniency — every *complete* 36-byte record is still
// bounds-checked via `ByteExt`, so this can never panic or misread).
let b = chunk(b"FDDM", &[0u8; 10]);
let ParsedAdt::Root(root) = parse(&b).expect("parses");
assert!(root.doodad_placements.is_empty());
}
#[test]
fn truncated_mcnk_header_is_an_error_not_a_panic() {
let b = chunk(b"KNCM", &[0u8; 40]);
assert!(matches!(parse(&b), Err(Error::Truncated("MCNK header"))));
}
#[test]
fn corrupt_mcnk_subchunk_offset_skips_cleanly_never_panics() {
// ofs = 3: `checked_sub(8)` on the second candidate is `None`, and the first candidate (3)
// doesn't land on a valid magic — must yield `None`, not wrap/panic (the verified
// `ofs.wrapping_sub(8)` trap this replaces).
let mut data = vec![0u8; 128];
data[0x14..0x18].copy_from_slice(&3u32.to_le_bytes());
let b = chunk(b"KNCM", &data);
let ParsedAdt::Root(root) = parse(&b).expect("parses despite corrupt MCVT offset");
assert!(root.mcnk_chunks[0].heights.is_none());
// ofs points past the end of the buffer entirely.
let mut data = vec![0u8; 128];
data[0x14..0x18].copy_from_slice(&u32::MAX.to_le_bytes());
let b = chunk(b"KNCM", &data);
let ParsedAdt::Root(root) = parse(&b).expect("parses despite out-of-range MCVT offset");
assert!(root.mcnk_chunks[0].heights.is_none());
}
#[test]
fn short_mclq_is_treated_as_no_liquid_not_an_error() {
let mut data = vec![0u8; 128];
data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); // MCLQ offset
data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid — far short of NEED
data.extend(chunk(b"QLCM", &[0u8; 4]));
let b = chunk(b"KNCM", &data);
let ParsedAdt::Root(root) = parse(&b).expect("parses");
assert!(root.mcnk_chunks[0].liquids.is_empty());
}
/// **One MCLQ block per set liquid header bit**, packed back to back — the shape that made the
/// sea vanish at 28 shipped river mouths when only the first was read (see [`MclqChunk`]).
/// Two bits set ⇒ two blocks, each keeping its OWN cell flags, so the consumer can tell the
/// river block from the ocean block by nibble.
#[test]
fn a_two_bit_mcnk_yields_two_mclq_blocks_in_disk_order() {
// One block: min/max, 81×8B verts, 64B cell flags, then the flow tail — 0x324 total.
let block = |height: f32, nibble: u8| {
let mut b = vec![0u8; MCLQ_BLOCK];
b[0..4].copy_from_slice(&height.to_le_bytes());
b[4..8].copy_from_slice(&height.to_le_bytes());
for i in 0..81 {
b[8 + i * 8 + 4..8 + i * 8 + 8].copy_from_slice(&height.to_le_bytes());
}
b[8 + 81 * 8..8 + 81 * 8 + 64].fill(nibble);
b
};
let mut payload = block(5.0, 4); // bit 2 (river) — first on disk
payload.extend(block(0.0, 1)); // bit 3 (ocean) — second
let mut data = vec![0u8; 128];
data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 AND 3
data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes());
data[0x64..0x68].copy_from_slice(&((payload.len() + 8) as u32).to_le_bytes());
data.extend(chunk(b"QLCM", &payload));
let b = chunk(b"KNCM", &data);
let ParsedAdt::Root(root) = parse(&b).expect("parses");
let liquids = &root.mcnk_chunks[0].liquids;
assert_eq!(liquids.len(), 2, "one block per set liquid bit");
assert_eq!(liquids[0].tile_flags[0] & 0xf, 4, "block 0 is the river");
assert_eq!(liquids[0].min_height, 5.0);
assert_eq!(liquids[1].tile_flags[0] & 0xf, 1, "block 1 is the ocean");
assert_eq!(liquids[1].min_height, 0.0);
}
/// The magma union bytes are an authored `(s, t)` u16 pair, little-endian over the same 4 bytes
/// the water blocks spend on a depth byte — VERIFIED `0x68d890` reads them as `u16 → i32`, so
/// `0xffff` is 65535 and never −1.
#[test]
fn magma_union_bytes_read_as_two_unsigned_u16_texcoords() {
let v = LiquidVertex {
union_data: [0xbd, 0x00, 0xe8, 0x00],
height: 1.0,
};
assert_eq!(v.texcoords(), [189, 232]); // a real Burning Steppes vertex
assert_eq!(v.depth_byte(), 0xbd, "the same bytes, read the water way");
let max = LiquidVertex {
union_data: [0xff, 0xff, 0xff, 0xff],
height: 1.0,
};
assert_eq!(max.texcoords(), [65535, 65535], "unsigned, not −1");
}
}