Files
benilla-octo/crates/benilla-wdt/src/lib.rs
T
SamandClaude Opus 5.5 c2d6f7c858 the spell owns its state, and a malformed file is refused, not a crash
- the spell owns its lifecycle state: the in-flight cast, the local cancel, the cooldowns and the spell-modifier tables live in one module (2328)
- a malformed MPQ, DBC, M2 or WDT file is refused instead of crashing the client, nine read sites bounded
- a test that needs the install or the addon corpus fails instead of skipping when the data is there; the Bagnon render suite runs again (2329)
- the dev crash injector is armed on every platform, and no instrument writes a path relative to the working directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:08:33 +07:00

525 lines
23 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.
//! WDT (map tile table) reader + tile↔world coords for **WoW 1.12.1 (build 5875)** — in-repo,
//! replacing `wow-wdt` (decision 0021).
//!
//! A vanilla `.wdt` is a tiny chunked file (`MVER`, `MPHD`, `MAIN`, and — on a **WMO-only map** —
//! `MWMO` + one `MODF`). Two things in it matter to the streamer:
//!
//! - **`MAIN`** — a 64×64 table of 8-byte entries whose flag bit 0 (`0x1`) marks "this tile has an
//! `.adt`".
//! - **The global WMO** — `MPHD` dword0 bit 0 marks a map with **no terrain at all**, whose whole
//! world is one building placed by the single `MODF` entry after `MWMO`. 20 of the 43 shipped
//! maps are built this way (every WMO dungeon: the Blackrock pair, Gnomeregan, Dire Maul, the
//! Stockade…); on those maps `MAIN` is entirely empty and this placement *is* the map
//! (decision 0688).
//!
//! The coord helpers map a world `(x, y)` to its `(tile_x, tile_y)` and back, on the 64-tile,
//! 533⅓-yd grid.
//!
//! Proven against `wow-wdt` over a real map's WDT during the decision-0021 migration (oracle test in
//! git history); the `benilla-formats` terrain tests pin tile selection + world coords end-to-end.
use std::io::{Read, Seek, SeekFrom};
/// WoW client generation. We only target Classic (1.12.1); kept as an enum so the reader signature
/// matches the call sites.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WowVersion {
Classic,
}
/// Re-export so `benilla_wdt::version::WowVersion` resolves (mirrors the original crate's module path).
pub mod version {
pub use super::WowVersion;
}
/// Tiles per map edge (64×64).
const MAP_SIZE: usize = 64;
/// Yards per ADT tile (1/64 of the map edge).
const TILE_YARDS: f32 = 533.333_3;
/// World `(x, y)` → ADT tile `(tile_x, tile_y)`. WoW's axes are swapped vs the tile grid: `tile_x`
/// comes from world **y**, `tile_y` from world **x**. Clamped to `0..=63`.
pub fn world_to_tile(world_x: f32, world_y: f32) -> (u32, u32) {
let offset = 32.0 * TILE_YARDS;
let tile_x = ((offset - world_y) / TILE_YARDS) as u32;
let tile_y = ((offset - world_x) / TILE_YARDS) as u32;
(tile_x.min(63), tile_y.min(63))
}
/// Chunks per tile edge (16×16 per ADT) and per map edge (1024).
const CHUNKS_PER_TILE: u32 = 16;
/// Yards per terrain chunk (1/16 of a tile, 33⅓ yd).
const CHUNK_YARDS: f32 = TILE_YARDS / CHUNKS_PER_TILE as f32;
/// World `(x, y)` → the **chunk** index `(chunk_x, chunk_y)` on the 1024×1024 chunk grid — the same
/// axis swap as [`world_to_tile`], and `chunk >> 4` is exactly that tile. Clamped to `0..=1023`.
///
/// This is the reference's own streaming coordinate: `0x672730` computes
/// `fistp((17066.666 − pos) × 0.03 − 0.5)` per axis — the containing chunk — and sizes its
/// residency windows in these units (wow-re `terrain.md`, "Streaming residency").
pub fn world_to_chunk(world_x: f32, world_y: f32) -> (u32, u32) {
let offset = 32.0 * TILE_YARDS;
let max = MAP_SIZE as u32 * CHUNKS_PER_TILE - 1;
let chunk_x = ((offset - world_y) / CHUNK_YARDS) as u32;
let chunk_y = ((offset - world_x) / CHUNK_YARDS) as u32;
(chunk_x.min(max), chunk_y.min(max))
}
/// ADT tile `(tile_x, tile_y)` → the world `(x, y)` of its max-corner origin (inverse of
/// [`world_to_tile`]).
pub fn tile_to_world(tile_x: u32, tile_y: u32) -> (f32, f32) {
let offset = 32.0 * TILE_YARDS;
(
offset - tile_y as f32 * TILE_YARDS,
offset - tile_x as f32 * TILE_YARDS,
)
}
/// Info about one map tile (the subset the streamer reads).
#[derive(Debug, Clone, Copy)]
pub struct TileInfo {
pub x: usize,
pub y: usize,
/// Whether this tile has an `.adt` (MAIN flag bit 0).
pub has_adt: bool,
}
/// The single building that **is** a WMO-only map — the `MWMO` path plus the one `MODF` entry that
/// follows it (decision 0688). Position/rotation are already in the same convention as an ADT's own
/// MODF placements (`benilla_formats::WmoInstance`), so the streamer places it through the exact
/// path it places any other building with.
#[derive(Debug, Clone)]
pub struct GlobalWmo {
/// `MWMO` root path (e.g. `world\wmo\dungeon\az_stormwindprisons\stormwindjail.wmo`).
pub model: String,
/// Position in WoW world coords (X north, Y west, Z up).
///
/// Taken **raw** from the MODF, without the `32·533⅓ − v` corner-origin remap an ADT's
/// placements get: this entry is authored in world coords already. Every shipped WMO-only map
/// stores `(0, 0, 0)` here, and the remap would put the whole dungeon 24 km from its own
/// entrance — falsified against all 26 of the server's real entry points (decision 0688).
pub position: [f32; 3],
/// Euler rotation in **degrees** (X, Y, Z); Y is the heading about the up axis. Live data, not
/// always identity: Dire Maul is authored at `ry = 180°` while the other 19 maps sit at 0.
pub rotation: [f32; 3],
/// MODF doodad-set index — which *additional* prop set to show beyond the always-on set 0.
pub doodad_set: u16,
/// MODF name-set index — the placement's `WMOAreaTable.NameSetID` naming/audio variant.
pub name_set: u16,
}
/// A parsed WDT: the 64×64 tile-existence grid, plus the global WMO on a WMO-only map.
#[derive(Debug)]
pub struct WdtFile {
/// `has_adt` per tile, indexed `y * 64 + x` (the on-disk MAIN order).
has_adt: Vec<bool>,
/// The whole map, on a map with no terrain; `None` on an ADT map.
global_wmo: Option<GlobalWmo>,
}
impl WdtFile {
/// Tile info at `(x, y)`, or `None` out of range.
pub fn get_tile(&self, x: usize, y: usize) -> Option<TileInfo> {
if x >= MAP_SIZE || y >= MAP_SIZE {
return None;
}
Some(TileInfo {
x,
y,
has_adt: self.has_adt[y * MAP_SIZE + x],
})
}
/// The map's single global building, if this is a WMO-only map (`MPHD` bit 0) — the entire
/// world on such a map. `None` on an ADT map, where the ground comes from the tiles instead.
pub fn global_wmo(&self) -> Option<&GlobalWmo> {
self.global_wmo.as_ref()
}
}
/// Reads a [`WdtFile`] from a chunked WDT stream.
pub struct WdtReader<R> {
reader: R,
_version: WowVersion,
}
impl<R: Read + Seek> WdtReader<R> {
pub fn new(reader: R, version: WowVersion) -> Self {
Self {
reader,
_version: version,
}
}
/// One chunk's `size`-byte payload — refused before the buffer is sized when the stream
/// (ending at `stream_end`) cannot hold it, so a raw size never reaches the allocator
/// unchecked.
fn read_payload(&mut self, size: u64, stream_end: u64) -> std::io::Result<Vec<u8>> {
let remaining = stream_end.saturating_sub(self.reader.stream_position()?);
if size > remaining {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"WDT chunk runs past the end of the stream",
));
}
let mut buf = vec![0u8; size as usize];
self.reader.read_exact(&mut buf)?;
Ok(buf)
}
/// Parse the WDT, returning the tile grid and (on a WMO-only map) the global WMO placement.
/// Walks chunks until EOF; everything but `MPHD`/`MAIN`/`MWMO`/`MODF` is skipped.
pub fn read(&mut self) -> std::io::Result<WdtFile> {
// Chunk sizes are raw on-disk u32s: each payload buffer below is bounded by what the
// stream still holds (`read_payload`), so a corrupt size fails as the short read it is
// instead of asking the allocator for up to 4 GiB first.
let start = self.reader.stream_position()?;
let stream_end = self.reader.seek(SeekFrom::End(0))?;
self.reader.seek(SeekFrom::Start(start))?;
let mut has_adt: Option<Vec<bool>> = None;
let mut wmo_only = false;
let mut wmo_path: Option<String> = None;
let mut modf: Option<Vec<u8>> = None;
loop {
let mut hdr = [0u8; 8];
if !read_full(&mut self.reader, &mut hdr)? {
break; // clean EOF
}
// Chunk magics are stored reversed on disk; MAIN ⇒ "NIAM".
let size = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as u64;
match &hdr[0..4] {
// MPHD — only dword0 bit 0 is ever read: "this map has no terrain, its world is
// the global WMO below" (the reference reads exactly this one bit, @0x694810).
b"DHPM" if size >= 4 => {
let buf = self.read_payload(size, stream_end)?;
wmo_only = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) & 0x1 != 0;
}
b"NIAM" => {
// 64×64 entries × 8 bytes (flags u32, area_id u32); bit 0 of flags = has_adt.
let count = MAP_SIZE * MAP_SIZE;
let mut buf = vec![0u8; count * 8];
self.reader.read_exact(&mut buf)?;
let grid = (0..count)
.map(|i| {
let flags = u32::from_le_bytes([
buf[i * 8],
buf[i * 8 + 1],
buf[i * 8 + 2],
buf[i * 8 + 3],
]);
flags & 0x1 != 0
})
.collect();
has_adt = Some(grid);
}
// MWMO — one NUL-terminated path on a WMO-only map, and a zero-length stub on an
// ADT map (which is why the `wmo_only` flag, not this chunk, is the gate).
b"OMWM" if size > 0 => {
let buf = self.read_payload(size, stream_end)?;
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let path = String::from_utf8_lossy(&buf[..end]).into_owned();
if !path.is_empty() {
wmo_path = Some(path);
}
}
// MODF — exactly one 64-byte placement here (the ADT variant carries many).
b"FDOM" if size >= 64 => {
modf = Some(self.read_payload(size, stream_end)?);
}
_ => {
self.reader.seek(SeekFrom::Current(size as i64))?;
}
}
}
let has_adt = has_adt.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "WDT missing MAIN chunk")
})?;
// A WMO-only map with no placement to show would be an empty void, so demand both parts
// before claiming one — a malformed header bit alone must not suppress the tile grid.
let global_wmo = match (wmo_only, wmo_path, modf) {
(true, Some(model), Some(e)) => Some(GlobalWmo {
model,
position: [f32_at(&e, 8), f32_at(&e, 12), f32_at(&e, 16)],
rotation: [f32_at(&e, 20), f32_at(&e, 24), f32_at(&e, 28)],
// 0x20..0x38 is the bounding box; the placement words follow it. (`uniqueId` at
// +4 is dead here — the reference overwrites it from its own counter @0xc9a320.)
doodad_set: u16_at(&e, 58),
name_set: u16_at(&e, 60),
}),
_ => None,
};
Ok(WdtFile {
has_adt,
global_wmo,
})
}
}
/// Little-endian `f32` at `off` (0.0 past the end — callers have already length-checked the entry).
fn f32_at(b: &[u8], off: usize) -> f32 {
b.get(off..off + 4)
.and_then(|s| s.try_into().ok())
.map_or(0.0, f32::from_le_bytes)
}
/// Little-endian `u16` at `off` (0 past the end).
fn u16_at(b: &[u8], off: usize) -> u16 {
b.get(off..off + 2)
.and_then(|s| s.try_into().ok())
.map_or(0, u16::from_le_bytes)
}
/// Read exactly `buf.len()` bytes; `Ok(false)` on a clean EOF before any byte, `Ok(true)` on success.
fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> std::io::Result<bool> {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => {
return if filled == 0 {
Ok(false)
} else {
Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"truncated WDT chunk header",
))
}
}
Ok(n) => filled += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
// Mirror of the crate-private grid constants, so the tests read the geometry independently of
// the values the code under test happens to use.
const T: f32 = 533.333_3;
const OFFSET: f32 = 32.0 * T;
/// Build a minimal on-disk WDT: an ignored `MVER`, an ignored `MPHD`, then a `MAIN` whose flag
/// bit 0 is set exactly for the `set_tiles` `(x, y)` list. A trailing unknown chunk exercises
/// the seek-past-unknown path *after* `MAIN` too.
fn synth_wdt(set_tiles: &[(usize, usize)], trailing: bool) -> Vec<u8> {
let mut out = Vec::new();
let chunk = |magic: &[u8; 4], payload: &[u8], out: &mut Vec<u8>| {
out.extend_from_slice(magic);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
};
// MVER (magic reversed on disk) — version 18, ignored by the reader.
chunk(b"REVM", &18u32.to_le_bytes(), &mut out);
// MPHD — 32 bytes of header we don't read.
chunk(b"DHPM", &[0u8; 32], &mut out);
// MAIN — 64×64 × 8 bytes; set bit 0 of `flags` for the requested tiles.
let count = MAP_SIZE * MAP_SIZE;
let mut main = vec![0u8; count * 8];
for &(x, y) in set_tiles {
let flags_off = (y * MAP_SIZE + x) * 8;
main[flags_off] = 0x1; // low byte of the u32 flags carries bit 0
}
chunk(b"NIAM", &main, &mut out);
if trailing {
chunk(b"FOOB", &[0xAB; 16], &mut out);
}
out
}
fn parse(bytes: Vec<u8>) -> std::io::Result<WdtFile> {
WdtReader::new(Cursor::new(bytes), WowVersion::Classic).read()
}
/// A WMO-only map's on-disk shape: `MPHD` bit 0, an all-empty `MAIN`, then `MWMO` + one 64-byte
/// `MODF`. Field values are distinct primes-ish so a column slip can't pass.
fn synth_wmo_only_wdt(mphd_bit0: bool, with_modf: bool) -> Vec<u8> {
let mut out = Vec::new();
let chunk = |magic: &[u8; 4], payload: &[u8], out: &mut Vec<u8>| {
out.extend_from_slice(magic);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
};
chunk(b"REVM", &18u32.to_le_bytes(), &mut out);
let mut mphd = [0u8; 32];
mphd[0] = u8::from(mphd_bit0);
chunk(b"DHPM", &mphd, &mut out);
chunk(b"NIAM", &vec![0u8; MAP_SIZE * MAP_SIZE * 8], &mut out); // no tiles at all
chunk(b"OMWM", b"world\\wmo\\dungeon\\test\\t.wmo\0", &mut out);
if with_modf {
let mut e = [0u8; 64];
e[0..4].copy_from_slice(&0u32.to_le_bytes()); // nameId
e[4..8].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // uniqueId (dead)
for (i, v) in [11.0f32, 22.0, 33.0].iter().enumerate() {
e[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); // position
}
for (i, v) in [1.0f32, 180.0, 2.0].iter().enumerate() {
e[20 + i * 4..24 + i * 4].copy_from_slice(&v.to_le_bytes()); // rotation
}
// 0x20..0x38 bounding box left zero — we don't read it.
e[56..58].copy_from_slice(&7u16.to_le_bytes()); // flags (dead)
e[58..60].copy_from_slice(&3u16.to_le_bytes()); // doodadSet
e[60..62].copy_from_slice(&5u16.to_le_bytes()); // nameSet
chunk(b"FDOM", &e, &mut out);
}
out
}
/// The WMO-only branch end to end: the `MPHD` gate, the `MWMO` path, and every `MODF` column
/// the streamer places the building with. Position is read **raw** — no corner-origin remap.
#[test]
fn reads_the_global_wmo_of_a_wmo_only_map() {
let wdt = parse(synth_wmo_only_wdt(true, true)).expect("parses");
let g = wdt.global_wmo().expect("MPHD bit 0 ⇒ a global WMO");
assert_eq!(g.model, "world\\wmo\\dungeon\\test\\t.wmo");
assert_eq!(g.position, [11.0, 22.0, 33.0]);
assert_eq!(g.rotation, [1.0, 180.0, 2.0]);
assert_eq!((g.doodad_set, g.name_set), (3, 5));
// Such a map authors no tiles — the grid still parses, it's just empty.
assert!(!wdt.get_tile(32, 32).expect("in range").has_adt);
}
/// An ADT map carries a zero-length `MWMO` stub and no `MODF`; it must not claim a global WMO
/// (which would spawn a phantom building over real terrain).
#[test]
fn an_adt_map_has_no_global_wmo() {
let wdt = parse(synth_wdt(&[(30, 30)], false)).expect("parses");
assert!(wdt.global_wmo().is_none());
// …and neither does a malformed file that sets the bit but ships no placement: a stray
// header bit must degrade to "no global WMO", never to a half-built one.
let half = parse(synth_wmo_only_wdt(true, false)).expect("parses");
assert!(half.global_wmo().is_none());
// …nor one that ships the placement without the flag.
let unflagged = parse(synth_wmo_only_wdt(false, true)).expect("parses");
assert!(unflagged.global_wmo().is_none());
}
/// The chunk grid nests in the tile grid: `chunk >> 4` is the tile `world_to_tile` names, at
/// every chunk centre of a whole tile and at the map's far clamp.
#[test]
fn the_chunk_index_nests_in_the_tile_index() {
let (ox, oy) = tile_to_world(32, 44);
for cx in 0..16u32 {
for cy in 0..16u32 {
// Chunk (cx, cy) of tile (32, 44): tile_x runs along world y, tile_y along world x.
let wy = oy - (cx as f32 + 0.5) * CHUNK_YARDS;
let wx = ox - (cy as f32 + 0.5) * CHUNK_YARDS;
let (chx, chy) = world_to_chunk(wx, wy);
assert_eq!((chx >> 4, chy >> 4), world_to_tile(wx, wy));
assert_eq!((chx, chy), (32 * 16 + cx, 44 * 16 + cy));
}
}
assert_eq!(world_to_chunk(-1.0e6, -1.0e6), (1023, 1023));
assert_eq!(world_to_chunk(1.0e6, 1.0e6), (0, 0));
}
#[test]
fn world_origin_is_map_center_tile() {
// World (0, 0) sits at the 32,32 tile boundary — the documented map center.
assert_eq!(world_to_tile(0.0, 0.0), (32, 32));
}
#[test]
fn tile_center_round_trips_exactly() {
// Sample the *center* of each tile (corner minus half a tile on both axes); a half-tile
// margin absorbs f32 rounding so truncation can't spill into a neighbour.
for &(tx, ty) in &[(0, 0), (1, 10), (20, 31), (33, 33), (50, 62), (63, 63)] {
let (cx, cy) = tile_to_world(tx, ty);
let center = (cx - 0.5 * T, cy - 0.5 * T);
assert_eq!(
world_to_tile(center.0, center.1),
(tx, ty),
"tile ({tx},{ty}) center did not round-trip"
);
}
}
#[test]
fn tile_to_world_matches_grid_formula() {
// Max-corner origin: world_x from tile_y, world_y from tile_x (the axis swap).
assert_eq!(tile_to_world(0, 0), (OFFSET, OFFSET));
let (wx, wy) = tile_to_world(1, 2);
assert!((wx - (OFFSET - 2.0 * T)).abs() < 0.01);
assert!((wy - (OFFSET - 1.0 * T)).abs() < 0.01);
}
#[test]
fn world_to_tile_clamps_both_extremes() {
// Far past the max corner saturates low; far past the min corner clamps to the last tile.
assert_eq!(world_to_tile(1.0e9, 1.0e9), (0, 0));
assert_eq!(world_to_tile(-1.0e9, -1.0e9), (63, 63));
}
#[test]
fn reads_main_grid_and_flags() {
let wdt = parse(synth_wdt(&[(3, 5), (63, 0), (0, 63)], false)).unwrap();
assert!(wdt.get_tile(3, 5).unwrap().has_adt);
assert!(wdt.get_tile(63, 0).unwrap().has_adt);
assert!(wdt.get_tile(0, 63).unwrap().has_adt);
// An untouched tile reads empty, and the returned coords echo the query.
let empty = wdt.get_tile(10, 10).unwrap();
assert!(!empty.has_adt);
assert_eq!((empty.x, empty.y), (10, 10));
}
#[test]
fn skips_unknown_chunks_before_and_after_main() {
// MVER/MPHD precede MAIN and a bogus chunk follows it; all must be seek-skipped cleanly.
let wdt = parse(synth_wdt(&[(1, 1)], true)).unwrap();
assert!(wdt.get_tile(1, 1).unwrap().has_adt);
}
#[test]
fn get_tile_out_of_range_is_none() {
let wdt = parse(synth_wdt(&[], false)).unwrap();
assert!(wdt.get_tile(64, 0).is_none());
assert!(wdt.get_tile(0, 64).is_none());
assert!(wdt.get_tile(usize::MAX, usize::MAX).is_none());
}
#[test]
fn missing_main_chunk_is_an_error() {
// Only MVER present — no MAIN ⇒ InvalidData.
let mut only_mver = Vec::new();
only_mver.extend_from_slice(b"REVM");
only_mver.extend_from_slice(&4u32.to_le_bytes());
only_mver.extend_from_slice(&18u32.to_le_bytes());
let err = parse(only_mver).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn empty_stream_has_no_main() {
// A clean EOF at the very first header is not a truncation error, but still no MAIN.
let err = parse(Vec::new()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn truncated_chunk_header_is_unexpected_eof() {
// A stray 3-byte tail cannot form an 8-byte chunk header.
let err = parse(vec![b'N', b'I', b'A']).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
/// A chunk claiming more bytes than the stream holds is refused before its buffer is sized:
/// the `MPHD`/`MWMO`/`MODF` reads used to `vec![0; size]` off the raw u32 first (a 4 GiB
/// commit a lazy allocator hides and Windows aborts on) and only then hit EOF. The message
/// pins that it is the guard, not the read after it, that refuses.
#[test]
fn a_chunk_size_past_the_end_of_stream_is_refused_before_allocating() {
for magic in [b"DHPM", b"OMWM", b"FDOM"] {
let mut bytes = Vec::new();
bytes.extend_from_slice(magic);
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
bytes.extend_from_slice(&[0u8; 64]); // some payload, nowhere near the claim
let err = parse(bytes).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
assert_eq!(err.to_string(), "WDT chunk runs past the end of the stream");
}
}
}