Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

crustywad is a safe, documented Rust library for reading Doom WAD files. A WAD (“Where’s All the Data?”) is the container format that id Software’s Doom engine uses to store maps, graphics, audio, and other game assets.

What crustywad provides

  • Parse WAD headers and lump directories from bytes or files.
  • Look up lumps by index or name and access their raw bytes.
  • Decode typed map-record lumps (Thing, Linedef, Sidedef, Vertex, Seg, Subsector, Node, Sector).
  • Choose between strict parsing (fail fast on bad data) and lenient parsing (best-effort recovery with collected warnings).
  • Optional memory-mapped loading for large WADs via the mmap feature flag.
  • A small cwad CLI binary for dogfooding and quick inspection.

When to use it

Use crustywad when you need to:

  • Extract lump data from IWAD or PWAD files for further processing.
  • Build Doom map editors, converters, or analysis tools in Rust.
  • Inspect WAD structure programmatically without a full game engine.

WAD format overview

Every WAD starts with a 12-byte header:

FieldSizeDescription
Magic4 bytesIWAD (game data) or PWAD (patch)
numlumps4 bytes (i32 LE)Number of lump directory entries
infotableofs4 bytes (i32 LE)Byte offset of the lump directory

The lump directory follows the lump data. Each directory entry is 16 bytes:

FieldSizeDescription
filepos4 bytes (i32 LE)Byte offset of lump data
size4 bytes (i32 LE)Byte length of lump data
name8 bytesNUL-padded ASCII name

See the Doom Wiki for the full unofficial spec.

Next steps

Start with Getting Started to add crustywad to your project and parse your first WAD file.

Getting Started

Adding crustywad to your project

Add the crate to your Cargo.toml:

[dependencies]
crustywad = "0.9.0"

Enable optional features as needed:

[dependencies]
crustywad = { version = "0.9.0", features = ["mmap"] }

Basic usage

Parse a WAD from an in-memory byte slice:

#![allow(unused)]
fn main() {
use crustywad::Wad;

// Minimal valid IWAD with zero lumps.
let bytes: &[u8] = &[
    b'I', b'W', b'A', b'D',  // magic
    0, 0, 0, 0,               // numlumps = 0
    12, 0, 0, 0,              // infotableofs = 12
];

let wad = Wad::from_bytes(bytes)?;
println!("kind:  {:?}", wad.kind());
println!("lumps: {}", wad.lump_count());
Ok::<(), crustywad::ParseError>(())
}

Loading from a file

#![allow(unused)]
fn main() {
use crustywad::Wad;

let wad = Wad::from_path("doom.wad")?;
println!("{} lumps", wad.lump_count());
Ok::<(), crustywad::ParseError>(())
}

Handling errors

All parse functions return Result<Wad, ParseError>. The ParseError type covers I/O failures, invalid magic bytes, negative field values, out-of-bounds directory offsets, and non-ASCII lump names. See crustywad::ParseError in the API docs for the full variant list.

What’s next?

Reading WAD Files

Constructors

Wad provides several constructors depending on your input source:

ConstructorSourceNotes
Wad::from_bytes(bytes)Vec<u8> / &[u8] / byte arrayNo file I/O
Wad::from_bytes_with_options(bytes, opts)Same, with custom options
Wad::from_path(path)File pathReads file into heap
Wad::from_path_with_options(path, opts)File path + options
Wad::from_path_mapped(path)File pathMemory-mapped; requires mmap feature
Wad::from_path_mapped_with_options(path, opts)File path + optionsRequires mmap feature

Accessing lumps

#![allow(unused)]
fn main() {
use crustywad::Wad;

// Build a minimal IWAD with one lump for illustration.
let mut bytes = Vec::new();
bytes.extend_from_slice(b"IWAD");
bytes.extend_from_slice(&1_i32.to_le_bytes());   // numlumps = 1
bytes.extend_from_slice(&16_i32.to_le_bytes());  // infotableofs = 16
bytes.extend_from_slice(&[1, 2, 3, 4]);           // lump data at offset 12
bytes.extend_from_slice(&12_i32.to_le_bytes());  // directory: filepos = 12
bytes.extend_from_slice(&4_i32.to_le_bytes());   // directory: size = 4
bytes.extend_from_slice(b"TEST\0\0\0\0");         // directory: name

let wad = Wad::from_bytes(bytes)?;

// By index.
if let Some(lump) = wad.lump(0) {
    println!("lump[0]: {} ({} bytes at {})", lump.name(), lump.size(), lump.filepos());
}

// By name.
if let Some(lump) = wad.lump_by_name("TEST") {
    let data: &[u8] = wad.lump_data(lump);
    println!("TEST lump is {} bytes", data.len());
}

// Iterate all lumps.
for lump in wad.lumps() {
    println!("{}: {} bytes", lump.name(), lump.size());
}

// Raw bytes by index (returns None if index is out of range).
if let Some(bytes) = wad.lump_bytes(0) {
    println!("raw bytes: {:?}", bytes);
}
Ok::<(), crustywad::ParseError>(())
}

Strict vs. lenient parsing

By default Wad::from_bytes and Wad::from_path use strict mode: the first validation error stops parsing and returns a ParseError.

Use lenient mode when you want best-effort recovery from malformed files:

#![allow(unused)]
fn main() {
use crustywad::{ParseOptions, Wad};

let options = ParseOptions::lenient();
let result = Wad::from_path_with_options("questionable.wad", options)?;

// Inspect any non-fatal issues collected during parsing.
for warning in result.warnings() {
    eprintln!("warning: {warning}");
}
Ok::<(), crustywad::ParseError>(())
}

You can also use the shorthand constructors:

#![allow(unused)]
fn main() {
use crustywad::ParseOptions;

let strict  = ParseOptions::strict();   // same as default
let lenient = ParseOptions::lenient();
}

What each mode does

ConditionStrictLenient
Invalid magic bytesParseErrorParseWarning, WadKind::Unknown
Negative numlumps or infotableofsParseErrorParseWarning, value clamped to 0
Directory extends past end-of-fileParseErrorParseWarning, truncated to available entries
Lump data out of boundsParseErrorParseWarning, range clamped
Non-ASCII lump nameParseErrorParseWarning, lossy UTF-8 decoding

WAD kind

#![allow(unused)]
fn main() {
use crustywad::{Wad, WadKind};

let mut bytes = Vec::new();
bytes.extend_from_slice(b"IWAD");
bytes.extend_from_slice(&1_i32.to_le_bytes());
bytes.extend_from_slice(&16_i32.to_le_bytes());
bytes.extend_from_slice(&[1, 2, 3, 4]);
bytes.extend_from_slice(&12_i32.to_le_bytes());
bytes.extend_from_slice(&4_i32.to_le_bytes());
bytes.extend_from_slice(b"TEST\0\0\0\0");
let wad = Wad::from_bytes(bytes)?;

match wad.kind() {
    WadKind::Iwad => println!("IWAD — base game data"),
    WadKind::Pwad => println!("PWAD — patch or add-on"),
    WadKind::Unknown(magic) => println!("Unknown magic: {:?}", magic),
}
Ok::<(), crustywad::ParseError>(())
}

Writing WAD Files

WadBuilder, behind the write feature flag, builds a new WAD from scratch or re-serializes an existing one.

crustywad = { version = "0.9.0", features = ["write"] }

Building from scratch

#![allow(unused)]
fn main() {
use crustywad::{WadBuilder, WadKind};

let bytes = WadBuilder::new(WadKind::Pwad)
    .add_lump("MAP01", b"")
    .add_lump("TEST", vec![1, 2, 3, 4])
    .build()
    .unwrap();

assert!(crustywad::Wad::from_bytes(bytes).is_ok());
}

Lumps are added in order with add_lump(name, data). Name and size validation, along with offset (filepos, infotableofs) computation, are deferred entirely to build() / build_with_options() — callers never supply offsets directly.

Round-tripping an existing WAD

Use Wad::to_builder() to load a WAD, modify it, and re-serialize:

#![allow(unused)]
fn main() {
use crustywad::Wad;

let mut bytes = Vec::new();
bytes.extend_from_slice(b"IWAD");
bytes.extend_from_slice(&1_i32.to_le_bytes());
bytes.extend_from_slice(&16_i32.to_le_bytes());
bytes.extend_from_slice(&[1, 2, 3, 4]);
bytes.extend_from_slice(&12_i32.to_le_bytes());
bytes.extend_from_slice(&4_i32.to_le_bytes());
bytes.extend_from_slice(b"TEST\0\0\0\0");
let wad = Wad::from_bytes(bytes)?;

let mut builder = wad.to_builder();
builder.add_lump("EXTRA", b"more data");
let rebuilt = builder.build()?;

assert_eq!(Wad::from_bytes(rebuilt)?.lump_count(), 2);
Ok::<(), Box<dyn std::error::Error>>(())
}

All lump data is copied into the builder during the conversion, so memory usage roughly doubles for the duration.

Writing UDMF maps

Use write_udmf() to serialize an assembled Map into a UDMF TEXTMAP string, or add_udmf_map() to add a complete map group to a WadBuilder. Both are available with the write feature:

#![allow(unused)]
fn main() {
use crustywad::{Wad, WadBuilder, WadKind, WriteOptions};
use crustywad::map::{Map, add_udmf_map, write_udmf};

// Assemble a Map to write out (here from a small in-memory UDMF WAD).
let textmap = concat!(
    "namespace = \"doom\";\n",
    "vertex { x = 0; y = 0; }\n",
    "vertex { x = 8; y = 0; }\n",
    "linedef { v1 = 0; v2 = 1; sidefront = 0; }\n",
    "sidedef { sector = 0; }\n",
    "sector { texturefloor = \"F\"; textureceiling = \"C\"; }\n",
);
let mut src = WadBuilder::new(WadKind::Pwad);
src.add_lump("MAP01", b"");
src.add_lump("TEXTMAP", textmap.as_bytes().to_vec());
src.add_lump("ENDMAP", b"");
let wad = Wad::from_bytes(src.build()?)?;
let group = wad.map_group("MAP01").unwrap();
let map: Map = Map::assemble(&wad, &group)?;

// Serialize the map to a UDMF TEXTMAP string:
let (textmap_out, _warnings) = write_udmf(&map, &WriteOptions::strict())?;
assert!(textmap_out.starts_with("namespace"));

// Or add a complete map group (MAP01 + TEXTMAP + ENDMAP) to a builder:
let mut builder = WadBuilder::new(WadKind::Pwad);
add_udmf_map(&mut builder, "MAP01", &map, &WriteOptions::strict())?;
let bytes = builder.build()?;
assert!(!bytes.is_empty());
Ok::<(), Box<dyn std::error::Error>>(())
}

Fields are emitted only when they differ from UDMF spec defaults; float coordinates are narrowed to integer form when whole (e.g. 64.0 is written as 64). The Strict vs. lenient write validation section below covers the WriteOptions modes; see Map records for the Map graph types these APIs consume.

Strict vs. lenient write validation

build() always uses strict validation. build_with_options() takes a WriteOptions for either mode, and returns any collected WriteWarnings alongside the bytes:

#![allow(unused)]
fn main() {
use crustywad::{WadBuilder, WadKind, WriteOptions};

let (bytes, warnings) = WadBuilder::new(WadKind::Pwad)
    .add_lump("VERYLONGNAME", b"data")
    .build_with_options(&WriteOptions::lenient())
    .unwrap();

assert!(!warnings.is_empty()); // name was truncated to 8 bytes
assert!(crustywad::Wad::from_bytes(bytes).is_ok());
}
ConditionStrictLenient
Lump name longer than 8 bytesWriteError::NameTooLongWriteWarning::NameTruncated, truncated to 8 bytes
Name contains a NUL byteWriteError::NulInNameSame (both modes)
Non-ASCII nameWriteError::NonAsciiNameSame (both modes)
WadKind::Unknown magicWriteError::UnknownMagicStrictWriteWarning::UnknownMagic, written unchanged
Lump data larger than i32::MAX bytesWriteError::LumpTooLargeSame (both modes)
Lump count exceeds i32::MAXWriteError::TooManyLumpsSame (both modes)
Computed offset exceeds i32::MAXWriteError::OffsetOverflowSame (both modes)

Error handling

build() returns Result<Vec<u8>, WriteError>. build_with_options() returns Result<(Vec<u8>, Vec<WriteWarning>), WriteError> — the warnings vector is only ever non-empty in lenient mode.

See Data flow for the write pipeline flowchart and the strict/lenient write mode comparison, and Data model for how WadBuilder and its supporting types relate to Wad.

Runnable example

crates/crustywad/examples/write_wad.rs runs the scenarios above end to end:

cargo run -p crustywad --example write_wad --features write

Converting maps

crustywad::map can convert an assembled Map between the UDMF text format and the classic Doom binary format, in both directions. Both directions are behind the write feature:

crustywad = { version = "0.9.0", features = ["write"] }

Conversion is read → Map → write: there is no direct format-to-format path. A UDMF field that the Map graph does not model is already lost at read time (see Map Record Parsing); conversion only polices loss that is visible in the graph. See ADR-0019 for the full decision record this page summarizes.

Doom → UDMF

write_udmf() and add_udmf_map() (covered in Writing WAD Files) accept a Map assembled from any source format, including a classic Doom map:

#![allow(unused)]
fn main() {
use crustywad::{Wad, WadBuilder, WadKind, WriteOptions};
use crustywad::map::{Map, add_udmf_map, write_udmf};

// A minimal classic Doom map: one linedef, one sector, one thing.
let vertexes = [0i16, 0, 64, 0].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>();
let mut linedefs = Vec::new();
linedefs.extend_from_slice(&0u16.to_le_bytes());
linedefs.extend_from_slice(&1u16.to_le_bytes());
linedefs.extend_from_slice(&1u16.to_le_bytes());
linedefs.extend_from_slice(&0u16.to_le_bytes());
linedefs.extend_from_slice(&0u16.to_le_bytes());
linedefs.extend_from_slice(&0u16.to_le_bytes());
linedefs.extend_from_slice(&0xffffu16.to_le_bytes());
let mut sidedefs = Vec::new();
sidedefs.extend_from_slice(&0i16.to_le_bytes());
sidedefs.extend_from_slice(&0i16.to_le_bytes());
sidedefs.extend_from_slice(b"-\0\0\0\0\0\0\0");
sidedefs.extend_from_slice(b"-\0\0\0\0\0\0\0");
sidedefs.extend_from_slice(b"STARTAN3");
sidedefs.extend_from_slice(&0u16.to_le_bytes());
let mut sectors = Vec::new();
sectors.extend_from_slice(&0i16.to_le_bytes());
sectors.extend_from_slice(&128i16.to_le_bytes());
sectors.extend_from_slice(b"FLOOR4_8");
sectors.extend_from_slice(b"CEIL3_5\0");
sectors.extend_from_slice(&160i16.to_le_bytes());
sectors.extend_from_slice(&0i16.to_le_bytes());
sectors.extend_from_slice(&0i16.to_le_bytes());
let things = vec![0u8; 10];
let mut src = WadBuilder::new(WadKind::Pwad);
src.add_lump("MAP01", b"");
src.add_lump("THINGS", things);
src.add_lump("LINEDEFS", linedefs);
src.add_lump("SIDEDEFS", sidedefs);
src.add_lump("VERTEXES", vertexes);
src.add_lump("SECTORS", sectors);
let wad = Wad::from_bytes(src.build()?)?;
let group = wad.map_group("MAP01").unwrap();
let map: Map = Map::assemble(&wad, &group)?;

let (textmap, _warnings) = write_udmf(&map, &WriteOptions::strict())?;
assert!(textmap.starts_with("namespace"));

let mut builder = WadBuilder::new(WadKind::Pwad);
add_udmf_map(&mut builder, "MAP01", &map, &WriteOptions::strict())?;
let bytes = builder.build()?;
assert!(!bytes.is_empty());
Ok::<(), Box<dyn std::error::Error>>(())
}

UDMF → Doom

write_doom_map() serializes an assembled Map into the five classic Doom map data lumps (THINGS, LINEDEFS, SIDEDEFS, VERTEXES, SECTORS); add_doom_map() adds a complete map group to a WadBuilder. Both are available with the write feature:

#![allow(unused)]
fn main() {
use crustywad::{Wad, WadBuilder, WadKind, WriteOptions};
use crustywad::map::{Map, add_doom_map, write_doom_map};

let textmap = concat!(
    "namespace = \"doom\";\n",
    "vertex { x = 0; y = 0; }\n",
    "vertex { x = 64; y = 0; }\n",
    "sector { texturefloor = \"FLOOR4_8\"; textureceiling = \"CEIL3_5\"; }\n",
    "sidedef { sector = 0; }\n",
    "linedef { v1 = 0; v2 = 1; sidefront = 0; }\n",
    "thing { x = 32; y = 32; type = 1; skill1 = true; skill2 = true; skill3 = true; }\n",
);
let mut src = WadBuilder::new(WadKind::Pwad);
src.add_lump("MAP01", b"");
src.add_lump("TEXTMAP", textmap.as_bytes().to_vec());
src.add_lump("ENDMAP", b"");
let wad = Wad::from_bytes(src.build()?)?;
let group = wad.map_group("MAP01").unwrap();
let map: Map = Map::assemble(&wad, &group)?;

// Serialize to the five Doom binary map lumps:
let (lumps, warnings) = write_doom_map(&map, &WriteOptions::strict())?;
assert!(!lumps.vertexes.is_empty());
// Nodes are never built (see below): this warning is always present.
assert!(warnings.contains(&crustywad::map::DoomWriteWarning::NodesNotBuilt));

// Or add a complete map group to a builder:
let mut builder = WadBuilder::new(WadKind::Pwad);
add_doom_map(&mut builder, "MAP01", &map, &WriteOptions::strict())?;
let bytes = builder.build()?;
assert!(!bytes.is_empty());
Ok::<(), Box<dyn std::error::Error>>(())
}

add_doom_map output is not engine-playable on vanilla ports. add_doom_map writes zero-length SEGS, SSECTORS, NODES, REJECT, and BLOCKMAP lumps — the canonical Doom lump run editors and nodebuilders expect to find, but with no node data in them. Every call returns DoomWriteWarning::NodesNotBuilt, in both strictness modes: it is a property of the output, not a defect strictness can fix. The ZDoom family rebuilds those lumps at load, but vanilla and Chocolate Doom need real ones. To get an engine-playable map, either build the node lumps in-crate with the nodebuild feature — the add_doom_map_with_nodes one-shot, or the build_nodes / build_blockmap / build_reject builders — or run an external nodebuilder (zdbsp, bsp, …) over the output. From the CLI, cwad convert --to doom --nodes is the turnkey path, and --node-format selects the on-disk node encoding — classic (the 16-bit default), the non-GL xnod/znod streams, the GL xgln/xgl2/xgl3 streams, or gl to auto-select the minimal GL dialect (z* values need cwad built with extended-nodes-zlib, the default). See Building nodes for the full picture, including the tolerated mixed-sector fan.

Round-tripping: not symmetric

Doom → UDMF → Doom is a byte-identical round-trip for VERTEXES, LINEDEFS, SIDEDEFS, and SECTORS, and for THINGS within an envelope. UDMF → Doom → UDMF is not reversible. No option, flag, or mode makes it reversible.

Doom → UDMF → Doom reproduces the four geometry lumps exactly, and THINGS too, provided the map stays inside the envelope where UDMF has a representation for every Doom bit:

  • Linedef flag bits 0–8 (the nine standard bits) round-trip; a bit ≥ 9 (e.g. Boom’s passuse, 0x200) has no UDMF boolean and is dropped.
  • Thing flag bits 0–7 (skill 1–5, ambush, multiplayer-only, and the Boom/MBF dm/co-op/friend bits) round-trip; a bit ≥ 8 has no UDMF boolean and is dropped.
  • A thing angle in 0..360 round-trips exactly; an angle ≥ 360 comes back as angle % 360. This is a semantic no-op, not data loss: Doom’s P_SpawnMapThing computes the spawn facing as ANG45 * (angle / 45) with integer division, so 360 and 0 produce the identical facing. This case is not hypothetical — 226 things across 10 Freedoom maps store a literal angle = 360.

UDMF → Doom → UDMF is one-way. Converting a UDMF map to Doom and back does not reproduce the original UDMF map: f64 coordinates are rounded to i16 map units, and fields Doom has no slot for (tier 3 below) are dropped permanently. For a lossless UDMF → UDMF round-trip — preserving comment fields, user_* fields, and unmodeled port fields (lexical // and /* */ comments are trivia and do not survive) — keep the parsed UdmfMap intermediate and re-emit it with UdmfMap::to_textmap (also behind the write feature) instead of round-tripping through Map (ADR-0027).

Strict vs. lenient conversion

write_doom_map() / add_doom_map() share the crate’s usual WriteOptions strict/lenient contract. Strict mode refuses any data loss — a typical ZDoom-namespace UDMF map, with linedef args or thing height/id/special set, will fail strict conversion to Doom, naming the first offending field. This is the intended design: write_doom_map(&map, &WriteOptions::strict()) returning Ok is exactly the answer to “does this map fit in the Doom format?” WriteOptions::lenient() is the single-flag acknowledgment that the loss is acceptable — it recovers a best-effort value for every lossy field and reports each recovery as a DoomWriteWarning.

The Doom binary format is strictly narrower than the Map graph, so narrowing it loses data in three tiers (from ADR-0019):

Tier 1 — structurally impossible: errors in both modes

Doom’s u16 indices cannot address an arena beyond their range; there is no honest recovery, so this errors in both strictness modes.

ArenaMaximumWhy
vertices65,536indices 0..=65,535
sectors65,536indices 0..=65,535
sidedefs65,5350xffff is the “no sidedef” sentinel

Reported as DoomWriteError::TooManyElements { kind, count, max }.

Tier 2 — value loss: strict errors, lenient recovers and warns

LossLenient recovery
Fractional f64 coordinate (vertex x/y, thing x/y)round to nearest i16 (half away from zero)
Coordinate outside i16 rangeclamp to i16::MIN/i16::MAX
Linedef special outside u16; args[0] (the sector tag) outside u16clamp
Sidedef x_offset / y_offset outside i16clamp
Sector floor_height / ceiling_height / light / special / tag outside i16clamp
Thing or linedef flags with any bit above 15 settruncate to u16 (& 0xffff)
Texture/flat name longer than 8 bytestruncate to 8 bytes
Non-finite (NaN/infinite) coordinatestrict errors, lenient substitutes 0

flags truncates rather than clamps, unlike every other integer field: a bit field is not a magnitude. Clamping 0x1_0001 to 0xffff would set all sixteen Doom flags at once (blocking, secret, two-sided, …) from one stray high bit; masking keeps the bits Doom can hold and drops only those it cannot. Lenient reports DoomWriteWarning::ValueTruncated; strict still errors.

Name fidelity has a caveat. A texture/flat name round-trips byte-for-byte only if it is valid UTF-8 and NUL-clean — valid UTF-8 up to its first NUL, with nothing but NUL padding after it. Every name in practice is ASCII, so this holds for real maps, but the exceptions are real and are not warned about:

  • Doom’s on-disk name field is a raw [u8; 8], and map::common::Name8 keeps those bytes verbatim — but the Map graph does not. MapSidedef and MapSector store String, filled on read via Name8::as_str_lossy, which trims at the first NUL and decodes with String::from_utf8_lossy.
  • A name containing invalid UTF-8 is therefore normalized on read: b"\x81OCK\0\0\0\0" becomes "\u{FFFD}OCK" in the graph and is written back as EF BF BD 4F 43 4B 00 00 — different bytes, no warning. An 8-byte all-invalid name expands to a 24-byte replacement-character string and then fails as DoomWriteError::NameTooLong in strict mode.
  • Bytes after the NUL terminator (which real IWADs do contain) are dropped on read for the same reason.

Only a name longer than 8 bytes is conversion loss; the two cases above are read-time normalization, and no WriteOptions mode changes them.

Tier 3 — no slot in the Doom format: strict errors, lenient drops and warns

A Doom linedef carries only special_type plus one sector tag; a Doom thing carries no special, no tid, and no height. A nonzero value in any of the following has nowhere to go:

  • linedef args[1..=4] (nonzero)
  • linedef id
  • thing special and args[0..=4] (nonzero)
  • thing height (nonzero)
  • thing id (the tid)

This is exactly why a ZDoom-namespace UDMF map typically fails strict conversion — “this map is not expressible in Doom format” is the correct answer, and WriteOptions::lenient() is how a caller accepts that.

Doom 64 maps

A Map assembled from a Doom 64 source converts to UDMF (and to classic Doom) like any other format, provided its texture references resolved to names at assembly time (see Doom 64 maps — this requires the outer WAD to carry a Textures section; without one, every texture field stays TextureRef::Index and conversion fails in both modes with UnresolvedTextureIndex, since the writer has no name to invent).

The one remaining unrepresentable piece is Doom 64’s per-sector colored lighting (MapSector.colors, Map::lights()): neither UDMF nor classic Doom has a slot for it, so it follows the tier-3 policy above:

  • Strict refuses with UnrepresentableField { block: "sector", field: "colors", .. }, naming the offending sector.
  • Lenient drops the colors and converts, recording one ColoredLightingDropped warning per map.
cwad convert doom64.wad -o doom64.udmf.wad --to udmf
# error: cannot convert map MAP01 to udmf: sector #0 has a colors value, which UDMF cannot represent
# note: re-run with --lenient to accept the data loss

cwad --lenient convert doom64.wad -o doom64.udmf.wad --to udmf
# converted 1 map to udmf
# warning: MAP01: the map's Doom 64 colored lighting (sector color references and lights table) has no UDMF slot and was dropped

Error handling

write_doom_map() and add_doom_map() return Result<(DoomMapLumps, Vec<DoomWriteWarning>), DoomWriteError> and Result<Vec<DoomWriteWarning>, DoomWriteError> respectively — the warnings vector always contains at least DoomWriteWarning::NodesNotBuilt, in both strictness modes. Every strict-mode DoomWriteError variant has a lenient-mode DoomWriteWarning counterpart naming the recovery it took, so the two modes read as a single decision table rather than two separate implementations. The mapping is one-to-one except for ValueOutOfRange, whose recovery depends on the field: a magnitude clamps (ValueClamped), a flags bit field truncates (ValueTruncated).

See Map Record Parsing for the Map graph types these APIs consume, and Writing WAD Files for the general WadBuilder / WriteOptions contract.

From the CLI

The cwad convert subcommand wraps this same read → Map → write path for whole WAD files, without writing any Rust. It replaces each map’s lump run with its converted form; non-map lumps and maps already in the target format pass through unchanged, in directory order:

cwad convert doom.wad -o udmf.wad --to udmf
cwad convert udmf.wad -o doom.wad --to doom --lenient

The second command needs --lenient for the same reason described above: strict mode refuses any UDMF field the Doom format cannot represent.

A converted group contains only what the target format defines — the marker plus TEXTMAP/ENDMAP, or the marker plus the classic data lumps and the empty node lumps. Pass --nodes to build real node lumps for either target — SEGS/SSECTORS/NODES/REJECT/BLOCKMAP replacing the empty placeholders for --to doom, a GL ZNODES lump (otherwise absent) for --to udmf — see Building nodes. Any other lump that lived inside the source map group (BEHAVIOR, SCRIPTS, ZNODES, DIALOGUE, GL nodes) is dropped, not passed through: compiled ACS is bound to the source map’s specials and node lumps describe the source geometry, so carrying either across would look intact while being subtly wrong. That is data loss under the same policy as any other: strict mode refuses (exit 3, naming each lump), --lenient drops them and warns. A map already in the target format is not converted, so nothing in its group is dropped. Under --to doom, such a same-format map passes through unchanged (unless --nodes is also given, which re-emits the map through the node-building one-shot — see Building nodes). Under --to udmf --nodes, it instead gets its ZNODES stream retrofitted in place: the group’s TEXTMAP bytes are re-emitted verbatim, any port lump in the group (DIALOGUE, BEHAVIOR) is preserved untouched, and a stale or corrupt existing ZNODES is replaced (or inserted right after TEXTMAP if the group has none). A per-group note reports the retrofit (is already UDMF; rebuilt ZNODES in place (map not converted)), and the retrofitted map is not counted in converted N maps — this is a patch, not a conversion. A map excluded from the run by --map passes through unchanged with no retrofit. cwad build --nodes remains the spec-based alternative for rebuilding ZNODES: it works directly from NAME=FILE lump specs rather than a whole WAD.

See CLI Usage for the full flag reference, example output, and exit codes.

Building nodes

A classic Doom map is not engine-playable from its editable lumps alone (THINGS, LINEDEFS, SIDEDEFS, VERTEXES, SECTORS). The engine also reads a run of derived lumps — SEGS, SSECTORS, NODES (the BSP tree), REJECT (sector-to-sector line-of-sight), and BLOCKMAP (the collision grid) — that a nodebuilder computes from the geometry. crustywad’s nodebuild feature is a clean-room nodebuilder for the classic 16-bit tier (ADR-0024).

This page explains when you need it, the pieces it provides, and the one-line turnkey path. For the full Rust worked example, see the nodebuild feature reference; this page links to it rather than duplicating the code.

Why the empty lumps are not always enough

add_doom_map() and cwad convert --to doom (see Converting maps) emit zero-length SEGS, SSECTORS, NODES, REJECT, and BLOCKMAP — the canonical lump run, present but empty — and always warn NodesNotBuilt. Whether that output plays depends on the engine tier (ADR-0024):

  • The ZDoom family (GZDoom, Zandronum, …) rebuilds missing nodes at load. It detects all-empty SEGS/SSECTORS/NODES and runs its own internal nodebuilder, and likewise regenerates a missing or oversized BLOCKMAP and REJECT. On these ports the empty-lump output is already playable.
  • Vanilla and Chocolate Doom rebuild nothing. Their loaders copy the node indices with almost no validation and walk the tree directly. Empty node lumps send every point to subsector 0 or crash outright. These ports need real node lumps.

The nodebuilder’s entire value is the vanilla tier: it produces the lumps a faithful port requires, so a freshly converted or generated map runs without an external tool.

The builders and the one-shot

With nodebuild enabled, crustywad::map::build exposes three builders, each turning an assembled Map into one part of the node run:

BuilderProducesNotes
build_rejectREJECTInfallible; the correctly-sized all-zeros table (ceil(sectors² / 8) bytes) — an all-clear table pre-rejects no line of sight, exactly what zdbsp emits.
build_blockmapBLOCKMAPThe packed 128-unit collision grid, deduplicated blocklists.
build_nodesSEGS / SSECTORS / NODES (+ split vertices)The classic BSP pass: partitions the map on seg lines into a deterministic tree.

When build_nodes splits a seg it creates a new vertex; its to_lump_bytes() output carries those split_vertexes, which must be appended to the map’s VERTEXES lump or the segs’ vertex indices dangle. The nodebuild worked example shows the full manual assembly, including this append and the canonical lump order.

The one-shot: add_doom_map_with_nodes

add_doom_map_with_nodes(builder, name, map, write_opts, build_opts) bundles all of the above — it serializes the five data lumps, runs the three builders, appends the split vertices, and adds the complete engine-playable map group to a WadBuilder in canonical lump order. Unlike add_doom_map, it never emits NodesNotBuilt (it built the nodes); every other write-path recovery still surfaces, wrapped as NodeBuildWarning::Write. Reach for it when you want a playable Doom map group in one call rather than orchestrating the builders by hand.

From the CLI: cwad convert --nodes

cwad convert --to doom --nodes is the turnkey path — it runs the add_doom_map_with_nodes one-shot for every converted map, so the output WAD is engine-playable with no external step:

cwad convert udmf.wad -o doom.wad --to doom --nodes --lenient
cwad validate --deep doom.wad

--to udmf --nodes instead runs add_udmf_map_with_nodes: UDMF has no binary node lumps, so the only thing to build is a ZNODES stream carrying the dialect --node-format selects (gl auto-format by default, noted on stderr):

cwad convert doom.wad -o udmf.wad --to udmf --nodes

--node-format still selects the dialect, and a UDMF target now accepts any of them — the non-GL extended pair (xnod/znod) builds an XNOD/ZNOD stream inside ZNODES just as it would in NODES for a Doom target. The non-GL streams are built by the classic BSP pass, which narrows coordinates through the shared integer write path — so a fractional-coordinate UDMF map exits 3 in strict mode, naming the offending coordinate and hinting at --lenient; --lenient rounds the coordinate to the nearest whole map unit for the node stream only (the TEXTMAP keeps the fractional originals) and warns instead. A map that needs fractional geometry preserved exactly should use a GL dialect (gl, xgln, xgl2, or xgl3), which has no such ceiling. The global --lenient flag selects lenient mode for both the conversion and the node build. See CLI Usage for the full flag reference.

Choosing the on-disk node format

--node-format selects how the built nodes are stored for a Doom target, or which dialect fills a UDMF target’s ZNODES stream — both GL and the non-GL extended pair are accepted for UDMF. It has no effect without --nodes, and a non-classic value passed without --nodes prints a note on stderr and is ignored. The default classic auto-selects gl for a UDMF target:

ValueOn-disk formNotes
classic (default)SEGS / SSECTORS / NODES (16-bit)Vanilla-compatible; unchanged from plain --nodes.
xnodA single uncompressed XNOD stream in NODES (SEGS/SSECTORS empty)ZDoom non-GL extended nodes; lifts the vanilla 16-bit ceilings.
znodA zlib-compressed ZNOD streamSame as xnod, compressed. Requires cwad built with the extended-nodes-zlib feature (on by default); a --no-default-features build rejects znod with a clear error.
xglnAn uncompressed XGLN stream, carried in SSECTORS (SEGS/NODES empty)The minimal GL dialect: 16-bit seg linedef reference (0xFFFF reserved as the miniseg sentinel), whole-unit i16 node partitions.
xgl2An uncompressed XGL2 stream, carried in SSECTORSLike xgln but with a 32-bit seg linedef reference; still whole-unit i16 node partitions.
xgl3An uncompressed XGL3 stream, carried in SSECTORSLike xgl2, plus fractional (sub-unit) node partitions.
glWhichever of xgln/xgl2/xgl3 is the minimal dialect the map needs, emitted uncompressedEscalates only if the geometry requires it (a real linedef index colliding with XGLN’s sentinel, or a fractional partition).
zgln / zgl2 / zgl3 / zglThe zlib-compressed twins of the four GL rows aboveEach carried in SSECTORS, same as its uncompressed twin. Requires cwad built with the extended-nodes-zlib feature (on by default); without it, these exit 3 with a clear error.
cwad convert udmf.wad -o doom.wad --to doom --nodes --node-format xnod
cwad convert udmf.wad -o doom.wad --to doom --nodes --node-format gl

cwad build --nodes

cwad build --nodes runs the same builders for a WAD assembled from scratch out of NAME=FILE lump specifications: after packing, it rebuilds every Doom-format map group in the result with real, engine-playable node lumps — SEGS/SSECTORS/NODES, REJECT, and BLOCKMAP — overwriting whatever was packed for those lumps, whether empty placeholders or existing data. The packed VERTEXES lump can also grow, since the BSP pass appends any split vertices it creates to it. It also rebuilds every UDMF-format map group’s ZNODES stream in place (replacing an existing one, or inserting it right after TEXTMAP), the rest of the group’s lumps carried through unchanged:

cwad build --nodes MAP01=map01.lmp THINGS=things.lmp ... -o playable.wad
cwad build --nodes --node-format gl MAP01=map01.lmp THINGS=things.lmp ... -o playable.wad

build --nodes accepts the same --node-format values as convert --nodes (the table above), including the GL dialects and their z* zlib twins. As with convert --to udmf --nodes, a UDMF map group’s ZNODES accepts any of them: classic auto-selects gl (noted on stderr); an explicit xnod/znod builds a non-GL extended stream instead. The classic BSP pass behind them is integer-precision, so a fractional-coordinate UDMF map is rejected in strict mode (naming the offending coordinate, with a --lenient hint) and rounded with a warning in lenient mode — for the node stream only, the TEXTMAP keeps the fractional originals; a map needing exact fractional geometry should use a GL dialect.

A Hexen map group is rebuilt in place rather than reassembled from scratch, since Hexen has no add_*_map_with_nodes one-shot: THINGS, LINEDEFS, SIDEDEFS, SECTORS, and BEHAVIOR are carried through byte-verbatim; SEGS/SSECTORS/NODES are rebuilt for whichever --node-format is in effect — unlike a UDMF target, Hexen accepts every format including the classic default, using the same three carrier conventions as a Doom group (the classic trio plus a split-vertex tail appended to VERTEXES; xnod/znod in NODES with SEGS/SSECTORS emptied and VERTEXES untouched; a GL dialect in SSECTORS). REJECT and BLOCKMAP are always rebuilt — a hand-tuned REJECT is replaced with the engine-safe all-zeros table build_reject produces, and the five rebuilt lumps are emitted at their canonical slot even if the input group lacked one outright. A corrupt node lump among the group’s own five (SEGS, SSECTORS, NODES, REJECT, BLOCKMAP) is excluded before assembly and so repaired rather than fatal; the repair claim does not extend to a separate in-WAD GL_<mapname> sidecar group, which Map::assemble_with_options decodes unconditionally. A corrupt sidecar therefore still strict-fails assembly (exit 3 — --lenient recovers), and a valid-but-stale sidecar passes through verbatim next to the rebuilt lumps, so a GL-preferring engine may load it in preference to the freshly built nodes. The whole group is re-emitted in the canonical THINGSBEHAVIOR order, since vanilla-class engines index a map’s lumps by offset from the marker. A map using polyobjects gets a warning that the rebuilt nodes may split a polyobject’s subsector — the warning fires on the vanilla Hexen anchor/spawn editor numbers 3000–3002 (per the Hexen source’s P_LOCAL.H) and on ZDoom’s 9300–9303 “Doom-in-Hexen” numbers (per GZDoom wadsrc/static/mapinfo/common.txt). It is advisory: in a Doom-in-Hexen map the Doom editor numbers apply, where 3001/3002 are the Imp/Demon, so those values may instead be ordinary monsters. Polyobject-aware splitting is the tracked follow-up (#389).

Doom 64 (#353) map groups remain the only ones not yet supported by build --nodes; they are passed through unchanged with a note on stderr. Non-map lumps always pass through unchanged. See CLI Usage for the full flag reference.

The tolerated mixed-sector fan

Real geometry occasionally produces a convex leaf that spans more than one sector with no seg line able to separate them — the mixed-sector fan (two sectors meeting at a bare corner vertex). Across the full retail collection, 551 classic maps build clean save for exactly this case:

  • Strict build_nodes / add_doom_map_with_nodes rejects such a map.
  • Lenient accepts the leaf and emits NodeBuildWarning::MixedSectorSubsector — the exact engine-tolerated output the retail masters themselves ship (ADR-0024 §7 amendment).

This is why converting real maps with --nodes often needs --lenient: the warning names an inherent property of the source geometry, not a defect the builder could fix.

What the library generates, and when you still need an external nodebuilder

The clean-room builder now spans three tiers of output:

  • Classic 16-bit — vanilla-layout SEGS/SSECTORS/NODES, which covers every real classic map with wide margin (ADR-0024 §1). This is NodeFormat::Classic.
  • Non-GL extended — the XNOD stream (and its zlib twin ZNOD, behind extended-nodes-zlib) via build_nodes + BuiltNodes::to_extended_lump_bytes, lifting the vanilla node ceilings (ADR-0025 §Amendment #323).
  • GL extended — the XGLN/XGL2/XGL3 streams (and their zlib twins ZGLN/ZGL2/ZGL3 with extended-nodes-zlib) via build_gl_nodes
    • BuiltGlNodes::to_extended_lump_bytes, or the add_doom_map_with_nodes one-shot, which carries the GL stream in SSECTORS (ADR-0026, #364, #365). The add_udmf_map_with_nodes one-shot builds either family for a UDMF map group, carried in ZNODES instead (#354, #384). NodeFormat::Gl/NodeFormat::Zgl auto-select the minimal dialect a map needs — XGLN unless a real linedef index collides with XGLN’s 0xFFFF miniseg sentinel (forcing XGL2’s 32-bit linedefs) or a fractional partition forces XGL3.

crustywad reads the full ZDoom extended family and classic GL nodes (ADR-0025 and its amendments, Extended nodes milestone, #199/#324) — see Extended node encodings and Classic GL nodes in the map-records guide.

Both cwad convert --nodes and cwad build --nodes expose the full tier set through --node-format — classic, the non-GL extended pair, and all four GL dialects (see the table above) — so no external nodebuilder pass is needed to reach any of them from the CLI.

Map Record Parsing

Doom maps are stored as a group of sequentially named lumps. After the marker lump (e.g. E1M1) come the parsed map data lumps. The table below covers the record lumps that crustywad decodes; classic Doom maps also include additional lumps such as REJECT and BLOCKMAP after SECTORS. Unlike the flat record lumps below, REJECT and BLOCKMAP decode into typed, queryable structures (MapReject sector-visibility lookups, MapBlockmap per-block linedef lists) during map assembly — see REJECT and BLOCKMAP below.

LumpRecord typeRecord size
THINGSThing10 bytes
LINEDEFSLinedef14 bytes
SIDEDEFSSidedef30 bytes
VERTEXESVertex4 bytes
SEGSSeg12 bytes
SSECTORSSubsector4 bytes
NODESNode28 bytes
SECTORSSector26 bytes

Parsing records

crustywad::map::parse_records::<T> decodes a byte slice into a Vec<T>. All record types implement BinRead with little-endian byte order.

#![allow(unused)]
fn main() {
use crustywad::map;

// Parse a raw THINGS byte slice containing a single thing.
let thing_bytes: &[u8] = &[
    100_i16.to_le_bytes()[0], 100_i16.to_le_bytes()[1],  // x = 100
    200_i16.to_le_bytes()[0], 200_i16.to_le_bytes()[1],  // y = 200
    0, 0,                                                  // angle = 0
    1, 0,                                                  // type_id = 1 (player 1 start)
    7, 0,                                                  // flags = 0x0007
];

let things: Vec<map::doom::Thing> = map::parse_records(thing_bytes)?;
let t = &things[0];
println!("Player 1 start at ({}, {}), angle {}", t.x, t.y, t.angle);
Ok::<(), crustywad::map::MapParseError>(())
}

Available record types

Thing

pub struct Thing {
    pub x: i16,        // X coordinate in map units
    pub y: i16,        // Y coordinate in map units
    pub angle: u16,    // Facing angle in degrees (0-359, counter-clockwise from east)
    pub type_id: u16,  // Editor number / thing type
    pub flags: u16,    // Doom thing flags
}

Linedef

pub struct Linedef {
    pub start_vertex: u16,   // Start vertex index
    pub end_vertex: u16,     // End vertex index
    pub flags: u16,
    pub special_type: u16,   // Special action
    pub sector_tag: u16,
    pub right_sidedef: u16,  // Right sidedef index
    pub left_sidedef: u16,   // 0xffff when absent
}

Sidedef

pub struct Sidedef {
    pub x_offset: i16,
    pub y_offset: i16,
    pub upper_texture: Name8,   // 8-byte NUL-padded name
    pub lower_texture: Name8,
    pub middle_texture: Name8,
    pub sector: u16,
}

Vertex

pub struct Vertex {
    pub x: i16,
    pub y: i16,
}

Sector

pub struct Sector {
    pub floor_height: i16,
    pub ceiling_height: i16,
    pub floor_texture: Name8,
    pub ceiling_texture: Name8,
    pub light_level: i16,
    pub special_type: i16,
    pub tag: i16,
}

See crustywad::map in the API docs for the full definitions of Seg, Subsector, and Node.

Error handling

parse_records returns MapParseError:

  • MapParseError::TrailingBytes — the lump length is not an exact multiple of the record size (e.g. a THINGS lump whose byte count is not divisible by 10).
  • MapParseError::Binrwbinrw failed to decode a record from the byte stream.

Both variants implement std::error::Error and display a human-readable message.

Assembling a map graph

The record types above are flat and unresolved — a Linedef’s start_vertex is just a u16 index. crustywad::map also assembles those flat records into a normalized Map graph, resolving cross-references between vertices, sidedefs, and sectors so callers don’t have to index arenas by hand.

Multi-format assembly. Map::assemble detects the map format from its lumps: the marker lump is checked first under the Doom 64 dual condition — a MAPxx name and nested IWAD/PWAD magic in its bytes, the same rule grouping applies (see Doom 64 maps below); otherwise a TEXTMAP lump marks a UDMF map, a BEHAVIOR lump marks a Hexen map, and anything else is treated as the classic Doom binary layout. The assembled Map carries its format via map.format(), which returns MapFormat::Doom for classic Doom/Doom II/Heretic maps (which share the same binary record layout and differ only in map-marker naming, e.g. MAP01 vs E1M1), MapFormat::Hexen for Hexen maps, MapFormat::Udmf for UDMF (TEXTMAP) maps, or MapFormat::Doom64 for Doom 64 maps. UDMF maps can also be written back out with write_udmf / add_udmf_map (the write feature).

Finding a map’s lumps

A WAD stores maps as a marker lump (e.g. E1M1, MAP01) followed by a run of data lumps (THINGS, LINEDEFS, SIDEDEFS, VERTEXES, SECTORS, and friends). Wad::map_groups and Wad::map_group locate these runs and return one MapGroup per map:

pub struct MapGroup {
    pub marker_index: usize,   // directory index of the marker lump
    pub name: String,          // the map's name, e.g. "E1M1"
    pub data_indices: Vec<usize>,  // directory indices of the map's data lumps, in order
}
#![allow(unused)]
fn main() {
use crustywad::Wad;

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
// All maps in the WAD.
for group in wad.map_groups() {
    println!("found map {}", group.name);
}

// A single named map.
if let Some(group) = wad.map_group("E1M1") {
    println!("E1M1 has {} data lumps", group.data_indices.len());
}
}

Directory sections

Besides map groups, a WAD’s lump directory brackets other kinds of content between marker lumps (typically zero-size; recognized by name): F_START/F_END for flats, S_START/S_END for sprites, P_START/P_END for patches, and Doom 64’s T_START/T_END (world textures) and DS_START/DS_END (digital sounds), each with nested numbered sub-namespaces (F1_/F2_/P1_/P2_/…) and Boom’s doubled-letter aliases (FF_, PP_, SS_). Wad::sections / Wad::sections_with_options scan a single WAD’s directory and return a SectionTable of Sections, each carrying its SectionKind, the directory range of its marker pair, its content lumps, and any nested sub-sections:

#![allow(unused)]
fn main() {
use crustywad::{SectionKind, Wad};

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
let table = wad.sections()?;
for flats in table.of_kind(SectionKind::Flats) {
    println!("flats section spans lumps {:?}", flats.lumps);
}
Ok::<(), Box<dyn std::error::Error>>(())
}

Both reference engines locate a section’s extent by unguarded subtraction of two independently looked-up marker positions, with no check for a missing, inverted, or duplicated marker (ADR-0022 §2) — this API replaces that anti-pattern with a validated scan: Wad::sections (strict) returns the first SectionError on a malformed marker layout (an unpaired start/end, a duplicate or nested pair, or cross-kind interleaving), while Wad::sections_with_options under ParseOptions::lenient() never errors — it recovers a best-effort SectionTable and records each anomaly as a SectionWarning instead. A balanced numbered pair with no enclosing parent of its kind (e.g. a bare P3_START..P3_END, as shipped by SVE.wad) is not an anomaly — engines model no parent/child relationship between markers, so it is read as a first-class top-level section in both modes. As with map groups, section scanning is scoped to one WAD’s directory; multi-WAD load-order overlay is out of scope here (tracked on the editor epic’s future lump/resource manager, #65).

Assembling a Map

Map::assemble builds a graph from a MapGroup’s THINGS, LINEDEFS, SIDEDEFS, VERTEXES, and SECTORS lumps, decoding the flat records and validating every cross-reference between them:

#![allow(unused)]
fn main() {
use crustywad::Wad;
use crustywad::map::Map;

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("E1M1") {
    let map = Map::assemble(&wad, &group)?;

    for linedef in map.linedefs() {
        let (start, end) = map.linedef_vertices(linedef);
        if let Some(right) = map.linedef_right(linedef) {
            println!(
                "line ({}, {}) -> ({}, {}), front sector floor {}",
                start.x, start.y, end.x, end.y,
                map.sidedef_sector(right).floor_height
            );
        }
    }
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

Map exposes each normalized arena — vertices(), linedefs(), sidedefs(), sectors(), things() — plus infallible resolvers that follow indices between them:

ResolverFollows
map.linedef_vertices(linedef)(start, end) vertex pair
map.linedef_right(linedef)right (front) sidedef, or None
map.linedef_left(linedef)left (back) sidedef, or None
map.sidedef_sector(sidedef)the sidedef’s sector

The resolvers are total for elements obtained from this map’s own accessors (map.linedefs(), map.sidedefs(), …): they never panic or return an out-of-range index, because assembly validated every cross-reference before Map was constructed. (Because MapLinedef/MapSidedef have public index fields, passing a hand-constructed value with an out-of-range index can still panic.)

Texture references

MapSidedef’s upper/lower/middle fields and MapSector’s floor_flat/ceiling_flat field are a TextureRef, not a bare string: TextureRef::Name(String) for a name (Doom/Hexen’s 8-byte lump name, a UDMF string, or a Doom 64 texture/flat hash resolved against a Textures section — see Doom 64 maps below), or TextureRef::Index(u16) for a Doom 64 texture/flat table hash that couldn’t be resolved. Classic Doom, Hexen, and UDMF maps always produce Name. TextureRef::as_name() returns Some(&str) for Name and None for Index, and TextureRef implements PartialEq<&str> against the name, so a Doom/Hexen/UDMF texture can be compared directly against a string literal:

#![allow(unused)]
fn main() {
use crustywad::Wad;
use crustywad::map::Map;

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("E1M1") {
    let map = Map::assemble(&wad, &group)?;
    for sector in map.sectors() {
        if sector.floor_flat == "LAVA1" {
            println!("lava sector, ceiling flat: {:?}", sector.ceiling_flat.as_name());
        }
    }
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

One-sided (and sideless) lines

On disk, either of a Linedef’s sidedef fields may hold the sentinel value 0xffff, meaning “no sidedef on this side”. A left_sidedef of 0xffff is the everyday case — a one-sided line, such as an outer wall. A right_sidedef of 0xffff is rare but engine-sanctioned (vanilla guards both fields identically): retail maps use it for invisible blocking lines with no render surfaces at all. Assembly translates the sentinel into Option<SidedefIdx> on both fields — MapLinedef.left and MapLinedef.right are each None when their side is absent — and map.linedef_left(linedef) / map.linedef_right(linedef) mirror this by returning Option rather than an error.

Extended thing and linedef fields

Hexen maps extend the classic Doom binary record layout with additional fields on things and linedefs. When assembled, a MapThing includes id (thing ID for cross-references), height (vertical position), and special (a Special carrying the action number and its five args). A MapLinedef likewise has a special: Special, plus an id — a UDMF/ZDoom line identifier that is 0 for Doom and Hexen maps (reserved for UDMF). Special is shared across formats: for a Doom linedef, its target sector tag lives in special.args[0]; Hexen and UDMF populate the full args.

On Doom maps the thing fields (id, height, and the thing special) are all zero, while a linedef’s special still holds its classic action number and sector tag (the latter in special.args[0]). Hexen maps additionally populate the thing fields with real values. Use map.format() to decide how to interpret them:

#![allow(unused)]
fn main() {
use crustywad::map::{Map, MapFormat};

let wad = crustywad::Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec())?;
if let Some(group) = wad.map_group("MAP01") {
    let map = Map::assemble(&wad, &group)?;

    for thing in map.things() {
        if map.format() == MapFormat::Hexen {
            println!("Hexen thing ID: {}, height: {}", thing.id, thing.height);
        }
    }

    for linedef in map.linedefs() {
        if map.format() == MapFormat::Hexen {
            println!(
                "Hexen line special: {}, args: {:?}",
                linedef.special.special, linedef.special.args
            );
        }
    }
}
Ok::<(), Box<dyn std::error::Error>>(())
}

Strict vs. lenient assembly

Map::assemble(wad, group) is a convenience wrapper that always uses strict mode. Map::assemble_with_options(wad, group, options) takes a ParseOptions and honors its strictness, the same as the raw Wad and parse_records APIs:

  • Strict (Map::assemble, or assemble_with_options with Strictness::Strict): the first out-of-range cross-reference (e.g. a linedef’s vertex index past the end of VERTEXES) aborts assembly with MapAssembleError::DanglingReference. A missing required lump or an undecodable record lump also aborts, in both modes, with MapAssembleError::MissingLump or MapAssembleError::Records.
  • Lenient (assemble_with_options with Strictness::Lenient): an out-of-range cross-reference is clamped to a valid fallback index instead of failing, and a MapWarning::DanglingReference is recorded. Structural failures (missing lump, undecodable records, or a required target arena that is empty) still return MapAssembleError even in lenient mode.
#![allow(unused)]
fn main() {
use crustywad::map::Map;
use crustywad::{ParseOptions, Wad};

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("E1M1") {
    let map = Map::assemble_with_options(&wad, &group, ParseOptions::lenient())?;
    for warning in map.warnings() {
        eprintln!("{warning}");
    }
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

map.warnings() returns the MapWarnings collected during a lenient assembly (empty for a clean map, and always empty after a strict Map::assemble, since strict mode returns an error instead of recording a warning).

Doom 64 maps

Doom 64 stores each map as a nested WAD: the MAPxx marker lump’s bytes are themselves a complete WAD (leading IWAD/PWAD magic), whose sub-lumps hold the map’s records, rather than a marker followed by a run of sibling data lumps in the outer directory. Wad::map_groups / Wad::map_group recognize a Doom 64 map only when both signals hold: the marker’s name matches MAPxx (MAP plus two ASCII digits) and its lump bytes carry the nested WAD magic — so an ordinary empty classic MAPxx marker is never misread as Doom 64. A Doom 64 MapGroup’s data_indices is always empty, since its data lives inside the marker lump rather than the flat directory. map.format() reports MapFormat::Doom64 for these maps, and both Map::assemble and Map::assemble_with_options assemble them into the same Map graph as every other format, in both strictness modes.

Doom 64 adds per-map colored lighting. Map::lights() returns the map’s full light table built the way the engine builds it (mirroring Doom64 EX’s P_LoadLights): 256 implicit grayscale entries (r = g = b = index, tag = 0), followed by the map’s LIGHTS lump records starting at index 256. MapSector.colors is Some([LightIdx; 5]) for a Doom 64 sector — five references into Map::lights() — and None for every other format. The five slots are carried positionally: Doom 64’s own format headers don’t name them, so crustywad doesn’t invent slot meanings either. MapSector.light (the classic scalar light level) is always 0 for a Doom 64 sector, since the format has no such field; MapSector.flags carries the sector’s raw Doom 64 flag bits (opaque, uninterpreted).

Doom 64’s sidedef/sector texture and flat fields carry a u16 hash on disk rather than a name. When the containing WAD has a Textures section (a T_START/T_END-delimited run — see Directory sections above), assembly resolves every hash to the matching texture/flat name in Textures, first-match-in-disk-order on a collision, and the field becomes TextureRef::Name like every other format. A miss against a present section is a strict MapAssembleError::UnresolvedTextureHash / lenient MapWarning::UnresolvedTextureHash (keeping TextureRef::Index); a WAD with no Textures section at all keeps every field as TextureRef::Index silently, since a bare nested-map WAD (no textures alongside it) is a legitimate input.

A Doom 64-sourced Map can be serialized back out (write_doom_map/write_udmf, the write feature) once its texture references resolve to names. The one remaining unrepresentable piece is colored lighting (MapSector.colors, described above): strict mode refuses with UnrepresentableField (block: "sector", field: "colors"), lenient mode drops the colors and records one ColoredLightingDropped warning per map, then converts. A leftover unresolved TextureRef::Index (no Textures section, or an unresolved hash kept under lenient assembly) still hits a defensive UnresolvedTextureIndex writer error in both modes.

Doom 64 also decodes the LEAFS lump — its render leaves — onto the graph. Map::leafs() is a per-subsector arena of MapLeaf { vertex: VertexIdx, seg: Option<SegIdx> }, and each MapSubsector::leafs range selects that subsector’s slice, mirroring the existing segs range below. The on-disk seg field’s -1 sentinel becomes seg: None (“no seg”: the edge is implicit geometry). The lump’s record count must equal the map’s subsector count — the engine treats a mismatch as fatal, and this reader mirrors it: strict mode rejects with MapAssembleError::LeafCountMismatch, lenient mode discards the whole LEAFS arena and records one warning, the same whole-arena degrade policy as the BSP data below. Map::leafs() and every MapSubsector::leafs range are empty for every source format other than Doom 64.

Doom 64 also decodes the MACROS lump — its scripted action sequences — onto the graph. Map::macros() returns the decoded macros as a slice, in lump order, each MapMacro { actions: Vec<MapMacroAction> } holding MapMacroAction { id, tag, special } entries; the engine’s loader reads one more action than the macro’s on-disk count states (count + 1), and this decode preserves that read exactly. Decoding stops at the data: crustywad builds no interpreter or execution machinery for these scripts, since running them is the ACS epic’s job (#248), not a WAD-reading concern. Map::macros() is empty for every source format other than Doom 64.

BSP data

Beyond the geometry arenas, Map also exposes the engine-built BSP (Binary Space Partitioning) tree: map.segs(), map.subsectors(), and map.nodes(), normalized from the SEGS, SSECTORS, and NODES lumps. These are populated for classic Doom/Heretic, Hexen, and Doom 64 maps alike — Doom 64’s BSP records share the classic on-disk layout, so they normalize through the same code path. A UDMF map’s BSP data, when present, lives in its own ZNODES lump instead, carrying the same ZDoom extended/GL node encoding described below — see Extended node encodings. Like the classic BSP lumps, it is optional: a UDMF map with no ZNODES lump simply has empty segs()/subsectors()/nodes().

map.bsp_root() returns the index of the tree’s root node — Some(NodeIdx) if map.nodes() is non-empty, None otherwise. By convention the root is the last node in the arena, matching Chocolate Doom’s R_RenderPlayerView, which starts traversal at R_RenderBSPNode(numnodes - 1).

Like SEGS/SSECTORS/NODES themselves, these three arenas are optional: many editable PWADs ship without built nodes, so their absence is not an assembly error — map.segs(), map.subsectors(), and map.nodes() are simply empty, and map.bsp_root() returns None. A map produced by converting another format to Doom (add_doom_map) ships zero-length SEGS/SSECTORS/NODES placeholder lumps — real BSP data requires an external nodebuilder pass — so re-assembling that output also yields empty arenas.

A MapNode’s right/left fields are NodeChild, not a bare index: NodeChild::Node(NodeIdx) for an internal child, or NodeChild::Subsector(SubsectorIdx) for a leaf. Assembly decodes the on-disk child word’s bit 15 once — set selects a subsector, clear selects a node — so callers match on NodeChild instead of re-checking the bit themselves:

#![allow(unused)]
fn main() {
use crustywad::Wad;
use crustywad::map::{Map, NodeChild};

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("MAP01") {
    let map = Map::assemble(&wad, &group)?;
    if let Some(root) = map.bsp_root() {
        match map.nodes()[root.0].right {
            NodeChild::Node(i) => println!("right child is node {}", i.0),
            NodeChild::Subsector(i) => println!("right child is subsector {}", i.0),
        }
    }
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

Extended node encodings

A NODES or SSECTORS lump (or, for UDMF, a ZNODES lump) can instead carry an extended/GL node encoding — the ZDBSP family: XNOD, ZNOD, XGLN, ZGLN, XGL2, XGL3, ZGL2, ZGL3 — identified by a 4-byte signature at the head of the lump. crustywad’s classic-path BSP normalizer never attempts to decode these as fixed-size classic records — doing so would misread the signature bytes as garbage geometry.

The four uncompressed dialects — XNOD (non-GL) and the GL layouts XGLN, XGL2, XGL3 — now decode transparently into the same map.segs(), map.subsectors(), and map.nodes() arenas as the classic encoding, on both the binary NODES/SSECTORS path and the UDMF ZNODES path. There is nothing extra to opt into: assembly detects the signature and decodes the stream in place, in both Strictness modes. A structural framing fault — a bad count or a truncated record — fails strict assembly with MapAssembleError::ExtendedNode, recovering under ParseOptions::lenient() as a MapWarning::ExtendedNode with empty BSP arenas. An out-of-range vertex/linedef/child reference instead reuses MapAssembleError::DanglingReference (strict), which lenient mode clamps with a MapWarning::DanglingReference, usually keeping the rest of the BSP populated. One difference from a classic-decoded map is worth knowing: a GL dialect’s segs can include minisegs — synthetic segs that run along a BSP partition line rather than following a linedef — so MapSeg::linedef is Option<LinedefIdx> (None for a miniseg) rather than always Some.

The four compressed Z* dialects (ZNOD, ZGLN, ZGL2, ZGL3 — zlib-wrapped twins of the X* streams above) decode when the extended-nodes-zlib feature is enabled (#327): assembly skips the 4-byte tag, inflates the remaining zlib stream — bounded by Limits::max_decoded_node_bytes (default 64 MiB) so a “zip bomb” can’t exhaust memory — and decodes the inflated body through the same parser its uncompressed twin uses, into the same segs()/subsectors()/nodes() arenas, on both the binary and UDMF paths. The structural-fault contract matches the uncompressed dialects; an un-inflatable stream is MapAssembleError::ExtendedNode with an ExtendedNodeError::CorruptStream reason (strict), or a whole-BSP degrade-to-empty with one warning (lenient), and exceeding the decode cap is ExtendedNodeError::DecodedSizeExceeded under the same split.

Without the extended-nodes-zlib feature, a Z* signature is gated, not parsed: detecting one gates the whole BSP normalization step — strict mode fails with MapAssembleError::UnsupportedNodeEncoding; lenient mode leaves map.segs(), map.subsectors(), and map.nodes() empty and records one MapWarning::UnsupportedNodeEncoding for the gated lump (a map’s extended stream lives in a single lump, so assembly stops at the first signature it finds and warns once).

DeePBSP v4 (xNd4) is decoded on the binary SEGS/SSECTORS/NODES path (#328). It is a classic-widened node format, not a ZDoom extended variant: it keeps the three separate SEGS/SSECTORS/NODES lumps but widens the records to 32-bit vertex/seg/child indices, and heads its NODES lump with an 8-byte xNd4\0\0\0\0 signature (distinct from the 4-byte X*/Z* signatures). Assembly detects that 8-byte signature first — ahead of the 4-byte extended-signature check — and decodes the three lumps into the same segs()/subsectors()/nodes() arenas. DeePBSP adds no new vertices (the map’s VERTEXES lump is used unchanged) and has no minisegs (every seg is linedef-backed). A NODES lump without the xNd4 signature falls through to the 4-byte extended check, then to the classic decoder, unchanged.

DeePBSP’s malformed-input contract differs from the ZDoom readers’ by design: a structurally-malformed DeePBSP lump — records whose length is not a whole multiple of the record size, or a NODES lump shorter than its 8-byte signature — is a hard MapAssembleError::Records in both strictness modes, mirroring the classic SEGS/SSECTORS/NODES path DeePBSP structurally resembles. Lenient recovery still applies to cross-references (an out-of-range vertex, linedef, or child index clamps and warns, and a reference into an empty arena degrades the whole BSP to empty with one warning), but not to unparseable bytes. This is unlike the ZDoom X*/Z* readers, whose whole self-describing stream degrades to empty on any structural fault in lenient mode.

On the UDMF ZNODES path there is no classic decoder to fall through to, so any unrecognized tag there (including xNd4, which never legitimately appears in UDMF) is gated — strict error, or lenient warning with empty BSP arenas. The staged extended-node design lives under the #199 umbrella; see ADR-0025.

Classic GL nodes

A classic binary (Doom/Hexen) map can additionally ship its own GL_<mapname> marker lump (e.g. GL_MAP01) followed by a GL_VERT/GL_SEGS/GL_SSECT/GL_NODES run — glBSP’s precursor to the ZDoom extended-node family above, computed for faster in-engine rendering (#324, ADR-0025 amendment). Unlike the extended/DeePBSP formats, which decode into the existing segs()/subsectors()/nodes() arenas, classic GL data is additive: it decodes into its own arenas — map.gl_vertices(), map.gl_segs(), map.gl_subsectors(), and map.gl_nodes() — alongside (never instead of) the vanilla BSP, because a GL_* group is a genuinely separate BSP glBSP built over the same geometry, not an alternate encoding of the classic one. GlVertex coordinates are f64 world units, widened losslessly from the on-disk 16.16 fixed-point. A GlSeg’s endpoints are a GlVertexRef (Normal into the map’s own VERTEXES, or Gl into gl_vertices()); linedef is None for a GL miniseg (a synthetic seg running along a BSP partition line, not backed by a linedef); a resolved partner links the seg on the far side of the same edge. GlNode mirrors MapNode’s partition/bbox/child shape, but its children (GlNodeChild) index the GL arenas instead.

Three on-disk versions decode — V2, V3, and V5 — detected from the GL_VERT (and, for the V2/V3 split, GL_SEGS) magic signature. V1 (no signature) and V4 (which dropped the partner-seg information needed to rebuild subsector winding) are refused, matching gzdoom’s own policy: strict mode fails with MapAssembleError::UnsupportedGlNodeVersion; lenient mode records one MapWarning::GlNodesRefused and leaves all four GL arenas empty — the same “no GL data” shape as a map with no GL_* group at all. A structural cross-reference fault that cannot be recovered by clamping degrades the whole GL group the same way the classic BSP does (MapWarning::GlNodesDegraded, empty arenas), while a framing defect (a lump whose length isn’t a whole multiple of its record size) is a hard error in both modes.

Classic GL nodes are decoded unconditionally — no feature flag — on the binary Doom/Hexen assembly path only; UDMF and Doom 64 maps always report empty gl_* arenas.

GL nodes can come from an in-WAD GL_<mapname> group or from a same-named external .gwa sibling WAD — the historical glBSP convention — via Map::assemble_with_gl_source, which takes an optional gl_wad: Option<&Wad> for an already-loaded .gwa (#342, ADR-0025 amendment). Map::assemble and Map::assemble_with_options are unchanged — they are equivalent to gl_wad: None, reading GL nodes from the main WAD only. When a .gwa is supplied, its group is preferred; if it has no matching group, the reader falls back to an in-WAD GL_<mapname> group. A .gwa has no map markers of its own, so its groups are located by a flat scan for either marker form glBSP emits:

  • GL_<mapname> — a lump named e.g. GL_MAP01, matched by name (only possible when the name fits the 8-byte lump-name limit); or
  • GL_LEVEL — a lump literally named GL_LEVEL whose text contents carry a LEVEL=<mapname> line, glBSP’s long-name form for maps whose name doesn’t fit the first form.
#![allow(unused)]
fn main() {
use crustywad::map::Map;
use crustywad::{ParseOptions, Wad};

let main = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
let gwa = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = main.map_group("MAP01") {
    let map = Map::assemble_with_gl_source(&main, &group, Some(&gwa), ParseOptions::default())?;
    println!("GL nodes: {}", map.gl_nodes().len());
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

The same whole-BSP posture applies when BSP data is internally unrecoverable in lenient mode: a reference that cannot be clamped (for example, a node child pointing into an absent SSECTORS arena) drops all three arenas, records the dangling reference as a warning, and the rest of the map still assembles. BSP data is optional (ADR-0015 §5), so it never fails a lenient assembly.

REJECT and BLOCKMAP

Like the BSP lumps above, REJECT and BLOCKMAP decode into typed, queryable structures during map assembly rather than staying raw bytes: map.reject() returns Option<&MapReject> and map.blockmap() returns Option<&MapBlockmap>, None when the map carries no (or an empty) lump of that kind — an editable PWAD with no built REJECT/BLOCKMAP table is as normal as one with no built nodes.

A structurally defective BLOCKMAP — including the deliberately degenerate blockmaps that node builders emit for maps too large for the lump’s 16-bit offsets — is fatal in strict mode; lenient assembly discards the whole lump (map.blockmap() returns None) and records a single warning describing the first defect, mirroring the BSP degrade behavior above (ADR-0029). A partially-usable blockmap is never surfaced.

MapReject is a row-major sector-visibility bit matrix, sector_count × sector_count bits, LSB-first within each byte (layout verified against Chocolate Doom’s P_LoadReject / P_CheckSight):

#![allow(unused)]
fn main() {
use crustywad::Wad;
use crustywad::map::{Map, SectorIdx};

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("E1M1") {
    let map = Map::assemble(&wad, &group)?;
    if let Some(reject) = map.reject() {
        for i in 0..reject.sector_count() {
            let sector = SectorIdx(i);
            if reject.is_rejected(sector, sector) == Some(true) {
                println!("sector {i} pre-rejects sight to itself");
            }
        }
    }
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

MapBlockmap is a grid of 128-map-unit blocks, each holding the linedefs that cross it (layout verified against Chocolate Doom’s P_LoadBlockMap / P_BlockLinesIterator). map.blockmap() exposes origin(), columns()/rows(), block(col, row) (grid-indexed lookup), and block_at(x, y) (map-space coordinate lookup, None outside the grid or for non-finite coordinates):

#![allow(unused)]
fn main() {
use crustywad::Wad;
use crustywad::map::Map;

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("E1M1") {
    let map = Map::assemble(&wad, &group)?;
    if let Some(blockmap) = map.blockmap() {
        if let Some(linedefs) = blockmap.block_at(0.0, 0.0) {
            println!("{} linedefs cross the block at the origin", linedefs.len());
        }
    }
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

Internally MapBlockmap stores the lump’s words once and each block holds a validated range into them, so offset aliasing (ZDBSP-style whole-list sharing) and tail sharing (ZokumBSP-style partial-list sharing) cost no extra memory (ADR-0016 §1).

Both types honor the same strict/lenient policy as the rest of assembly: an undersized REJECT table is a strict error (MapAssembleError::UndersizedReject) or a lenient warning with the missing bits treated as “not rejected” (MapWarning::UndersizedReject); a malformed BLOCKMAP header, an out-of-lump block offset, an unterminated block list, or a block list referencing a nonexistent linedef are each a strict error (MapAssembleError::MalformedBlockmap / BlockmapBlockOffset / UnterminatedBlockmapList / DanglingReference) or, in lenient mode, exactly one matching MapWarning (MalformedBlockmap / BlockmapBlockOffset / UnterminatedBlockmapList / BlockmapListDangling) and the whole blockmap discarded — no block list is ever patched, so map.blockmap() is Some only when every block decoded cleanly (ADR-0029). An empty REJECT or BLOCKMAP lump (as crustywad’s own writer emits, ADR-0019 §4) is read back as simply absent, in both modes, with no warning.

A consumer that never reads a lump can skip it entirely instead of relying on lenient recovery: MapGroup::without_lumps returns a filtered copy of the group, and absent REJECT/BLOCKMAP lumps decode to None with no error and no warning in both strictness modes — so strict validation still covers everything the consumer actually reads:

#![allow(unused)]
fn main() {
use crustywad::Wad;
use crustywad::map::Map;

let wad = Wad::from_bytes(b"PWAD\x00\x00\x00\x00\x0c\x00\x00\x00".to_vec()).unwrap();
if let Some(group) = wad.map_group("MAP15") {
    let map = Map::assemble(&wad, &group.without_lumps(&wad, &["BLOCKMAP", "REJECT"]))?;
    assert!(map.blockmap().is_none());
}
Ok::<(), crustywad::map::MapAssembleError>(())
}

Game identification (Strife)

Strife ships its maps in the byte-identical classic Doom binary layout — same record sizes, same lump names — yet its flags, special, and type values carry different meanings. Left unidentified, a Strife WAD reads silently as Doom: the bytes decode without error, but their semantics are wrong. crustywad distinguishes the two without changing how records are decoded.

Wad::detect_game fingerprints the container by scanning its lump directory for the signature lumps a Strife IWAD/PWAD carries (names and sizes only — no record decoding, so detection adds no parse surface). It returns Some(WadGame::Strife) for a Strife WAD and None otherwise; Map::game reports the same attribution for an assembled map, so callers know a Doom-format map’s raw values follow Strife’s meaning rather than Doom’s.

#![allow(unused)]
fn main() {
use crustywad::{Wad, WadGame};
use crustywad::map::Map;

let wad = Wad::from_path("strife1.wad")?;
assert_eq!(wad.detect_game(), Some(WadGame::Strife));
for group in wad.map_groups() {
    let map = Map::assemble(&wad, &group)?;
    // Raw flags follow Strife semantics — see the map::strife constants.
    assert_eq!(map.game(), Some(WadGame::Strife));
}
Ok::<(), Box<dyn std::error::Error>>(())
}

The Strife-specific flag-bit constants live in the map::strife module. The headline divergence: thing-flag bit 3 (0x0008) is MTF_AMBUSH (deaf) in Doom but MTF_STAND in Strife, with Strife’s own AMBUSH relocated to bit 5 (0x0020) — the reason attribution matters at all, since reading a Strife map’s raw bits as Doom’s would misclassify a standing NPC as a deaf one. crustywad does not reinterpret those values for you: the assembled Map still exposes the raw Doom-layout records. During lenient assembly of a Doom-format map from a fingerprinted WAD, Map::assemble_with_options records one MapWarning::UnmodeledGameSemantics per map, flagging that the graph’s flag/special/type semantics beyond the Doom baseline are not modeled (ADR-0028). Strict assembly is unaffected.

Strife also carries branching NPC conversations in dedicated SCRIPTnn lumps — one per map (SCRIPT03 for MAP03), with SCRIPT00 doubling as the global fallback script. map::strife::parse_dialogue decodes such a lump into normalized DialogueRecords, auto-selecting between the retail and demo on-disk layouts by lump length and reporting which via the returned DialogueFormat; both layouts normalize to the same record type, with the demo layout’s absent fields carried as Options and its speaker voice reconstructed. script_lump_name builds the lump name for a map number.

#![allow(unused)]
fn main() {
use crustywad::ParseOptions;
use crustywad::Wad;
use crustywad::map::strife::{parse_dialogue, script_lump_name};

let wad = Wad::from_path("strife1.wad")?;
let name = script_lump_name(3).expect("map numbers 0-99 have script lumps");
if let Some(lump) = wad.lump_by_name(&name) {
    let (records, format, warnings) =
        parse_dialogue(wad.lump_data(lump), &ParseOptions::strict())?;
    println!("{name}: {} {format:?} dialogue record(s)", records.len());
    assert!(warnings.is_empty());
}
Ok::<(), Box<dyn std::error::Error>>(())
}

Graphics

crustywad::gfx decodes the classic Doom graphics lumps: the picture format used by patches and sprites, the raw 64×64 flat format, and the PLAYPAL/COLORMAP palette lumps. This is “tier 1” of ADR-0022 §3’s three-tier plan (raw typed lumps); tier 2 (TEXTUREx/PNAMES composition) is #157.

The module is dependency-free and lives in the core crate with no feature flag — the same precedent map parsing set: a format this central to the WAD ecosystem does not need a format-specific gate (ADR-0022 §3).

The four lump types

  • Picture (patches and sprites): an 8-byte header of four little-endian i16 fields (width, height, left_offset, top_offset), followed by exactly width little-endian i32 column offsets counted from the start of the lump. Each column is a chain of posts: top_delta (u8; 0xFF terminates the chain), length (u8), a padding byte, length pixel bytes, and a trailing padding byte. top_delta is plain, not cumulative — vanilla has no DeePsea-style “tall patch” handling (ADR-0022 §3).
  • Playpal: N × 768 bytes (256 RGB entries per palette), with no count field on disk — the palette count is derived from the lump’s length (len / 768). Strict mode rejects a length that is not a positive multiple of 768; lenient mode truncates the remainder and warns (ADR-0022 §3).
  • Colormap: N × 256 bytes (NUMCOLORMAPS is a vanilla compile-time constant of 32, not a value read from the lump, and the engine loads the lump with no size check). Strict mode requires a whole number of 256-byte tables totaling at least 8192 bytes (the 32-table floor); lenient mode zero-pads a short lump to 8192 or truncates a long one’s trailing partial table (ADR-0022 §3, corrected by the §3 amendment). Retail lumps carry 34 tables — id, Freedoom, Raven, and Rogue all ship 8704 bytes — and every table is exposed via tables().
  • Flat: a raw 64×64 blob, at least 4096 bytes — an assumption vanilla makes only at render time, never validated against the lump’s actual length at load. Strict mode requires a whole number of 64-byte rows totaling at least 4096 bytes (accepting Heretic’s 4160-byte and Hexen’s 8192-byte retail flats); lenient mode keeps the actual bytes and warns (ADR-0022 §3, corrected by the §3 amendment).

Strictness policy

Every lump type follows the crate-wide strict/lenient contract (ParseOptions::strict()/ParseOptions::lenient()): strict mode returns the first GfxError encountered; lenient mode recovers with a best-effort value and records the matching GfxWarning — with one exception: a picture lump under 8 bytes has no header to recover from and errors in both modes.

ConditionStrictLenient
Picture lump under 8 bytes (no header to recover from)GfxError::TruncatedPictureError in both modes
Picture lump under 8 + width × 4 bytes (offset table truncated)GfxError::TruncatedPictureWidth clamped to the offsets present; GfxWarning::TruncatedPicture
Negative picture width/heightGfxError::NegativeDimensionClamped to 0; GfxWarning::NegativeDimension
Column offset outside the lump (including a negative offset)GfxError::ColumnOffsetOutOfBoundsColumn left empty; GfxWarning::ColumnOffsetOutOfBounds
Post chain runs past the lump end without a 0xFF terminatorGfxError::UnterminatedColumnPosts fully read so far are kept; GfxWarning::UnterminatedColumn
A post’s rows exceed the picture heightGfxError::PostOutOfBoundsClipped to the picture height (dropped if entirely out of bounds); GfxWarning::PostOutOfBounds
Cumulative post-chain bytes consumed exceed the lump length (aliased column offsets)GfxError::ExcessivePostDataRemaining columns left empty; GfxWarning::ExcessivePostData
PLAYPAL length not a positive multiple of 768GfxError::PlaypalSizeRemainder truncated (zero palettes for a zero-length lump); GfxWarning::PlaypalSize
COLORMAP length not a 256-byte multiple of at least 8192GfxError::ColormapSizeZero-padded to 8192 (short) or truncated to whole tables (long); GfxWarning::ColormapSize
Flat length not a 64-byte multiple of at least 4096GfxError::FlatSizeActual bytes kept as parsed (to_indexed pads or truncates to 4096); GfxWarning::FlatSize

The consumed-bytes budget behind ExcessivePostData is a hardening addition beyond the spec’s plain post-chain description (ADR-0016 §1): cumulative bytes actually consumed across all posts and columns (4 + pixel length per post) is capped at the lump length, closing an O(width × length) blowup that aliased column offsets would otherwise allow.

Worked example

#![allow(unused)]
fn main() {
use crustywad::{ParseOptions, SectionKind, Wad};
use crustywad::gfx::Picture;

fn run(wad: &Wad) -> Result<(), Box<dyn std::error::Error>> {
let sections = wad.sections()?;
let Some(palette) = wad.playpal()? else {
    return Ok(()); // no PLAYPAL in this WAD
};

for section in sections.of_kind(SectionKind::Sprites) {
    for i in section.lumps.clone() {
        let bytes = wad.lump_bytes(i).expect("valid lump index");
        if bytes.is_empty() {
            continue; // nested sub-namespace marker
        }
        let pic = Picture::parse(bytes, &ParseOptions::strict())?;
        let rgba = pic.to_rgba(&palette.palettes()[0]);
        // `rgba.pixels` is `width * height * 4` bytes, row-major RGBA8.
        let _ = rgba;
    }
}
Ok(())
}
}

Picture::to_indexed produces an IndexedImage (palette indices plus a coverage mask — posts don’t have to cover every row of every column); Picture::to_rgba composes that with a Palette in one step. Flat has the same to_indexed/to_rgba pair, always fully covered since a flat has no post gaps.

Doom 64 graphics

Doom 64’s texture, sprite, and gfx lumps are complete PNG files, not this format (ADR-0022 §3/§5). They are decoded separately, behind the optional doom64-gfx feature — see that page for Doom64Png’s usage, the png dependency, and the Limits::max_decoded_pixels cap.

Texture composition

crustywad::gfx::TextureSet is “tier 2” of ADR-0022 §3’s three-tier plan: assembling TEXTURE1/TEXTURE2 texture definitions plus the PNAMES patch-name table and their resolved Picture lumps into named, multi-patch composite images — reimplementing the contract of vanilla’s R_GenerateComposite/R_GenerateLookup.

Worked example

#![allow(unused)]
fn main() {
use crustywad::{ParseOptions, Wad};

fn run(wad: &Wad) -> Result<(), Box<dyn std::error::Error>> {
let Some(set) = wad.texture_set()? else {
    return Ok(()); // no TEXTURE1/TEXTURE2 in this WAD
};
let Some(index) = set.find("STARTAN2") else {
    return Ok(()); // this WAD doesn't define the texture
};
let (image, warnings) = set.compose(index, &ParseOptions::strict())?;
assert!(warnings.is_empty()); // strict mode: no warnings ever accompany Ok
// `image` is an `IndexedImage` (palette indices + a coverage mask); apply a
// palette in the same step with `compose_rgba` instead when RGBA8 is wanted:
let palette = wad.playpal()?.map(|p| p.palettes()[0].clone());
if let Some(palette) = palette {
    let (rgba, _) = set.compose_rgba(index, &ParseOptions::strict(), &palette)?;
    let _ = rgba; // width * height * 4 bytes, row-major RGBA8
}
Ok(())
}
}

Wad::texture_set() (strict) / Wad::texture_set_with_options() (either mode) build the set once; TextureSet::compose/compose_rgba are then called per texture, as many times as needed — the resolved patch pictures are shared across every compose call.

Build strictness policy

Building the set parses TEXTURE1/TEXTURE2 (see the Strictness policy table above for those rows) and PNAMES, then resolves and validates every patch reference:

ConditionStrictLenient
TEXTUREx present but no PNAMES lump existsGfxError::MissingPnamesSet built with an empty name table; GfxWarning::MissingPnames
A patch reference indexes past the resolved PNAMES table (including a negative index)GfxError::PatchIndexOutOfBoundsReference ignored; GfxWarning::PatchIndexOutOfBounds — suppressed when the PNAMES lump is absent entirely (MissingPnames already explains every reference)
A resolved patch name matches no lump in the WADGfxError::UnresolvedPatchNamePatch left unresolved; GfxWarning::UnresolvedPatchName
A resolved patch lump fails to parse as a PictureGfxError::PatchPictureFailedPatch left unresolved; GfxWarning::PatchPictureFailed

The PWAD reality. Patch names resolve through the crate’s first-match Wad::lump_by_name after uppercasing (vanilla uppercases its own search name too). A PWAD that references patches shipped only in the base IWAD therefore cannot resolve them here — multi-WAD merge (loading a PWAD layered over its IWAD) is out of scope for a single Wad — so a retail PWAD’s strict texture_set() commonly fails with GfxError::UnresolvedPatchName even though the WAD is perfectly well-formed; building leniently instead recovers with those patches left unresolved (composing them draws holes rather than failing).

Compose strictness policy

TextureSet::compose composites one texture already validated at build time:

ConditionStrictLenient
Negative composed width/heightGfxError::NegativeDimensionClamped to 0 (see the picture NegativeDimension row above)
width × height exceeds Limits::max_composite_pixelsGfxError::CompositeTooLarge in both modesGfxError::CompositeTooLarge in both modes
A composited column has no contributing patch (the Medusa case)GfxError::MedusaColumnColumn(s) left as holes; GfxWarning::MedusaColumns

Limits::max_composite_pixels (default 1 << 24) bounds the pixel buffer a single compose call allocates. A TEXTUREx header can declare a 32767×32767 canvas (nearly 1 GiB) from a 30-byte lump, so this cap is enforced in both strictness modes — the same DoS-cap exception to the strict/lenient contract that the UDMF nesting-depth limit uses (ADR-0016): an oversized composite is a resource-exhaustion risk, not a recoverable parse anomaly, so lenient mode does not clamp past it and instead returns the same error strict mode does.

Medusa policy vs. vanilla (ADR-0022 §3). Vanilla’s R_GenerateLookup handles a texture column with no contributing patch by printing “column without a patch” and returning early from the entire function, leaving every later column’s composite state uninitialized — a silent, partial, engine-visible bug (the well-known “Medusa effect”). A stricter I_Error abort exists in the vanilla source but is commented out. compose instead treats the Medusa case as a Strictness::Strict error and a Strictness::Lenient warning-with-hole: the column decodes with an explicit gap rather than either aborting the whole texture or silently leaving other columns corrupt — deliberately better than either of vanilla’s two behaviors. Dead patch references (an unresolved or out-of-bounds PNAMES index) never count as contributors, even though vanilla’s own column-contributor count includes them regardless of lookup failure; only live, resolved patches count here, which is what the explicit-holes model requires.

What’s next

Doom 64’s graphics are a different family entirely — see the Doom 64 graphics note above; PNG decoding lives behind the doom64-gfx feature.

CLI Usage

The cwad binary ships with the crustywad-cli crate and provides quick WAD inspection from the command line.

Installation

Build and install from the workspace:

cargo install --path crates/crustywad-cli

Or run directly without installing:

cargo run -p crustywad-cli -- <subcommand> [options] <file.wad>

Synopsis

cwad [OPTIONS] <COMMAND>

Subcommands

info

Print a WAD summary: the kind (Iwad or Pwad), total lump count, data size, detected maps, an audio-lump tally, and — when the WAD positively identifies as a specific game family — a game: line.

$ cwad info doom.wad
kind:      Iwad
lumps:     1264
data size: 4194304 bytes
maps:      E1M1, E1M2
audio:     midi: 3, digital: 12

A game: line appears only when the WAD positively identifies as a specific game family (currently Strife, via its dialogue lumps — see Game identification); a Doom WAD prints none.

list

Print the full lump directory. Each line contains the zero-based index, the file offset (filepos), the byte size, and the lump name.

$ cwad list doom.wad
0000       12     1160 PLAYPAL
0001     1172     4096 COLORMAP
0002     5268        0 ENDOOM
...

Column order: index filepos size name.

validate

Check whether a WAD file parses without errors and exits with the appropriate code (see Exit codes).

$ cwad validate doom.wad
ok: doom.wad

On a corrupt file:

$ cwad validate broken.wad
error: broken.wad: invalid WAD magic

The error message goes to stderr in human format; the exit code is 2.

Deep validation

--deep goes beyond the header and directory: after the container parses, every map in the WAD is assembled — all four formats, including Doom 64 nested-WAD maps — with per-map errors and warnings reported. Validation continues past a failing map so one corrupt map cannot mask another.

$ cwad validate --deep doom.wad
ok: doom.wad (36 map(s) validated)

On a WAD whose E1M1 has a corrupt lump:

$ cwad validate --deep broken.wad
error: map E1M1: failed to decode LINEDEFS records: record stream ended mid-record at byte offset 0
error: broken.wad: 1 of 2 map(s) failed validation

Per-map diagnostics go to stderr; the exit code is 1 if any map fails — ADR-0008’s “validation errors found” code, distinct from 2 (the container itself is unreadable or malformed). The strictness flag applies: under --lenient, recoverable per-map issues become warnings on stderr and the exit code stays 0. In JSON format, --deep emits one newline-delimited record per map ({"map":"E1M1","ok":true,"warnings":0} or {"map":"E1M1","ok":false,"error":"..."}) followed by the usual summary object; in CSV it emits a map,ok,error table instead of the shallow ok/true pair.

merge

Combine multiple WAD files into one, writing lumps in the order the input files are given.

$ cwad merge base.wad patch.wad --output combined.wad

Use --kind to set the output WAD kind (iwad or pwad; default pwad). Lump-name or size validation failures during the write exit 3.

diff

Compare two WAD files lump by lump: same lump names, same count of each name, and same data for each occurrence. Directory order of distinct lump names does not matter; for a name that appears more than once, the sequence of occurrences is compared in directory order. Exits 0 if identical, 1 if any differences are found, or 2 on I/O or parse error.

$ cwad diff doom.wad doom-modified.wad
Only in doom.wad:  DEMO1
Changed:           E1M1

extract

Extract lumps from a WAD file into a directory (which must already exist). Extracts every lump by default, or only the occurrences of one lump name via --lump/-l. Each lump is written as <SANITIZED_NAME>.bin; when two or more lumps sanitize to the same filename, later ones get a _1, _2, … suffix.

$ cwad extract doom.wad --output ./out
PLAYPAL.bin
COLORMAP.bin
...

build

Build a new WAD file from NAME=FILE lump specifications, added to the output in the order listed.

$ cwad build --output custom.wad E1M1=e1m1.lmp PLAYPAL=playpal.lmp
wrote custom.wad: kind=Pwad lumps: 2

Use --kind iwad to build an IWAD instead of the default PWAD. Lump-name or size validation failures exit 3.

Building nodes: build --nodes

Pass --nodes to rebuild, after packing, every Doom-format map group in the output with engine-playable node lumps via the add_doom_map_with_nodes one-shot — the BSP tree (SEGS/SSECTORS/NODES), the collision BLOCKMAP, and the all-clear REJECT — every Hexen-format map group via an in-place node-lump splice (below), and every UDMF-format map group with a built ZNODES stream (a GL dialect by default; see --node-format below), replacing any existing ZNODES (or inserted right after TEXTMAP if the group has none) with the rest of the group’s lumps carried through unchanged:

$ cwad build --nodes -o playable.wad MAP01=map01.lmp THINGS=things.lmp ...
wrote playable.wad: kind=Pwad lumps: 11

All of a rebuilt Doom group’s node lumps — SEGS/SSECTORS/NODES, the REJECT visibility table, and the BLOCKMAP — are overwritten with the newly built ones, whether they were packed as empty placeholders or already held data. The map’s packed VERTEXES lump can also grow: the BSP pass appends any split vertices it creates to it.

A Hexen group is patched in place instead of reassembled: THINGS, LINEDEFS, SIDEDEFS, SECTORS, and BEHAVIOR carry through byte-verbatim, while SEGS/SSECTORS/NODES are rebuilt for whichever --node-format is in effect — Hexen accepts every format, including the classic default, using the same carrier conventions as a Doom group. REJECT and BLOCKMAP are always rebuilt, so a hand-tuned REJECT is replaced with the engine-safe all-zeros table. The group is re-emitted in the canonical THINGSBEHAVIOR order, since vanilla-class engines index a map’s lumps by offset from the marker; a corrupt node lump among the group’s own five (SEGS/SSECTORS/NODES/REJECT/BLOCKMAP) is repaired rather than fatal, but a separate in-WAD GL_<mapname> sidecar is not — a corrupt sidecar still strict-fails assembly (--lenient recovers) and a stale one passes through verbatim beside the rebuilt lumps. A map using polyobjects prints a warning that the rebuilt nodes may split a polyobject’s subsector; it fires on both the vanilla Hexen (3000–3002) and ZDoom Doom-in-Hexen (9300–9303) editor numbers and is advisory, since 3001/3002 are also the Doom Imp/Demon (polyobject-aware splitting is tracked in #389). See Building nodes for the full splice details.

Doom 64 (#353) map groups remain the only ones not yet supported by --nodes; they are passed through unchanged with a note on stderr. Non-map lumps always pass through unchanged; if none of a Doom, Hexen, or UDMF map group is found, --nodes is a no-op and prints a note.

build --nodes takes the same --node-format <FORMAT> flag as convert --nodesclassic (default), the non-GL extended pair (xnod/znod), the four GL dialects (xgln/xgl2/xgl3/gl), and their z* zlib twins. A UDMF map group’s ZNODES stream accepts any of them: classic auto-selects gl (noted on stderr once per group); an explicit xnod/znod builds a non-GL extended stream instead. The classic BSP pass behind them is integer-precision, so a fractional-coordinate UDMF map is rejected in strict mode (naming the offending coordinate, with a --lenient hint) and rounded to the nearest whole unit with a warning in lenient mode — the rounding applies to the node stream only, the TEXTMAP keeps the fractional originals; a map that needs exact fractional geometry preserved should use a GL dialect instead:

$ cwad build --nodes --node-format gl -o playable.wad MAP01=map01.lmp THINGS=things.lmp ...
wrote playable.wad: kind=Pwad lumps: 11

See Choosing the on-disk node format for the full value table — it applies identically to build --nodes and convert --nodes. The global --lenient flag applies to the node build too — a strict-mode build failure exits 3, and, when the error is one lenient mode can recover, hints to re-run with --lenient. See Building nodes for the full picture.

convert

Convert every map in a WAD between the classic Doom binary format and UDMF, replacing each map’s lump run in place; non-map lumps, and maps already in the target format, pass through unchanged in directory order.

$ cwad convert doom.wad -o udmf.wad --to udmf
wrote udmf.wad: converted 1 map to udmf

--to is required and takes doom or udmf. Use --map NAME to convert only the named map (e.g. --map MAP01) and pass every other map through unchanged; omit it to convert every map in the WAD. A --map NAME that matches no map in the WAD is an error (exit 3), not a no-op. Use --kind to set the output WAD kind (iwad or pwad; default pwad).

Building nodes: --nodes

By default, --to doom emits empty SEGS/SSECTORS/NODES/REJECT/BLOCKMAP lumps and always prints a NodesNotBuilt warning to stderr — playable on the ZDoom family (which rebuilds nodes at load) but not on vanilla ports. Pass --nodes to build those lumps for real, so the output is engine-playable everywhere with no external nodebuilder pass:

$ cwad convert udmf.wad -o doom.wad --to doom --nodes --lenient
wrote doom.wad: converted 1 map to doom

--nodes builds the classic 16-bit node lumps via the nodebuild pipeline (add_doom_map_with_nodes): the BSP tree, the collision BLOCKMAP, and the all-clear REJECT. The NodesNotBuilt warning is then gone (the nodes exist). The global --lenient flag applies to the build too — it is often needed for real maps, whose geometry can contain the engine-tolerated mixed-sector fan that strict mode rejects (see Building nodes).

--nodes combined with --to udmf instead builds a ZNODES stream for each converted map — UDMF has no binary node lumps, so ZNODES is the only place the dialect selected by --node-format has to go. The default classic auto-selects gl and prints a note; any explicit value (GL or non-GL) needs no note:

$ cwad convert doom.wad -o out.wad --to udmf --nodes
note: --to udmf --nodes builds GL nodes (gl auto-format) into ZNODES for each converted map
wrote out.wad: converted 1 map to udmf

A source map already in UDMF is not converted — but --to udmf --nodes retrofits its ZNODES stream in place rather than passing the group through untouched: the group’s TEXTMAP bytes are re-emitted verbatim, any port lump in the group (DIALOGUE, BEHAVIOR) is preserved untouched, and a stale or corrupt existing ZNODES is replaced (or inserted right after TEXTMAP if the group has none). A per-group note reports the retrofit (is already UDMF; rebuilt ZNODES in place (map not converted)), and it is not counted in converted N maps — this is a patch, not a conversion. A map excluded from the run by --map passes through unchanged with no retrofit. cwad build --nodes remains the spec-based alternative: it rebuilds ZNODES in place directly from NAME=FILE lump specs, without needing a whole WAD as input.

--node-format <FORMAT> selects the on-disk form of the nodes --nodes builds; default classic. It has no effect without --nodes; a non-classic value passed without --nodes prints a note on stderr and is ignored. Besides classic, the values are the non-GL extended pair — xnod (uncompressed XNOD stream in NODES) and znod (its zlib-compressed twin) — and four GL dialects — xgln, xgl2, xgl3, and gl (auto-selects the minimal sufficient dialect) — each carried in SSECTORS instead of NODES (SEGS/NODES left empty). Every GL value also has a z* zlib-compressed twin (zgln/zgl2/zgl3/zgl). All z* values, GL and non-GL alike, require cwad built with the extended-nodes-zlib feature (on by default) — without it, a z* value that actually takes effect (i.e. --nodes is in play) exits 3 with a clear error rather than a clap parse failure; without --nodes the flag is noted and ignored as described above.

--to udmf --nodes accepts any --node-format value. The ZNODES container can carry either a GL stream or the non-GL extended pair (XNOD/ZNOD) — engines accept both. The default classic auto-selects gl; an explicit xnod/znod builds that non-GL stream instead. The classic BSP pass behind them narrows coordinates through the shared integer write path, so a fractional-coordinate UDMF map exits 3 in strict mode, naming the offending coordinate and hinting at --lenient (lenient rounds for the node stream only — the TEXTMAP keeps the fractional originals):

$ cwad convert fractional.wad -o out.wad --to udmf --nodes --node-format xnod
error: failed to build nodes for map MAP01: fractional x 0.5 in vertex #0 cannot be stored as an i16
note: re-run with --lenient to build anyway

--lenient instead rounds the fractional coordinate to the nearest whole map unit and reports a warning; a map that needs the fractional geometry preserved exactly should use a GL dialect (gl, or one of xgln/xgl2/ xgl3), which has no such precision ceiling.

See Choosing the on-disk node format for the full value table.

Strict mode refuses data loss. Converting a typical ZDoom-namespace UDMF map (linedef args, thing height/id/special, …) to doom exits 3, naming the offending field on stderr:

$ cwad convert udmf.wad -o doom.wad --to doom
error: cannot convert map MAP01 to doom: thing #0 has a height value, which the Doom format cannot represent
note: re-run with --lenient to accept the data loss

This is intended, not a bug: --to doom succeeding is the answer to “does this map fit in the Doom format?” Pass the global --lenient flag to accept the loss and convert anyway; each dropped or rounded field is then reported as a warning on stderr instead. See Converting maps for the full loss policy.

A converted map keeps only the lumps its target format defines. A converted group is rebuilt from the assembled map: the marker plus TEXTMAP and ENDMAP (--to udmf), or the marker plus the classic THINGS/LINEDEFS/SIDEDEFS/VERTEXES/SECTORS run and the empty node lumps (--to doom). Any other lump that lived inside the map group — BEHAVIOR (compiled ACS), SCRIPTS, ZNODES, DIALOGUE, GL node lumps — is dropped. It is not passed through: compiled ACS is bound to the source map’s specials and node lumps describe the source geometry, so carrying either into a converted map would produce something that looks intact and is subtly broken. Dropping it is data loss, and is treated like any other:

$ cwad convert hexen.wad -o udmf.wad --to udmf
error: cannot convert map MAP01 to udmf: it contains lump(s) that cannot be carried into the converted map: BEHAVIOR
note: re-run with --lenient to convert anyway and drop them

With --lenient the conversion proceeds and each dropped lump is named in a warning on stderr. A map already in the target format is not converted, so nothing in its group is dropped.

Exits 0 on success, 2 on I/O or parse error, 3 if a map cannot be assembled, cannot be converted without loss in strict mode, or if --map NAME matches no map in the WAD.

Global options

FlagShortDescription
--lenientUse lenient parsing instead of strict when reading a WAD; attempts best-effort recovery for non-fatal issues and emits warnings to stderr. For build, also uses lenient instead of strict validation when writing
--format <FORMAT>-FOutput format: human (default), json, or csv
--help-hPrint help and exit 0
--version-VPrint version and exit 0

Lenient mode

In lenient mode cwad attempts best-effort recovery and prints warnings to stderr for any non-fatal issues encountered.

cwad --lenient info damaged.wad

Example output when the WAD magic is unrecognized:

kind:  Unknown([88, 87, 65, 68])
lumps: 3
warning: unrecognized WAD magic `XWAD`

Output formats

All subcommands accept the --format / -F flag, but merge does not currently produce any structured stdout output — it only writes the merged file, and warnings/errors still go to stderr regardless of format.

human (default)

Human-readable text written to stdout. Warnings and errors go to stderr.

json

Newline-delimited JSON (one object per record). Useful for scripting and piping into tools like jq.

cwad -F json info doom.wad
{"kind":"Iwad","lumps":1264}
cwad -F json list doom.wad
{"index":0,"filepos":12,"size":1160,"name":"PLAYPAL"}
{"index":1,"filepos":1172,"size":4096,"name":"COLORMAP"}
cwad -F json validate doom.wad
{"ok":true}

On parse failure the validate subcommand writes {"ok":false,"error":"..."} to stdout and exits 2.

csv

RFC 4180 CSV with a header row. Field values that contain commas, quotes, or newlines are wrapped in double-quotes with internal quotes doubled.

cwad -F csv info doom.wad
kind,lumps,data_size,maps,game
Iwad,1264,4194304,E1M1 E1M2,

The trailing game field is empty unless the WAD positively identifies (e.g. strife for a Strife WAD).

cwad -F csv list doom.wad
index,filepos,size,name
0,12,1160,PLAYPAL
1,1172,4096,COLORMAP
cwad -F csv validate doom.wad
ok
true

Exit codes

CodeMeaning
0Success
1Negative result — the two WADs differ (diff), or validate --deep found map validation errors
2I/O error or parse error (malformed WAD, missing file, etc.); for extract, also a nonexistent --output directory or a --lump name not found
3Usage error (unknown subcommand, invalid flag value, missing required argument, or a lump-name/size validation failure when writing for build, merge, or convert — note a non-ASCII lump name decodes under a lenient read but is rejected on write in both strictness modes); for convert, also a map that fails to assemble, a map that cannot be converted without loss in strict mode (including a group lump such as BEHAVIOR that the target format cannot carry), or a --map NAME that matches no map in the WAD; for build --nodes, also a Doom map group that fails to assemble or a node build that fails in strict mode

Man page

A man page (cwad.1) is generated into $OUT_DIR/man/ at build time via clap_mangen. To install it system-wide after building the crate, copy the generated file to the appropriate man directory, for example:

install -m 644 \
  "$(cargo build -p crustywad-cli --message-format=json \
      | jq -r 'select(.reason=="build-script-executed") | .out_dir')/man/cwad.1" \
  /usr/local/share/man/man1/cwad.1
mandb

Shell completions

Completion scripts for bash, zsh, and fish are generated into $OUT_DIR/completions/ at build time via clap_complete. Source the appropriate script for your shell to enable tab completion for cwad subcommands and flags.

Feature Flags

crustywad uses Cargo feature flags to keep the default dependency footprint small while allowing callers to opt in to additional capabilities.

Summary

FeatureDefaultPurpose
mmapnoMemory-mapped file loading via memmap2
freedoom-testsnoIntegration tests against local Freedoom WAD fixtures (auto-fetchable)
hexen-testsnoIntegration tests against a local Hexen IWAD (not auto-fetchable)
doom64-testsnoIntegration tests against a local Doom 64 IWAD (not auto-fetchable)
sweep-testsnoSweep test that assembles every map of every WAD in a local collection (not auto-fetchable)
guide-doctestsnoInternal, CI-only. Compiles this guide’s Rust code samples as crate doctests (enabled by --all-features); not a runtime capability
writenoWAD serialization — WadBuilder, WriteError, WriteOptions, WriteWarning
nodebuildnoClean-room node-lump builders (enables write) — map::build, build_blockmap, build_reject, build_nodes (the classic BSP pass: SEGS/SSECTORS/NODES), the add_doom_map_with_nodes engine-playable one-shot, and the to_lump_bytes serializers; also emits the XNOD/ZNOD non-GL stream via NodeFormat, plus the GL XGLN/XGL2/XGL3 streams (and their Z* twins with extended-nodes-zlib), with NodeFormat::Gl auto-selecting the minimal dialect, via build_gl_nodes (ADR-0025, ADR-0026), and a UDMF one-shot (add_udmf_map_with_nodes) that builds a ZNODES stream for a UDMF map group; powers cwad convert --nodes and cwad build --nodes, including UDMF ZNODES output — GL dialects by default, xnod/znod on explicit request
doom64-gfxnoDoom 64 PNG texture/sprite decoding via pngDoom64Png, capped by Limits::max_decoded_pixels
extended-nodes-zlibnoDecode the zlib-compressed ZDoom extended node formats (ZNOD/ZGLN/ZGL2/ZGL3) via miniz_oxide, bounded by Limits::max_decoded_node_bytes; with nodebuild also enabled, also powers the nodebuild ZNOD and Z* GL writers

mmap

Enables: Wad::from_path_mapped and Wad::from_path_mapped_with_options

Adds dependency: memmap2

Memory-maps the WAD file instead of reading it into a Vec<u8>. On large WADs this avoids a heap allocation equal to the file size and lets the OS page in only the bytes that are actually accessed. The tradeoff is a small amount of unsafe code in mmap.rs (the only unsafe in the library crate) to call memmap2::MmapOptions::map.

Wad::from_path (the non-mapped variant) always reads the whole file into memory regardless of whether this feature is enabled.

Usage

# Cargo.toml
crustywad = { version = "0.9.0", features = ["mmap"] }
#![allow(unused)]
fn main() {
use crustywad::{Wad, ParseOptions};

// Zero-copy load from disk:
let _wad = Wad::from_path_mapped("doom.wad")?;

// Zero-copy load with options:
let _wad = Wad::from_path_mapped_with_options("doom.wad", ParseOptions::lenient())?;
Ok::<(), crustywad::ParseError>(())
}

When to use mmap

Memory-mapped loading is useful for large WADs when you only need to access a subset of lumps. The OS maps the file into the address space without copying all bytes into heap memory upfront — pages are faulted in on demand.

For small WADs or when you will access most lumps, Wad::from_path (which reads into a Vec<u8>) is equally fast and has simpler lifetime semantics.

The parse/from_path benchmark group measures both variants side by side. See the Performance page for live throughput data and how to run the benchmarks locally.

Platform notes

memmap2 is supported on all tier-1 Rust targets (Linux, macOS, Windows). Memory-mapped files are read-only; there is no risk of accidentally writing to the underlying file.

Warning: the WAD file must not be truncated or replaced by another process while the Wad is alive. On Unix, truncation from another process triggers a SIGBUS on the next lump data access, which will abort the process. On Windows the mapping prevents truncation but concurrent writes by another process may expose inconsistent data. Use Wad::from_path if the file may be modified externally while in use.


freedoom-tests

Enables: integration tests in crates/crustywad/tests/freedoom.rs

Adds dependency: none (test-only fixture files on disk)

Gates optional tests that parse real Freedoom WAD files. Tests skip gracefully when CRUSTYWAD_FREEDOOM_DIR is not set or when the expected WAD files are not present in that directory — they do not fail.

Fetching fixtures

# Default version (configured in tests/fixtures/fetch_freedoom.py):
just fetch-fixtures

# Specific Freedoom release:
just fetch-fixtures version=v0.14.0

Running the tests

# Using just — defaults CRUSTYWAD_FREEDOOM_DIR to an absolute path under the repo root:
just test-freedoom

# Override the fixture directory:
just test-freedoom dir=/path/to/freedoom

# Or run cargo directly. The path must be ABSOLUTE: cargo sets the test binary's
# working directory to the package root (crates/crustywad), so a relative path
# never resolves and the fixture tests skip silently.
CRUSTYWAD_FREEDOOM_DIR="$PWD/tests/fixtures/freedoom" \
  cargo test -p crustywad --features freedoom-tests

CI

CI runs cargo test --workspace --all-features, which enables the freedoom-tests feature flag. The tests skip gracefully when CRUSTYWAD_FREEDOOM_DIR is not set — and CI never sets it because the fixture WADs are gitignored and not downloaded in the standard CI pipeline.


hexen-tests

Enables: integration tests in crates/crustywad/tests/hexen.rs

Purpose

Gates an optional smoke test that parses a real Hexen IWAD. Unlike Freedoom, Hexen’s IWAD is not freely redistributable, so there is no fetch script and no committed fixture — supply your own copy locally.

Running the tests

Point CRUSTYWAD_HEXEN_DIR at a directory containing hexen.wad:

CRUSTYWAD_HEXEN_DIR=/path/to/hexen \
  cargo test -p crustywad --features hexen-tests

The test skips gracefully when CRUSTYWAD_HEXEN_DIR is unset or the file is missing.


doom64-tests

Enables: integration tests in crates/crustywad/tests/doom64.rs

Purpose

Gates an optional smoke test that parses a real Doom 64 IWAD. Like Hexen, the Doom 64 IWAD is not freely redistributable — no fetch script, no committed fixture; supply your own copy locally.

Running the tests

Point CRUSTYWAD_DOOM64_DIR at a directory containing doom64.wad:

CRUSTYWAD_DOOM64_DIR=/path/to/doom64 \
  cargo test -p crustywad --features doom64-tests

The test skips gracefully when CRUSTYWAD_DOOM64_DIR is unset or the file is missing.


sweep-tests

Enables: the integration test in crates/crustywad/tests/sweep.rs

Purpose

Gates the retail-WAD sweep: for every WAD file in a caller-supplied directory, it parses the container strictly, assembles every map group in both strictness modes (reading Doom 64 nested-WAD maps through read_doom64_map), and asserts zero errors and zero warnings throughout — no allowlist. It is the regression net for the map read path against real retail data. Retail WADs are not freely redistributable — no fetch script, no committed fixture; supply your own collection locally.

Running the tests

Point CRUSTYWAD_SWEEP_DIR at a directory of WAD files. Use an absolute path — cargo runs the test binary with its CWD at the package root (crates/crustywad), so a relative path resolves against that directory rather than the workspace root and can miss (or accidentally hit the wrong) collection, leaving only a stderr skip note:

CRUSTYWAD_SWEEP_DIR=/path/to/wads \
  cargo test -p crustywad --features sweep-tests --test sweep

Or use the just recipe, which defaults to the repository’s gitignored RETAIL/ directory as an absolute path (an explicit dir= override should also be absolute):

just test-sweep              # sweeps ./RETAIL
just test-sweep dir=/path/to/wads

The test skips gracefully when CRUSTYWAD_SWEEP_DIR is unset or contains no WAD files.


guide-doctests

Enables: compiling this guide’s own Rust code samples as crate doctests (crates/crustywad/src/guide_doctests.rs)

Adds dependency: none

Internal, CI-only. The harness pulls each guide page into the crate via #[doc = include_str!(...)] so that cargo test --doc --all-features compiles (and runs, where not no_run) every ```rust block the guide presents as real code — catching API drift in a sample before it ships. It is not a runtime capability; a library consumer never needs it.

The module is gated cfg(all(doctest, feature = "guide-doctests", has_guide_sources)). build.rs sets has_guide_sources only when the repo-level docs/guide/src/ files exist, so enabling the feature outside the source workspace (e.g. on the packaged crate, where those files are absent) is a graceful no-op rather than a missing-file compile error. CI runs it via the existing cargo test --workspace --all-features; just guide-test runs it locally.


write

Enables: WadBuilder, WriteError, WriteWarning, WriteOptions, and Wad::to_builder

Adds dependency: none (uses binrw already in the dependency tree)

Adds WAD serialization support. WadBuilder accumulates lumps and serializes them to a Vec<u8> in the canonical Doom WAD layout: [12-byte header][lump data blobs][16-byte directory entries].

Usage

# Cargo.toml
crustywad = { version = "0.9.0", features = ["write"] }
#![allow(unused)]
fn main() {
use crustywad::{WadBuilder, WadKind};

// Build a new PWAD from scratch:
let bytes = WadBuilder::new(WadKind::Pwad)
    .add_lump("MAP01", b"data")
    .build()
    .unwrap();

assert!(crustywad::Wad::from_bytes(bytes).is_ok());
}

Round-tripping a parsed WAD

#![allow(unused)]
fn main() {
use crustywad::{Wad, WadBuilder, WadKind};

let mut source = Vec::new();
source.extend_from_slice(b"PWAD");
source.extend_from_slice(&0_i32.to_le_bytes());
source.extend_from_slice(&12_i32.to_le_bytes());
let wad = Wad::from_bytes(source).unwrap();
let rebuilt = wad.to_builder().build().unwrap();
}

Validation and error handling

WadBuilder::build uses strict mode by default. Use build_with_options with WriteOptions::lenient() to collect recoverable issues as WriteWarning values instead:

  • Names with NUL bytes or non-ASCII bytes always error in both modes.
  • Names longer than 8 bytes: strict mode returns WriteError::NameTooLong; lenient mode truncates and emits WriteWarning::NameTruncated.
  • WadKind::Unknown magic: strict mode returns WriteError::UnknownMagicStrict; lenient mode writes the raw 4-byte magic.

nodebuild

Enables: the map::build module — NodeBuildOptions, NodeBuildError, NodeBuildWarning, build_blockmap, build_reject, build_nodes (the classic BSP pass), add_doom_map_with_nodes (the engine-playable one-shot), and the nodebuild-gated to_lump_bytes serializers on the read-side lump types (MapBlockmap, MapReject, and BuiltNodes) — plus BuiltNodes::to_extended_lump_bytes, which serializes an XNOD/ZNOD ZDoom extended-node stream instead of the classic three-lump layout

Adds dependency: none — implies write

Clean-room BLOCKMAP, REJECT, and classic BSP (SEGS/SSECTORS/NODES) generation from an assembled Map (ADR-0024) — together the full set of node lumps a vanilla engine needs. It fulfills the revisit condition add_doom_map left open: that path deliberately emits zero-length SEGS/SSECTORS/NODES/REJECT/BLOCKMAP with an always-on DoomWriteWarning::NodesNotBuilt, whereas the nodebuild builders produce those lumps for real. Coordinate narrowing is shared with the write path (ADR-0024 §3), so a builder operates on exactly the i16 geometry the engine reads.

build_reject returns the correctly-sized all-zeros REJECT (ceil(sectors² / 8) bytes) — an all-clear table pre-rejects no line of sight, which is always engine-correct and is what zdbsp itself emits. build_blockmap builds the packed 128-unit-grid BLOCKMAP (deduplicated blocklists, strict/lenient offset-ceiling policy per ADR-0024 §5). build_nodes is the classic BSP pass: it partitions the map on seg lines into a deterministic SEGS/SSECTORS/NODES tree (BuiltNodes), narrowing through the same write-path pass. It is validated against the full retail collection — 551 classic maps build clean, save for the mixed-sector fan (two sectors meeting at a bare corner vertex, which no seg line can separate): strict build_nodes rejects such a map, and lenient accepts the leaf with a NodeBuildWarning::MixedSectorSubsector — the exact engine-tolerated output the retail masters themselves ship (ADR-0024 §7 amendment, 2026-07-19).

The add_doom_map_with_nodes one-shot bundles all three builders (plus the five data lumps) into a single call that adds a complete, engine-playable map group to a WadBuilder — the same path cwad convert --to doom --nodes runs. See the Building nodes guide page for when you need built nodes, the tolerated mixed-sector fan, and when GL/extended nodes still call for an external tool.

NodeBuildOptions::format (a NodeFormat, ADR-0025 §Amendment #323) selects the on-disk node encoding build_nodes/add_doom_map_with_nodes target: NodeFormat::Classic (the default, unchanged from above) writes the vanilla SEGS/SSECTORS/NODES lumps; NodeFormat::Xnod (or, with extended-nodes-zlib, NodeFormat::Znod) instead serializes a single ZDoom non-GL extended-node stream in NODES via BuiltNodes::to_extended_lump_bytes, leaving SEGS/ SSECTORS empty. The extended formats widen the subsector/node/seg/vertex ceilings from the vanilla 15/16-bit limits to a 31-bit structural cap, so a past-vanilla map can serialize — though a seg’s linedef reference stays a 16-bit field in the non-GL XNOD/ZNOD streams, so a map with more than 65,536 linedefs is unrepresentable there. The GL formats lift that in stages: build_gl_nodes (and add_doom_map_with_nodes) emit an XGLN, XGL2, or XGL3 stream (or their zlib twins ZGLN/ZGL2/ZGL3) via BuiltGlNodes::to_extended_lump_bytes, carried in SSECTORS; XGLN keeps a 16-bit seg linedef but reserves 0xFFFF as the miniseg sentinel (so its largest real linedef index is 0xFFFE), XGL2 widens it to u32, and XGL3 additionally allows fractional (sub-unit) node partitions. NodeFormat::Gl/NodeFormat::Zgl auto-select the minimal dialect that fits the map, so callers who don’t need a specific dialect can request Gl and get the smallest stream that round-trips it. cwad convert --nodes/build --nodes expose the full set through --node-format (xgln/xgl2/xgl3/gl, plus their z* twins), each GL stream carried in SSECTORS.

Usage

# Cargo.toml
crustywad = { version = "0.9.0", features = ["nodebuild"] }

Or with cargo add:

cargo add crustywad --features nodebuild
#![allow(unused)]
fn main() {
use crustywad::map::build::{NodeBuildOptions, build_blockmap, build_nodes, build_reject};
use crustywad::map::write_doom_map;
use crustywad::{WadBuilder, WadKind, WriteOptions};

fn run(map: &crustywad::map::Map) -> Result<(), Box<dyn std::error::Error>> {
let reject = build_reject(map); // infallible: ceil(sectors² / 8) all-zero bytes
let (blockmap, _warnings) = build_blockmap(map, &NodeBuildOptions::strict())?;

// The classic BSP pass: SEGS/SSECTORS/NODES. Lenient tolerates the mixed-sector
// fan the retail masters ship (ADR-0024 §7 amendment); strict rejects it.
let (nodes, _warnings) = build_nodes(map, &NodeBuildOptions::lenient())?;
let node_lumps = nodes.to_lump_bytes()?;

// The five data lumps. When the BSP pass splits segs it creates new vertices;
// `split_vertexes` MUST be appended to VERTEXES or the segs' vertex indices
// (which address the map's vertices followed by the split ones) dangle.
let (mut data, _warnings) = write_doom_map(map, &WriteOptions::strict())?;
data.vertexes.extend_from_slice(&node_lumps.split_vertexes);

let mut builder = WadBuilder::new(WadKind::Pwad);
builder
    .add_lump("MAP01", b"")
    .add_lump("THINGS", data.things)
    .add_lump("LINEDEFS", data.linedefs)
    .add_lump("SIDEDEFS", data.sidedefs)
    .add_lump("VERTEXES", data.vertexes) // map vertices + split vertices
    .add_lump("SEGS", node_lumps.segs)
    .add_lump("SSECTORS", node_lumps.ssectors)
    .add_lump("NODES", node_lumps.nodes)
    .add_lump("SECTORS", data.sectors)
    .add_lump("REJECT", reject.to_lump_bytes())
    .add_lump("BLOCKMAP", blockmap.to_lump_bytes()?);
let _ = builder;
Ok(())
}
}

doom64-gfx

Enables: Doom64Png decoding of Doom 64’s PNG texture/sprite lumps via the png crate (indexed pixels + palette rows + grAb offsets, capped by Limits::max_decoded_pixels)

Adds dependency: png

Doom 64’s PC port stores its texture and sprite lumps as standard palette-indexed PNG files rather than the classic picture format (ADR-0022 §5) — a different lump family from the rest of crustywad::gfx, decoded separately behind this feature rather than unconditionally in the core crate. Doom64Png::decode parses the indexed pixel data, the embedded PLTE (up to 16 rows of 16 colors serving runtime palette variants), optional per-index tRNS alpha, and sprite draw offsets from a private grAb chunk (a big-endian i32 pair, the ZDoom convention). The declared width × height is checked against Limits::max_decoded_pixels — and a 65535-per-side cap — before any pixel buffer is allocated, fired in both strictness modes (the same DoS-cap exception TextureSet::compose’s composite limit uses).

Usage

# Cargo.toml
crustywad = { version = "0.9.0", features = ["doom64-gfx"] }

Or with cargo add:

cargo add crustywad --features doom64-gfx
#![allow(unused)]
fn main() {
use crustywad::gfx::Doom64Png;
use crustywad::ParseOptions;

fn run(png_bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let img = Doom64Png::decode(png_bytes, &ParseOptions::strict())?;

// Tier-2 view: palette indices plus a coverage mask.
let indexed = img.to_indexed();

// Full-color view: the PNG's own PLTE/tRNS, not `indexed`'s palette + boolean
// mask — Doom 64 PNGs carry per-index alpha that a boolean mask can't represent.
let rgba = img.to_rgba();
let _ = (indexed, rgba);
Ok(())
}
}

Strictness and limits

Doom64Png::decode follows the same ParseOptions::strict()/ParseOptions::lenient() contract as the rest of crustywad::gfx: strict mode returns the first GfxError encountered; lenient mode recovers with a best-effort value and records the matching GfxWarning. Limits::max_decoded_pixels (default 1 << 24) bounds the pixel buffer a single decode call allocates and is enforced in both modes, ahead of any allocation — see the Graphics guide page for how this fits alongside the rest of crustywad::gfx.


extended-nodes-zlib

Enables: reading the zlib-compressed ZDoom extended node formats (ZNOD/ZGLN/ZGL2/ZGL3) — the compressed twins of the uncompressed XNOD/XGLN/XGL2/XGL3 dialects — by inflating each to its uncompressed body and decoding it through the same parser

Adds dependency: miniz_oxide

ZDoom’s node builders (ZDBSP, GDBSP) can write the extended node data either raw (X*, read unconditionally since ADR-0025 §4, #326) or zlib-compressed (Z*). A compressed lump is [4-byte plaintext tag][zlib RFC1950 stream]; with this feature on, the assembler skips the tag, inflates the remaining bytes, and feeds the result to the same decoder its uncompressed twin uses — so a ZNOD lump yields BSP arenas byte-identical to the XNOD twin’s. The inflater is the pure-Rust miniz_oxide (no C dependency), used through its length-limited entry point so the decompressor stops at the cap rather than materializing an unbounded buffer from a malicious “zip bomb”. Off by default so the core build pulls in no decompressor. This covers both the binary NODES/SSECTORS seam and the UDMF ZNODES lump.

With the feature off, a recognized Z* signature keeps the extended-encoding gate: strict mode returns MapAssembleError::UnsupportedNodeEncoding, lenient mode skips the BSP arenas and records a warning — the geometry still assembles.

This feature is unrelated to two other node formats that decode as always-on core (no feature flag, since neither needs a decompressor): DeePBSP v4 (xNd4) and classic GL node lumps (GL_VERT/GL_SEGS/GL_SSECT/GL_NODES) — see Classic GL nodes in the map-records guide.

With nodebuild also enabled, this feature gates the write side too: NodeFormat::Znod (ADR-0025 §Amendment #323) and its GL twins NodeFormat::Zgln/Zgl2/Zgl3/Zgl (ADR-0026 #364, #365) only exist as variants when extended-nodes-zlib is on. It powers both the ZNOD and Z* GL writers: BuiltNodes::to_extended_lump_bytes(_, compressed: true) compresses the XNOD body, and BuiltGlNodes::to_extended_lump_bytes(_, format) compresses the selected GL dialect’s body for Zgln/Zgl2/Zgl3 (and the auto Zgl), each with miniz_oxide::deflate::compress_to_vec_zlib before prepending the matching four-byte tag. Requesting compressed output without this feature returns NodeBuildError::CompressionUnavailable rather than panicking.

Usage

# Cargo.toml
crustywad = { version = "0.9.0", features = ["extended-nodes-zlib"] }

Or with cargo add:

cargo add crustywad --features extended-nodes-zlib

Decoding is transparent — the compressed lump is inflated and decoded during normal map assembly:

#![allow(unused)]
fn main() {
use crustywad::map::Map;
use crustywad::{ParseOptions, Wad};

fn run(wad_bytes: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
let wad = Wad::from_bytes(wad_bytes)?;
let group = wad.map_group("MAP01").expect("MAP01");
// With `extended-nodes-zlib`, a compressed `ZNOD`/`ZGL*` node lump inflates
// and decodes into the map's BSP arenas exactly as an uncompressed `X*` lump.
let map = Map::assemble_with_options(&wad, &group, ParseOptions::strict())?;
let _ = (map.segs(), map.subsectors(), map.nodes());
Ok(())
}
}

Strictness and limits

The inflated output of a single compressed node lump is bounded by Limits::max_decoded_node_bytes (default 1 << 26, 64 MiB), enforced during inflation via miniz_oxide’s length-limited inflater — the decoder never allocates a buffer larger than the cap (ADR-0016 §1). Exceeding it is MapAssembleError::ExtendedNode { reason: DecodedSizeExceeded, .. } in strict mode, or a whole-BSP degrade-to-empty with one warning in lenient mode; an un-inflatable stream is CorruptStream under the same strict/lenient split. All other structural faults in the inflated body follow the same contract as the uncompressed decoder.


Common cargo invocations

GoalCommand
Build with all featurescargo build --workspace --all-features
Build with mmap onlycargo build -p crustywad --features mmap
Test with all featurescargo test --workspace --all-features
Test with mmap onlycargo test -p crustywad --features mmap
Test with Freedoom fixturesCRUSTYWAD_FREEDOOM_DIR=… cargo test -p crustywad --features freedoom-tests
Test with Hexen fixtureCRUSTYWAD_HEXEN_DIR=… cargo test -p crustywad --features hexen-tests
Test with Doom 64 fixtureCRUSTYWAD_DOOM64_DIR=… cargo test -p crustywad --features doom64-tests
Sweep a local WAD collectionCRUSTYWAD_SWEEP_DIR=… cargo test -p crustywad --features sweep-tests
Build with writecargo build -p crustywad --features write
Test with writecargo test -p crustywad --features write
Build with nodebuildcargo build -p crustywad --features nodebuild
Test with nodebuildcargo test -p crustywad --features nodebuild
Build with doom64-gfxcargo build -p crustywad --features doom64-gfx
Test with doom64-gfxcargo test -p crustywad --features doom64-gfx
Build with extended-nodes-zlibcargo build -p crustywad --features extended-nodes-zlib
Test with extended-nodes-zlibcargo test -p crustywad --features extended-nodes-zlib
Mid-iteration check (skips doctests + rustdoc)just ci-fast
Pre-push CI gate (fail-fast)just ci
Full CI check (adds build + deny)just ci-full

See the justfile for available just recipes including feature-specific aliases.

Performance

crustywad tracks performance via Criterion micro-benchmarks that run in CI on every push to main. Results are published as interactive trend charts so regressions are visible before they reach a release.


Live benchmark charts

crustywad.dev/dev/bench/

The chart page shows throughput and latency trends over time for every benchmark group. Each data point corresponds to a CI run on main (push or workflow_dispatch).

Benchmark groups

GroupWhat is measured
parse/from_bytes_strictWad::from_bytes — strict mode — on small (10 × 256 B), medium (100 × 4 KiB), and large (1 000 × 16 KiB) synthetic WADs
parse/from_bytes_lenientWad::from_bytes_with_options — lenient mode — on small (10 × 256 B), medium (100 × 4 KiB), and large (1 000 × 16 KiB) synthetic WADs
parse/from_pathWad::from_path and Wad::from_path_with_options on a tempfile-backed medium WAD; mmap variants when the mmap feature is enabled
lump_accesslump(idx), lump_by_name (first-match and worst-case last-match), lump_bytes, lump_data, lumps().iter().count(), clone, into_bytes
map_recordsparse_records::<T> for all eight classic map-record types (Thing, Linedef, Sidedef, Vertex, Seg, Subsector, Node, Sector) against 1 000 records each
write/build_strictWadBuilder::build — strict mode — on small / medium / large synthetic WADs
write/build_lenientWadBuilder::build_with_options(&WriteOptions::lenient()) on the same sizes
write/build_from_scratchWadBuilder populated entirely at runtime (10 or 100 lumps)
write/roundtripWad::from_bytesWad::to_builderWadBuilder::build end-to-end
freedoomWad::from_bytes, Wad::lump_by_name (hit and miss), and roundtrip on real Freedoom WAD files; skipped when CRUSTYWAD_FREEDOOM_DIR is not set

Throughput groups report MB/s via Throughput::Bytes so results scale naturally with input size. Latency groups report ns/iter.


Running benchmarks locally

Prerequisites

Benchmarks are part of the standard workspace and require a stable Rust toolchain. The just bench / just bench-open recipes also require just; if you prefer not to install it, use the equivalent cargo command directly:

cargo bench --all-features --benches

Quick run

# Run all benchmarks and print the path to the HTML report:
just bench

# Run benchmarks and open the HTML report in the default browser:
just bench-open

just bench-open uses open on macOS, xdg-open on Linux, and explorer on Windows.

The Criterion HTML report is written to:

target/criterion/report/index.html

Open it to see per-benchmark violin plots, regression detection, and historical comparisons between the last two runs on your machine.

Running a specific group

Pass a filter after -- to run only matching benchmarks:

# Only the lump_access group:
cargo bench --all-features --benches -- lump_access

# Only the write/roundtrip benchmarks:
cargo bench --all-features --benches -- "write/roundtrip"

Freedoom real-world benchmarks

The freedoom group is skipped by default. To enable it, fetch the fixtures first and point the environment variable at the directory:

just fetch-fixtures                         # downloads freedoom1.wad / freedoom2.wad
# Absolute path required — cargo runs the bench binary from the package root.
CRUSTYWAD_FREEDOOM_DIR="$PWD/tests/fixtures/freedoom" just bench

CI benchmark workflow

The bench.yml workflow runs on every push to main and on workflow_dispatch. It is non-blockingfail-on-alert: false ensures benchmark regressions never fail the run or block a merge.

Each run:

  1. Compiles and runs all Criterion bench targets with --output-format bencher.
  2. Uploads the Criterion HTML report as a downloadable GitHub Actions artifact (90-day retention) named criterion-html-report.
  3. Appends trend data to the gh-pages branch at dev/bench/ via github-action-benchmark, which powers the chart page at crustywad.dev/dev/bench/.

The bench workflow and the guide deploy workflow share a concurrency: group: gh-pages so they never write to the branch simultaneously.

To trigger a benchmark run manually without pushing to main:

gh workflow run bench.yml

Architecture

Audience: Library users and contributors

Workspace layout

The workspace contains two crates. crustywad-cli depends on crustywad; the library has no dependency on the CLI.

graph TD
    subgraph lib["crustywad  (library crate)"]
        lrs["lib.rs\nWad · WadHeader · Lump\nParseOptions · Strictness"]
        ers["error.rs\nParseError · ParseWarning"]
        mrs["map.rs\nThing · Linedef · Sidedef · Vertex\nSeg · Subsector · Node · Sector"]
        mmrs["mmap.rs\n(feature: mmap only)"]
        wrs["write.rs\nWadBuilder · WriteError\nWriteWarning · WriteOptions\n(feature: write only)"]
    end
    subgraph cli["crustywad-cli  (binary crate)"]
        mains["main.rs\ncwad — info · list · validate\nmerge · diff · extract · build subcommands"]
    end
    cli -->|"cargo dependency"| lib
    mmrs -. "feature = mmap\nadds memmap2 dependency" .-> memmap2(["memmap2\n(external crate)"])
    lrs -. "feature = write" .-> wrs

Feature flags

graph LR
    lib["crustywad"]
    lib -. "mmap" .-> mmap["Wad::from_path_mapped\nWad::from_path_mapped_with_options\nzero-copy loading via memmap2"]
    lib -. "freedoom-tests" .-> ft["integration tests against\nlocal Freedoom WAD fixtures\n(test-only, no runtime dependency)"]
    lib -. "write" .-> write["WadBuilder · WriteError · WriteWarning\nWriteOptions · Wad::to_builder()\nWAD serialization"]

Data Model

Audience: Library users

WAD on-disk layout

The header is always at offset 0 and is exactly 12 bytes. Lump data blobs can appear anywhere in the file; each directory entry’s filepos and size fields locate the blob. The lump directory sits at the byte offset stored in infotableofs (typically at the end of the file). Each directory entry is exactly 16 bytes and describes one lump.

flowchart TD
    subgraph Header["Header - 12 bytes at offset 0"]
        magic["magic\n4 bytes\n'IWAD' or 'PWAD'"]
        numlumps["numlumps\n4 bytes i32\nlump count"]
        infotableofs["infotableofs\n4 bytes i32\ndirectory offset"]
    end
    subgraph Data["Lump Data Blobs (variable)"]
        lump0["lump 0 data\n(variable)"]
        lump1["lump 1 data\n(variable)"]
        lumpN["... lump N data\n(variable)"]
    end
    subgraph Dir["Lump Directory - N x 16 bytes at infotableofs"]
        entry0["entry 0\nfilepos(4) + size(4) + name(8)"]
        entry1["entry 1\nfilepos(4) + size(4) + name(8)"]
        entryN["... entry N-1\nfilepos(4) + size(4) + name(8)"]
    end
    Header -- "infotableofs" --> Dir

Rust type relationships

The class diagram below shows the public API types in crustywad and how they relate to each other. Constructors return Result<Wad, ParseError>; in lenient mode the returned Wad carries zero or more ParseWarning values accessible via Wad::warnings(). Methods marked [mmap] are only available when the mmap feature flag is enabled; types and methods marked [write] are only available when the write feature flag is enabled.

classDiagram
    class Wad {
        +from_bytes(bytes) Result~Wad, ParseError~
        +from_bytes_with_options(bytes, opts) Result~Wad, ParseError~
        +from_path(path) Result~Wad, ParseError~
        +from_path_with_options(path, opts) Result~Wad, ParseError~
        +from_path_mapped(path) Result~Wad, ParseError~ [mmap]
        +from_path_mapped_with_options(path, opts) Result~Wad, ParseError~ [mmap]
        +kind() WadKind
        +header() &WadHeader
        +lump_count() usize
        +lumps() &[Lump]
        +lump(index) Option~&Lump~
        +lump_by_name(name) Option~&Lump~
        +lump_bytes(index) Option~&[u8]~
        +warnings() &[ParseWarning]
        +into_bytes() Vec~u8~
        +to_builder() WadBuilder [write]
    }
    class WadHeader {
        +kind WadKind
        +num_lumps usize
        +info_table_offset usize
    }
    class Lump {
        +name() &str
        +filepos() usize
        +size() usize
    }
    class WadKind {
        <<enumeration>>
        Iwad
        Pwad
        Unknown([u8; 4])
    }
    class ParseOptions {
        +strictness Strictness
        +strict() ParseOptions
        +lenient() ParseOptions
    }
    class Strictness {
        <<enumeration>>
        Strict
        Lenient
    }
    class ParseError {
        <<enumeration>>
        Io
        Header
        Directory
        InvalidMagic
        NegativeValue
        OutOfBounds
        NonAsciiName
        Overflow
    }
    class ParseWarning {
        <<enumeration>>
        InvalidMagic
        NegativeValue
        OutOfBounds
        NonAsciiName
        Overflow
    }
    class MapParseError {
        <<enumeration>>
        TrailingBytes
        Binrw
    }
    class WadBuilder {
        [write]
        +new(kind) WadBuilder
        +add_lump(name, data) &mut WadBuilder
        +build() Result~Vec~u8~, WriteError~
        +build_with_options(opts: &WriteOptions) Result~(Vec~u8~, Vec~WriteWarning~), WriteError~
    }
    class WriteOptions {
        [write]
        +strictness Strictness
        +strict() WriteOptions
        +lenient() WriteOptions
    }
    class WriteError {
        <<enumeration>>
        [write]
        NulInName
        NonAsciiName
        NameTooLong
        LumpTooLarge
        TooManyLumps
        OffsetOverflow
        UnknownMagicStrict
        Binrw
    }
    class WriteWarning {
        <<enumeration>>
        [write]
        NameTruncated
        UnknownMagic
    }

    Wad "1" --> "1" WadHeader : has
    Wad "1" --> "0..*" Lump : contains
    Wad "1" --> "0..*" ParseWarning : collects
    WadHeader --> WadKind : kind
    ParseOptions --> Strictness : strictness
    Wad ..> ParseOptions : constructed with
    Wad ..> ParseError : returns on failure
    Wad ..> WadBuilder : to_builder() [write]
    WadBuilder ..> WriteOptions : build_with_options(opts) [write]
    WadBuilder ..> WriteError : returns on failure [write]
    WadBuilder ..> WriteWarning : returns via build_with_options (lenient) [write]
    WriteOptions --> Strictness : strictness [write]

CLI Flow

Audience: Library users

The cwad binary exposes eight subcommands: info, list, validate, diff, and extract (read-only) plus merge, build, and convert (write-path, backed by crustywad’s write feature). crustywad-cli’s Cargo.toml enables that feature unconditionally on its crustywad dependency, so the write-path subcommands are always available in cwad — there is no user-facing flag to opt in. --lenient and --format (-F; human default, json, or csv) are global flags that apply to every subcommand. Warnings are always written to stderr; normal output routes through --format. Argument-parsing failures (via clap) exit 3 before any subcommand runs, regardless of which subcommand was given.

Read-path dispatch

info, list, validate, diff, and extract all read one or more WADs via Wad::from_path_with_options under ParseOptions::strict()/lenient(). validate handles its load Result explicitly so it can route both outcomes through --format; the other read subcommands propagate load failures via anyhow’s ?, which main catches and turns into exit 2. extract additionally exits 2 if a requested --lump NAME isn’t found in the WAD (a separate explicit check after a successful load).

flowchart TD
    A["cwad [--lenient] [--format FMT] <subcommand>"]
    B{"--lenient flag?"}
    C["ParseOptions::strict()\n(default)"]
    D["ParseOptions::lenient()"]
    A --> B
    B -- "absent" --> C
    B -- "present" --> D
    C & D --> E{"subcommand"}

    E -- "extract" --> X0{"output.is_dir()?\n(false if missing or\nan existing non-directory path)"}
    X0 -- "no" --> ERR2A["stderr: error\nexit 2"]
    X0 -- "yes" --> LOAD

    E -- "info / list" --> LOAD["Wad::from_path_with_options(path, opts)"]
    E -- "validate" --> LOADV["Wad::from_path_with_options(path, opts)\n(Result handled explicitly, not via ?)"]
    E -- "diff" --> LOAD2["Wad::from_path_with_options\nfor file1, then file2"]

    LOAD --> R1{"Ok?"}
    R1 -- "no" --> ERR2B["stderr: error message\nexit 2 (propagated via anyhow ?)"]
    R1 -- "yes" --> S1{"subcommand"}

    LOADV --> RV{"Ok?"}
    RV -- "no" --> FMTERRV["--format routed:\nhuman -> stderr\njson/csv -> stdout ok:false\nexit 2"]
    RV -- "yes" --> FMTOKV["--format routed:\nhuman/json/csv ok:true\nexit 0"]

    LOAD2 --> R2{"both Ok?"}
    R2 -- "no" --> ERR2B
    R2 -- "yes" --> DCALC["diff lumps by name\n(per-name data-sequence comparison)"]

    S1 -- "info" --> OUT1["--format routed:\nkind, lump count, data size, maps\nexit 0"]
    S1 -- "list" --> OUT2["--format routed:\nlump directory\n(index, filepos, size, name)\nexit 0"]
    S1 -- "extract" --> WARNX["stderr: one line per ParseWarning\n(lenient mode; empty in strict)\nprinted before the --lump check below"]
    WARNX --> OUT3{"--lump NAME given\nand not found?"}
    OUT3 -- "yes" --> ERR2C["stderr: lump not found\nexit 2\n(ParseWarnings, if any, already printed)"]
    OUT3 -- "no" --> OUT3B["extract matching lumps,\nsanitize filenames,\n--format routed per-file output\nexit 0"]

    DCALC --> WARND["stderr: one line per ParseWarning\nfor file1, then file2\n(lenient mode; empty in strict)"]
    WARND --> DHAS{"any differences?"}
    DHAS -- "no" --> ZERO["exit 0\n(no diff output;\nParseWarnings, if any, already printed above)"]
    DHAS -- "yes" --> OUT4["--format routed:\nkind + name per difference\n(json = NDJSON)\nexit 1"]

    OUT1 & OUT2 & FMTOKV --> WARN["stderr: one line per ParseWarning\n(lenient mode; empty in strict)"]

Write-path dispatch

merge, build, and convert construct a WAD via WadBuilder. crustywad-cli gets WadBuilder by unconditionally enabling crustywad’s write feature in its Cargo.toml — all three subcommands are always available in cwad, with no user-facing flag required. --lenient selects WriteOptions::strict()/lenient() for the build step, distinct from (but analogous to) the read-side ParseOptions. WriteError from build_with_options is a usage/data error and exits 3; I/O failures (reading input files, writing the output file) still propagate via ? and exit 2, mirroring the read-path exit codes. merge additionally reads each input WAD under ParseOptions, so it prints that WAD’s ParseWarnings (path-prefixed) as each input is loaded, in addition to the WriteWarnings from the build step.

convert reads one WAD, re-emits every map group in the requested target format (--to doom|udmf), and passes all other lumps through unchanged in directory order. It exits 3 when a map cannot be converted — in strict mode, that includes any data loss the target format cannot represent, and the message names the offending field and points at --lenient. Converting to doom emits empty node lumps and always warns NodesNotBuilt: the output needs an external nodebuilder before it is engine-playable. See Converting maps.

flowchart TD
    A["cwad [--lenient] [--format FMT] merge|build ..."]
    B{"--lenient flag?"}
    C["WriteOptions::strict()\n(default)"]
    D["WriteOptions::lenient()"]
    A --> B
    B -- "absent" --> C
    B -- "present" --> D
    C & D --> E{"subcommand"}

    E -- "merge" --> M1["for each input path:\nWad::from_path_with_options(path, ParseOptions);\nstderr: one line per ParseWarning, path-prefixed\n(lenient mode; empty in strict);\nadd_lump for every lump into WadBuilder"]
    M1 --> M2{"all inputs Ok?"}
    M2 -- "no" --> ERR2["stderr: error\nexit 2 (propagated via anyhow ?)"]
    M2 -- "yes" --> BUILD

    E -- "build" --> B1["for each NAME=FILE spec:\nsplit on '=', read file, add_lump"]
    B1 --> B2{"spec malformed or\nname/file empty?"}
    B2 -- "yes" --> ERR3A["stderr: invalid lump spec\nexit 3"]
    B2 -- "no" --> B3{"file read Ok?"}
    B3 -- "no" --> ERR2
    B3 -- "yes" --> BUILD

    BUILD["builder.build_with_options(write_opts)"]
    BUILD --> BOK{"Ok((bytes, warnings))?"}
    BOK -- "no, Err(WriteError)" --> ERR3B["stderr: build error\nexit 3 (usage/data error, not I/O)"]
    BOK -- "yes" --> WARN["stderr: one line per WriteWarning\n(lenient mode; empty in strict)"]
    WARN --> WFILE["fs::write(output, bytes)"]
    WFILE --> WOK{"Ok?"}
    WOK -- "no" --> ERR2
    WOK -- "yes" --> DONE{"subcommand"}

    DONE -- "merge" --> M_EXIT["exit 0\n(no confirmation output)"]
    DONE -- "build" --> B_EXIT["--format routed:\nhuman/csv 'wrote ... lumps: N'\njson ok:true,lumps:N\nexit 0"]

Data Flow

Audience: Contributors

Read pipeline

Strictness only affects semantic validation: strict mode returns Err(ParseError) immediately; lenient mode pushes a ParseWarning and continues. Binary decode errors from binrw — for both the header and directory entries — are always fatal regardless of mode.

flowchart TD
    A["Input bytes\n(from_bytes / from_path / from_path_mapped [mmap])"]
    B["binrw reads RawHeader\n(12 bytes, little-endian)"]
    C{Header OK?}
    D["Err(ParseError::Header)"]
    E{Magic valid?\n'IWAD' / 'PWAD'}
    F{Strictness?}
    G["Err(ParseError::InvalidMagic)"]
    H["warn ParseWarning::InvalidMagic\nkind = WadKind::Unknown"]
    I["Validate numlumps / infotableofs\n(coerce_i32: negative values → error or clamp)"]
    J{Values non-negative?}
    K["Err(ParseError::NegativeValue)"]
    L["warn ParseWarning::NegativeValue\nclamp to 0"]
    M["Compute directory span\n(numlumps x 16 bytes)"]
    MOVF{dir_span overflows?}
    F4{Strictness?}
    OVF_E["Err(ParseError::Overflow)"]
    OVF_W["warn ParseWarning::Overflow\ndir_span saturated"]
    N{Directory within buffer?}
    O["Err(ParseError::OutOfBounds)"]
    P["warn ParseWarning::OutOfBounds\ntruncate to available entries"]
    Q["Parse N x RawDirectoryEntry\n(16 bytes each, little-endian)"]
    R["validate_entry: check filepos/size/name\nlump-directory overlap\nper-entry strict/lenient branch"]
    S["Ok(Wad)\n+ warnings (may be empty)"]

    A --> B
    B --> C
    C -- "binrw error" --> D
    C -- "ok" --> E
    E -- "yes" --> I
    E -- "no" --> F
    F -- "Strict" --> G
    F -- "Lenient" --> H
    H --> I
    I --> J
    J -- "yes" --> M
    J -- "no" --> F2{Strictness?}
    F2 -- "Strict" --> K
    F2 -- "Lenient" --> L
    L --> M
    M --> MOVF
    MOVF -- "yes" --> F4
    F4 -- "Strict" --> OVF_E
    F4 -- "Lenient" --> OVF_W
    OVF_W --> N
    MOVF -- "no" --> N
    N -- "yes" --> Q
    N -- "no" --> F3{Strictness?}
    F3 -- "Strict" --> O
    F3 -- "Lenient" --> P
    P --> Q
    Q --> R
    R --> S

Strict vs. lenient mode

The sequence diagram below shows how the same malformed WAD (bad magic bytes) flows through each mode. Strict mode returns an error immediately; lenient mode records a warning and proceeds to produce a usable Wad.

sequenceDiagram
    participant Caller
    participant Parser
    participant Warnings

    Note over Caller,Warnings: Input: WAD bytes with magic = XWAD (not IWAD/PWAD)

    rect rgb(255, 230, 230)
        Note over Caller,Parser: Strict mode (ParseOptions::strict())
        Caller->>Parser: Wad::from_bytes_with_options(bytes, ParseOptions::strict())
        Parser->>Parser: read RawHeader, magic = XWAD
        Parser->>Parser: magic != IWAD/PWAD, Strictness::Strict
        Parser-->>Caller: Err(ParseError::InvalidMagic)
    end

    rect rgb(230, 255, 230)
        Note over Caller,Warnings: Lenient mode (ParseOptions::lenient())
        Caller->>Parser: Wad::from_bytes_with_options(bytes, ParseOptions::lenient())
        Parser->>Parser: read RawHeader, magic = XWAD
        Parser->>Parser: magic != IWAD/PWAD, Strictness::Lenient
        Parser->>Warnings: push ParseWarning::InvalidMagic
        Parser->>Parser: kind = WadKind::Unknown
        Parser->>Parser: continue parsing numlumps, infotableofs, directory
        Parser-->>Caller: Ok(Wad) with warnings
        Caller->>Caller: wad.warnings() includes InvalidMagic
    end

Write pipeline

Note: requires the write feature flag.

WadBuilder accumulates lumps and defers all validation to build() / build_with_options(). build() is a strict-mode convenience wrapper; build_with_options takes a &WriteOptions and returns collected WriteWarnings alongside the bytes (always empty in strict mode). Offsets (filepos, infotableofs) are always recomputed by the builder — callers never supply them directly. The output layout is [12-byte header][lump data blobs][16-byte directory entries].

flowchart TD
    A["WadBuilder::new(kind)\n.add_lump(name, data) *"]
    B["build() / build_with_options(opts)"]
    C{"kind is WadKind::Unknown?"}
    D{"Strictness?"}
    E["Err(WriteError::UnknownMagicStrict)"]
    F["warn WriteWarning::UnknownMagic\nwrite raw magic bytes"]
    G{"lumps.len() > i32::MAX?"}
    H["Err(WriteError::TooManyLumps)"]
    I["For each lump: validate name and data"]
    J{"name contains NUL?"}
    K["Err(WriteError::NulInName)"]
    L{"name is ASCII?"}
    M["Err(WriteError::NonAsciiName)"]
    N{"name.len() > 8?"}
    O{"Strictness?"}
    P["Err(WriteError::NameTooLong)"]
    Q["warn WriteWarning::NameTruncated\ntruncate to 8 bytes"]
    R{"data.len() > i32::MAX?"}
    S["Err(WriteError::LumpTooLarge)"]
    T["Compute filepos per lump\n(offset starts at 12, the header size)"]
    U{"any filepos / infotableofs\nexceeds i32::MAX?"}
    V["Err(WriteError::OffsetOverflow)"]
    W["BinWrite RawHeader\n(magic, numlumps, infotableofs)"]
    X["Append lump data blobs\nin insertion order"]
    Y["BinWrite RawDirectoryEntry per lump\n(filepos, size, name)"]
    Z["build_with_options: Ok((bytes, warnings))\nbuild(): Ok(bytes)\nwarnings empty in strict mode"]

    A --> B
    B --> C
    C -- "no" --> G
    C -- "yes" --> D
    D -- "Strict" --> E
    D -- "Lenient" --> F
    F --> G
    G -- "yes" --> H
    G -- "no" --> I
    I --> J
    J -- "yes" --> K
    J -- "no" --> L
    L -- "no" --> M
    L -- "yes" --> N
    N -- "no" --> R
    N -- "yes" --> O
    O -- "Strict" --> P
    O -- "Lenient" --> Q
    Q --> R
    R -- "yes" --> S
    R -- "no" --> T
    T --> U
    U -- "yes" --> V
    U -- "no" --> W
    W --> X
    X --> Y
    Y --> Z

Strict vs. lenient write mode

The sequence diagram below shows a lump name longer than 8 bytes flowing through both modes of build_with_options. Strict mode returns an error immediately; lenient mode truncates the name, records a warning, and produces a valid WAD.

sequenceDiagram
    participant Caller
    participant Builder
    participant Warnings

    Note over Caller,Warnings: Input: add_lump("VERYLONGNAME", data) — 12-byte name

    rect rgb(255, 230, 230)
        Note over Caller,Builder: Strict mode (WriteOptions::strict(), or build())
        Caller->>Builder: build_with_options(&WriteOptions::strict())
        Builder->>Builder: name.len() == 12 > 8, Strictness::Strict
        Builder-->>Caller: Err(WriteError::NameTooLong { name, len: 12 })
    end

    rect rgb(230, 255, 230)
        Note over Caller,Warnings: Lenient mode (WriteOptions::lenient())
        Caller->>Builder: build_with_options(&WriteOptions::lenient())
        Builder->>Builder: name.len() == 12 > 8, Strictness::Lenient
        Builder->>Warnings: push WriteWarning::NameTruncated { name }
        Builder->>Builder: truncate name to first 8 bytes
        Builder->>Builder: compute filepos/infotableofs, serialize header + lumps + directory
        Builder-->>Caller: Ok((bytes, warnings))
        Caller->>Caller: warnings includes NameTruncated
    end

Map record parsing

parse_records::<T> turns raw lump bytes into a typed vector using binrw. The generic parameter T may be any map record type (Thing, Linedef, Sidedef, Vertex, Seg, Subsector, Node, Sector) that implements BinRead<Args<'_> = ()>. An empty buffer always yields an empty Vec. Otherwise the function parses the first record and measures how many bytes BinRead consumed (record_size = cursor.position()); this avoids relying on size_of::<T>(), which reflects in-memory layout rather than on-disk size. If record_size == 0 the type has no on-disk representation and any non-empty input is a TrailingBytes error. If the total length is not an exact multiple of record_size, the remaining partial bytes are a TrailingBytes error.

flowchart TD
    A["Input: raw lump bytes\ne.g. THINGS lump data"]
    B["Caller specifies record type T\nfor parse_records, e.g. T = Thing"]
    EMPTY{bytes is empty?}
    OK_EMPTY["Ok, empty Vec"]
    FIRST["BinRead parses first T\nrecord_size = cursor.position()"]
    BINRW1{BinRead ok?}
    BINRW1_ERR["Err(MapParseError::Binrw)"]
    ZSZ{record_size == 0?}
    ZSZ_ERR["Err(MapParseError::TrailingBytes)\noffset = 0"]
    C{"bytes.len() %\nrecord_size == 0?"}
    D["Err(MapParseError::TrailingBytes)\noffset = last complete record end"]
    E["Allocate Vec\ncapacity = bytes.len() / record_size\npush first record"]
    F{more bytes\nto read?}
    G["binrw reads one T\nlittle-endian fixed-size struct"]
    H{binrw ok?}
    I["Err(MapParseError::Binrw)"]
    J["push T into Vec"]
    K["Ok, Vec of T\ne.g. Vec of Thing or Vec of Linedef"]

    A --> B
    B --> EMPTY
    EMPTY -- "yes" --> OK_EMPTY
    EMPTY -- "no" --> FIRST
    FIRST --> BINRW1
    BINRW1 -- "error" --> BINRW1_ERR
    BINRW1 -- "ok" --> ZSZ
    ZSZ -- "yes" --> ZSZ_ERR
    ZSZ -- "no" --> C
    C -- "no" --> D
    C -- "yes" --> E
    E --> F
    F -- "yes" --> G
    G --> H
    H -- "error" --> I
    H -- "ok" --> J
    J --> F
    F -- "no" --> K

    subgraph examples["Concrete T examples"]
        T1["Thing\n10 bytes: x i16, y i16, angle u16\ntype_id u16, flags u16"]
        T2["Linedef\n14 bytes: 7 x u16"]
        T3["Vertex\n4 bytes: x i16, y i16"]
        T4["Sector\n26 bytes: floor_height i16, ceiling_height i16\nfloor_texture Name8, ceiling_texture Name8\nlight_level i16, special_type i16, tag i16"]
    end

    K --> T1
    K --> T2
    K --> T3
    K --> T4

Lump Hierarchy

Audience: Contributors

Lumps in a WAD file are undifferentiated byte blobs at the format level — each identified only by an 8-byte name, a file offset, and a size. This diagram shows the conventional taxonomy used by the Doom engine and followed by crustywad’s typed structs.

The root node represents the on-disk directory entry (raw fields as stored in the WAD). The public Lump API type exposes these as a decoded &str name and usize offsets.

graph TD
    Lump["WAD directory entry (on-disk)\nfilepos: i32 · size: i32 · name: [u8; 8]"]

    Lump --> Map["Map group\n(follows a map-marker lump, e.g. E1M1 / MAP01)"]
    Lump --> NS["Namespace markers\n(delimit resource namespaces)"]
    Lump --> Special["Special lumps\n(global resources)"]
    Lump --> Raw["Untyped lumps\n(passthrough blobs)"]

    Map --> THINGS["THINGS → Thing\n10 bytes per record"]
    Map --> LINEDEFS["LINEDEFS → Linedef\n14 bytes per record"]
    Map --> SIDEDEFS["SIDEDEFS → Sidedef\n30 bytes per record"]
    Map --> VERTEXES["VERTEXES → Vertex\n4 bytes per record"]
    Map --> SEGS["SEGS → Seg\n12 bytes per record"]
    Map --> SSECTORS["SSECTORS → Subsector\n4 bytes per record"]
    Map --> NODES["NODES → Node\n28 bytes per record"]
    Map --> SECTORS["SECTORS → Sector\n26 bytes per record"]
    Map --> REJECT["REJECT → MapReject\nsector-visibility bit matrix"]
    Map --> BLOCKMAP["BLOCKMAP → MapBlockmap\nspatial linedef index"]

    NS --> SS["S_START / S_END\n(sprite namespace)"]
    NS --> PP["P_START / P_END\n(patch namespace)"]
    NS --> FF["F_START / F_END\n(flat / floor texture namespace)"]

    Special --> PLAYPAL["PLAYPAL\n(color palettes — planned)"]
    Special --> COLORMAP["COLORMAP\n(light level tables — planned)"]
    Special --> TEXTURE["TEXTURE1 / TEXTURE2\n(wall texture definitions — planned)"]
    Special --> PNAMES["PNAMES\n(patch name list — planned)"]

Record-based map lump types are defined in crates/crustywad/src/map/ — format-specific records (Thing, Linedef) in doom.rs, and records whose byte layout is shared across formats (Sidedef, Vertex, Seg, Subsector, Node, Sector) in common.rs — and decoded via parse_records::<T>. REJECT/BLOCKMAP are variable-length, not fixed-size records, so they decode via MapReject::parse/MapBlockmap::parse (crates/crustywad/src/map/assemble.rs) into MapReject/MapBlockmap (crates/crustywad/src/map/graph.rs), exposed on an assembled map via Map::reject/Map::blockmap. Items marked planned are future milestones with no current typed struct.

Versioning and Release Policy

This page documents the SemVer guarantees, MSRV policy, versioning model, and release cadence for crustywad and crustywad-cli.


Semantic Versioning

Both crates follow Semantic Versioning 2.0.0, adapted to the pre-1.0 phase. While the crates are at 0.y.z — which SemVer treats as explicitly unstable — this project uses version increments as deliberate compatibility signals; a 0.y.z version is not a license to make arbitrary breaking changes in patches.

Pre-1.0 version mapping (current)

Cargo reads a 0.MINOR caret requirement (a "0.8" dependency means ^0.8, i.e. >=0.8.0, <0.9.0) as allowing patch updates but not a minor bump. So while at 0.y.z, the minor bump is the breaking-change boundary — the opposite of the post-1.0 intuition where a minor release is a safe feature drop. To keep that boundary meaningful, the scheme collapses to two levels until 1.0:

ChangeBump while at 0.xDoes a 0.MINOR caret pin auto-upgrade?
Breaking change (or MSRV raise)minor0.8.00.9.0No — the consumer must opt in.
New backward-compatible API or featurepatch0.8.00.8.1Yes.
Bug fixpatch0.8.00.8.1Yes.

Every backward-compatible change — new public types/functions/methods, new off-by-default feature flags, and bug fixes — ships as a patch; the minor bump is reserved for breaking changes (and MSRV raises, which break the build environment). This maximizes what a 0.MINOR consumer receives automatically while still giving them a hard signal — the minor bump — before anything can break them. release-plz derives the bump from Conventional Commits accordingly: a ! / BREAKING CHANGE commit bumps the minor; every other releasable commit (feat, fix, …) bumps the patch.

At 1.0.0 this expands to standard MAJOR.MINOR.PATCH, and the per-channel meanings below apply literally (backward-compatible new API → minor, breaking → major).

Patch releases (0.MINOR.PATCH)

Canonically (at 1.0+), a patch release fixes a bug without changing any public API, and is safe for all existing callers to upgrade without modification. While at 0.x, a patch release carries every backward-compatible change — bug fixes and new additive API/features (per the pre-1.0 mapping above), since all of them are safe for a 0.MINOR caret consumer to receive.

Examples of patch changes:

  • Correcting incorrect byte offsets in a parser
  • Fixing a panic or incorrect error variant in an existing code path
  • Adding a new public type, function, or method (0.x — a minor change at 1.0+)
  • Adding a new off-by-default feature flag, or a variant to a #[non_exhaustive] enum (0.x)
  • Updating documentation without changing behavior
  • Updating a dependency to a compatible patch version

Minor releases (0.MINOR.0)

While at 0.x, a minor bump signals a breaking change — it is the boundary a 0.MINOR caret consumer must opt into (see the pre-1.0 mapping above and the breaking-change list below). An MSRV raise is also a minor bump: a caller on an older compiler can no longer build, so it is treated as a build-environment break (see MSRV policy).

Canonically (at 1.0+), the minor channel instead carries backward-compatible new functionality — adding a public type, function, or method; a new off-by-default feature flag; a new #[non_exhaustive] enum variant. Until 1.0 those ship as patches (above); only breaking changes and MSRV raises bump the minor.

Major releases (MAJOR.0.0)

A major release contains at least one breaking change. Callers may need to update their code after upgrading.

Pre-1.0 note: While this crate is at 0.y.z, there is no 1.0.0 to bump to. Breaking changes are instead signaled by a minor bump (e.g. 0.1.00.2.0). The breaking-change examples below apply regardless of whether the release is 0.MINOR.0 or a future MAJOR.0.0.

Examples of breaking changes:

  • Removing or renaming a public type, function, method, or field
  • Changing a function signature (parameter types, return type, added required parameter)
  • Adding a variant to an exhaustive enum
  • Changing the behavior of an existing function in a way that violates the previous contract
  • Changing a feature flag that is on by default
  • Implementing a foreign trait (from std or a dependency) on an existing public type (may cause coherence conflicts in downstream code)

What is not a breaking change

  • Adding new public items (types, functions, methods)
  • Adding new trait impls for traits defined in this crate
  • Adding variants to enums marked #[non_exhaustive]
  • Adding optional feature flags
  • Internal implementation changes with identical observable behavior
  • Updating dependencies to compatible versions (patch or minor per their own SemVer)

MSRV Policy

The current minimum supported Rust version (MSRV) is 1.94.0, set via rust-version in Cargo.toml. The project targets the Rust 2024 edition.

Rules:

  • An MSRV bump is a minor version change, never a patch. A caller pinned to the old compiler will fail to build after an MSRV bump, so it is treated as a backward-incompatible change to the build environment even though the public API is unchanged.
  • Rolling N-3 target. The MSRV tracks a bounded window: at each release it is (latest stable Rust minor at release time) − 3, so the crates are guaranteed to build on the last four stable Rust releases (roughly the most recent six months). This replaces the former need-driven policy — the window makes the compatibility promise explicit rather than leaving it implicit, and keeps the toolchain modern enough for the current dependency ecosystem.
  • Revisited each release. The MSRV is reviewed at every release and raised when the rolling window advances, or earlier when a required dependency or language feature demands a newer toolchain. Raising it stays a minor version bump (see the first rule); dropping support for releases below the new floor is the deliberate, semver-signaled cost of a bounded window.
  • CI enforces the declared MSRV. The msrv job in CI builds and tests the workspace on the declared MSRV on every PR. The toolchain version is pinned explicitly in .github/workflows/ci.yml and does not auto-track [workspace.package].rust-version. A PR that raises the MSRV must update both the rust-version field in Cargo.toml and the toolchain: pin in the workflow file, then bump the version of each affected crate (a minor bump) — both crates currently share rust-version.workspace = true, so an MSRV bump affects both. If crustywad’s version moves outside crustywad-cli’s pinned caret range as a result, update that pin too.

Versioning Model

Independent per-crate versioning

Per ADR-0011, each crate carries its own explicit version field in its [package] block rather than inheriting from [workspace.package]. release-plz manages each package independently, proposing version bumps only for crates whose content has changed since the last release.

Dependency constraint: crates/crustywad-cli/Cargo.toml pins the library with an explicit caret requirement (currently crustywad = { version = "0.9.5", ... }), required by cargo-deny’s wildcards = "deny" setting (which disallows * version requirements). version = "0.9.5" resolves as ^0.9.5 (>=0.9.5, <0.10.0), so patch bumps to crustywad within the same minor series are satisfied automatically. When crustywad’s version moves outside that range (e.g., to 0.10.0), this field must be updated manually before merging — otherwise cargo build and crates.io publishing will fail.


Release Cadence

Releases are automated by release-plz, which monitors main for Conventional Commits and opens a release PR whenever releasable changes accumulate.

The workflow:

  1. Commits land on main via merged PRs, following the Conventional Commits format (feat:, fix:, docs:, etc.).
  2. release-plz inspects the commit history and proposes a release PR with a version bump and an updated CHANGELOG.md. Breaking changes must be marked (feat!: or a BREAKING CHANGE: footer) for the commit-derived bump to be correct; as a safety net, release-plz also runs cargo-semver-checks against the previously published version (semver_check in release-plz.toml) so unmarked API breakage still produces the required minor bump rather than a patch.
  3. The maintainer reviews and merges the release PR.
  4. release-plz runs cargo publish automatically after the release PR merges, in dependency order (crustywad before crustywad-cli), and pushes the crustywad-v* / crustywad-cli-v* tags.

There is no fixed release schedule. Releases happen when meaningful changes have accumulated. The release-plz release PR is the signal that a release is ready.

Publishing status: Both crates publish to crates.io automatically, authenticated by Trusted Publishing (OIDC — no stored registry token). release-plz does not create GitHub Releases; the cross-platform cwad binaries and installers are published separately by dist off the crustywad-cli-v* tag. See ADR-0011 for the full publish workflow design.


Version Compatibility Table

Both regimes are shown side by side — while at 0.y.z the pre-1.0 mapping collapses the canonical three channels into two, so the same change bumps a different level before and after 1.0.0.

ScenarioBump while at 0.x (current)Bump at 1.0+
Bug fix, no API changepatchpatch
New public type or functionpatchminor
New optional feature flagpatchminor
#[non_exhaustive] enum variant addedpatchminor
MSRV raisedminorminor
Public type removed or renamedminormajor
Function signature changedminormajor
Exhaustive enum variant addedminormajor