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
mmapfeature flag. - A small
cwadCLI 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:
| Field | Size | Description |
|---|---|---|
| Magic | 4 bytes | IWAD (game data) or PWAD (patch) |
numlumps | 4 bytes (i32 LE) | Number of lump directory entries |
infotableofs | 4 bytes (i32 LE) | Byte offset of the lump directory |
The lump directory follows the lump data. Each directory entry is 16 bytes:
| Field | Size | Description |
|---|---|---|
filepos | 4 bytes (i32 LE) | Byte offset of lump data |
size | 4 bytes (i32 LE) | Byte length of lump data |
name | 8 bytes | NUL-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 — access lumps and choose a parse mode.
Reading WAD Files
Constructors
Wad provides several constructors depending on your input source:
| Constructor | Source | Notes |
|---|---|---|
Wad::from_bytes(bytes) | Vec<u8> / &[u8] / byte array | No file I/O |
Wad::from_bytes_with_options(bytes, opts) | Same, with custom options | |
Wad::from_path(path) | File path | Reads file into heap |
Wad::from_path_with_options(path, opts) | File path + options | |
Wad::from_path_mapped(path) | File path | Memory-mapped; requires mmap feature |
Wad::from_path_mapped_with_options(path, opts) | File path + options | Requires 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
| Condition | Strict | Lenient |
|---|---|---|
| Invalid magic bytes | ParseError | ParseWarning, WadKind::Unknown |
Negative numlumps or infotableofs | ParseError | ParseWarning, value clamped to 0 |
| Directory extends past end-of-file | ParseError | ParseWarning, truncated to available entries |
| Lump data out of bounds | ParseError | ParseWarning, range clamped |
| Non-ASCII lump name | ParseError | ParseWarning, 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());
}
| Condition | Strict | Lenient |
|---|---|---|
| Lump name longer than 8 bytes | WriteError::NameTooLong | WriteWarning::NameTruncated, truncated to 8 bytes |
| Name contains a NUL byte | WriteError::NulInName | Same (both modes) |
| Non-ASCII name | WriteError::NonAsciiName | Same (both modes) |
WadKind::Unknown magic | WriteError::UnknownMagicStrict | WriteWarning::UnknownMagic, written unchanged |
Lump data larger than i32::MAX bytes | WriteError::LumpTooLarge | Same (both modes) |
Lump count exceeds i32::MAX | WriteError::TooManyLumps | Same (both modes) |
Computed offset exceeds i32::MAX | WriteError::OffsetOverflow | Same (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_mapoutput is not engine-playable on vanilla ports.add_doom_mapwrites zero-lengthSEGS,SSECTORS,NODES,REJECT, andBLOCKMAPlumps — the canonical Doom lump run editors and nodebuilders expect to find, but with no node data in them. Every call returnsDoomWriteWarning::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 thenodebuildfeature — theadd_doom_map_with_nodesone-shot, or thebuild_nodes/build_blockmap/build_rejectbuilders — or run an external nodebuilder (zdbsp,bsp, …) over the output. From the CLI,cwad convert --to doom --nodesis the turnkey path, and--node-formatselects the on-disk node encoding —classic(the 16-bit default), the non-GLxnod/znodstreams, the GLxgln/xgl2/xgl3streams, orglto auto-select the minimal GL dialect (z*values need cwad built withextended-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, andSECTORS, and forTHINGSwithin 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
anglein0..360round-trips exactly; an angle ≥ 360 comes back asangle % 360. This is a semantic no-op, not data loss: Doom’sP_SpawnMapThingcomputes the spawn facing asANG45 * (angle / 45)with integer division, so360and0produce the identical facing. This case is not hypothetical — 226 things across 10 Freedoom maps store a literalangle = 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.
| Arena | Maximum | Why |
|---|---|---|
| vertices | 65,536 | indices 0..=65,535 |
| sectors | 65,536 | indices 0..=65,535 |
| sidedefs | 65,535 | 0xffff is the “no sidedef” sentinel |
Reported as DoomWriteError::TooManyElements { kind, count, max }.
Tier 2 — value loss: strict errors, lenient recovers and warns
| Loss | Lenient recovery |
|---|---|
Fractional f64 coordinate (vertex x/y, thing x/y) | round to nearest i16 (half away from zero) |
Coordinate outside i16 range | clamp to i16::MIN/i16::MAX |
Linedef special outside u16; args[0] (the sector tag) outside u16 | clamp |
Sidedef x_offset / y_offset outside i16 | clamp |
Sector floor_height / ceiling_height / light / special / tag outside i16 | clamp |
Thing or linedef flags with any bit above 15 set | truncate to u16 (& 0xffff) |
| Texture/flat name longer than 8 bytes | truncate to 8 bytes |
Non-finite (NaN/infinite) coordinate | strict 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], andmap::common::Name8keeps those bytes verbatim — but theMapgraph does not.MapSidedefandMapSectorstoreString, filled on read viaName8::as_str_lossy, which trims at the first NUL and decodes withString::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 asEF 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 asDoomWriteError::NameTooLongin 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
specialandargs[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
ColoredLightingDroppedwarning 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/NODESand runs its own internal nodebuilder, and likewise regenerates a missing or oversizedBLOCKMAPandREJECT. 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:
| Builder | Produces | Notes |
|---|---|---|
build_reject | REJECT | Infallible; 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_blockmap | BLOCKMAP | The packed 128-unit collision grid, deduplicated blocklists. |
build_nodes | SEGS / 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:
| Value | On-disk form | Notes |
|---|---|---|
classic (default) | SEGS / SSECTORS / NODES (16-bit) | Vanilla-compatible; unchanged from plain --nodes. |
xnod | A single uncompressed XNOD stream in NODES (SEGS/SSECTORS empty) | ZDoom non-GL extended nodes; lifts the vanilla 16-bit ceilings. |
znod | A zlib-compressed ZNOD stream | Same 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. |
xgln | An 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. |
xgl2 | An uncompressed XGL2 stream, carried in SSECTORS | Like xgln but with a 32-bit seg linedef reference; still whole-unit i16 node partitions. |
xgl3 | An uncompressed XGL3 stream, carried in SSECTORS | Like xgl2, plus fractional (sub-unit) node partitions. |
gl | Whichever of xgln/xgl2/xgl3 is the minimal dialect the map needs, emitted uncompressed | Escalates only if the geometry requires it (a real linedef index colliding with XGLN’s sentinel, or a fractional partition). |
zgln / zgl2 / zgl3 / zgl | The zlib-compressed twins of the four GL rows above | Each 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 THINGS…BEHAVIOR 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_nodesrejects 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 isNodeFormat::Classic. - Non-GL extended — the
XNODstream (and its zlib twinZNOD, behindextended-nodes-zlib) viabuild_nodes+BuiltNodes::to_extended_lump_bytes, lifting the vanilla node ceilings (ADR-0025 §Amendment #323). - GL extended — the
XGLN/XGL2/XGL3streams (and their zlib twinsZGLN/ZGL2/ZGL3withextended-nodes-zlib) viabuild_gl_nodesBuiltGlNodes::to_extended_lump_bytes, or theadd_doom_map_with_nodesone-shot, which carries the GL stream inSSECTORS(ADR-0026, #364, #365). Theadd_udmf_map_with_nodesone-shot builds either family for a UDMF map group, carried inZNODESinstead (#354, #384).NodeFormat::Gl/NodeFormat::Zglauto-select the minimal dialect a map needs —XGLNunless a real linedef index collides withXGLN’s0xFFFFminiseg sentinel (forcingXGL2’s 32-bit linedefs) or a fractional partition forcesXGL3.
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.
| Lump | Record type | Record size |
|---|---|---|
THINGS | Thing | 10 bytes |
LINEDEFS | Linedef | 14 bytes |
SIDEDEFS | Sidedef | 30 bytes |
VERTEXES | Vertex | 4 bytes |
SEGS | Seg | 12 bytes |
SSECTORS | Subsector | 4 bytes |
NODES | Node | 28 bytes |
SECTORS | Sector | 26 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. aTHINGSlump whose byte count is not divisible by 10).MapParseError::Binrw—binrwfailed 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::assembledetects the map format from its lumps: the marker lump is checked first under the Doom 64 dual condition — aMAPxxname and nestedIWAD/PWADmagic in its bytes, the same rule grouping applies (see Doom 64 maps below); otherwise aTEXTMAPlump marks a UDMF map, aBEHAVIORlump marks a Hexen map, and anything else is treated as the classic Doom binary layout. The assembledMapcarries its format viamap.format(), which returnsMapFormat::Doomfor classic Doom/Doom II/Heretic maps (which share the same binary record layout and differ only in map-marker naming, e.g.MAP01vsE1M1),MapFormat::Hexenfor Hexen maps,MapFormat::Udmffor UDMF (TEXTMAP) maps, orMapFormat::Doom64for Doom 64 maps. UDMF maps can also be written back out withwrite_udmf/add_udmf_map(thewritefeature).
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:
| Resolver | Follows |
|---|---|
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, orassemble_with_optionswithStrictness::Strict): the first out-of-range cross-reference (e.g. a linedef’s vertex index past the end ofVERTEXES) aborts assembly withMapAssembleError::DanglingReference. A missing required lump or an undecodable record lump also aborts, in both modes, withMapAssembleError::MissingLumporMapAssembleError::Records. - Lenient (
assemble_with_optionswithStrictness::Lenient): an out-of-range cross-reference is clamped to a valid fallback index instead of failing, and aMapWarning::DanglingReferenceis recorded. Structural failures (missing lump, undecodable records, or a required target arena that is empty) still returnMapAssembleErroreven 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); orGL_LEVEL— a lump literally namedGL_LEVELwhose text contents carry aLEVEL=<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-endiani16fields (width,height,left_offset,top_offset), followed by exactlywidthlittle-endiani32column offsets counted from the start of the lump. Each column is a chain of posts:top_delta(u8;0xFFterminates the chain),length(u8), a padding byte,lengthpixel bytes, and a trailing padding byte.top_deltais plain, not cumulative — vanilla has no DeePsea-style “tall patch” handling (ADR-0022 §3).Playpal:N × 768bytes (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 × 256bytes (NUMCOLORMAPSis 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 viatables().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.
| Condition | Strict | Lenient |
|---|---|---|
| Picture lump under 8 bytes (no header to recover from) | GfxError::TruncatedPicture | Error in both modes |
Picture lump under 8 + width × 4 bytes (offset table truncated) | GfxError::TruncatedPicture | Width clamped to the offsets present; GfxWarning::TruncatedPicture |
| Negative picture width/height | GfxError::NegativeDimension | Clamped to 0; GfxWarning::NegativeDimension |
| Column offset outside the lump (including a negative offset) | GfxError::ColumnOffsetOutOfBounds | Column left empty; GfxWarning::ColumnOffsetOutOfBounds |
Post chain runs past the lump end without a 0xFF terminator | GfxError::UnterminatedColumn | Posts fully read so far are kept; GfxWarning::UnterminatedColumn |
| A post’s rows exceed the picture height | GfxError::PostOutOfBounds | Clipped 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::ExcessivePostData | Remaining columns left empty; GfxWarning::ExcessivePostData |
PLAYPAL length not a positive multiple of 768 | GfxError::PlaypalSize | Remainder truncated (zero palettes for a zero-length lump); GfxWarning::PlaypalSize |
COLORMAP length not a 256-byte multiple of at least 8192 | GfxError::ColormapSize | Zero-padded to 8192 (short) or truncated to whole tables (long); GfxWarning::ColormapSize |
Flat length not a 64-byte multiple of at least 4096 | GfxError::FlatSize | Actual 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:
| Condition | Strict | Lenient |
|---|---|---|
TEXTUREx present but no PNAMES lump exists | GfxError::MissingPnames | Set built with an empty name table; GfxWarning::MissingPnames |
A patch reference indexes past the resolved PNAMES table (including a negative index) | GfxError::PatchIndexOutOfBounds | Reference 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 WAD | GfxError::UnresolvedPatchName | Patch left unresolved; GfxWarning::UnresolvedPatchName |
A resolved patch lump fails to parse as a Picture | GfxError::PatchPictureFailed | Patch 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:
| Condition | Strict | Lenient |
|---|---|---|
| Negative composed width/height | GfxError::NegativeDimension | Clamped to 0 (see the picture NegativeDimension row above) |
width × height exceeds Limits::max_composite_pixels | GfxError::CompositeTooLarge in both modes | GfxError::CompositeTooLarge in both modes |
| A composited column has no contributing patch (the Medusa case) | GfxError::MedusaColumn | Column(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 THINGS…BEHAVIOR 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 --nodes — classic (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
| Flag | Short | Description |
|---|---|---|
--lenient | — | Use 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> | -F | Output format: human (default), json, or csv |
--help | -h | Print help and exit 0 |
--version | -V | Print 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
| Code | Meaning |
|---|---|
0 | Success |
1 | Negative result — the two WADs differ (diff), or validate --deep found map validation errors |
2 | I/O error or parse error (malformed WAD, missing file, etc.); for extract, also a nonexistent --output directory or a --lump name not found |
3 | Usage 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
| Feature | Default | Purpose |
|---|---|---|
mmap | no | Memory-mapped file loading via memmap2 |
freedoom-tests | no | Integration tests against local Freedoom WAD fixtures (auto-fetchable) |
hexen-tests | no | Integration tests against a local Hexen IWAD (not auto-fetchable) |
doom64-tests | no | Integration tests against a local Doom 64 IWAD (not auto-fetchable) |
sweep-tests | no | Sweep test that assembles every map of every WAD in a local collection (not auto-fetchable) |
guide-doctests | no | Internal, CI-only. Compiles this guide’s Rust code samples as crate doctests (enabled by --all-features); not a runtime capability |
write | no | WAD serialization — WadBuilder, WriteError, WriteOptions, WriteWarning |
nodebuild | no | Clean-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-gfx | no | Doom 64 PNG texture/sprite decoding via png — Doom64Png, capped by Limits::max_decoded_pixels |
extended-nodes-zlib | no | Decode 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 emitsWriteWarning::NameTruncated. WadKind::Unknownmagic: strict mode returnsWriteError::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
| Goal | Command |
|---|---|
| Build with all features | cargo build --workspace --all-features |
Build with mmap only | cargo build -p crustywad --features mmap |
| Test with all features | cargo test --workspace --all-features |
Test with mmap only | cargo test -p crustywad --features mmap |
| Test with Freedoom fixtures | CRUSTYWAD_FREEDOOM_DIR=… cargo test -p crustywad --features freedoom-tests |
| Test with Hexen fixture | CRUSTYWAD_HEXEN_DIR=… cargo test -p crustywad --features hexen-tests |
| Test with Doom 64 fixture | CRUSTYWAD_DOOM64_DIR=… cargo test -p crustywad --features doom64-tests |
| Sweep a local WAD collection | CRUSTYWAD_SWEEP_DIR=… cargo test -p crustywad --features sweep-tests |
Build with write | cargo build -p crustywad --features write |
Test with write | cargo test -p crustywad --features write |
Build with nodebuild | cargo build -p crustywad --features nodebuild |
Test with nodebuild | cargo test -p crustywad --features nodebuild |
Build with doom64-gfx | cargo build -p crustywad --features doom64-gfx |
Test with doom64-gfx | cargo test -p crustywad --features doom64-gfx |
Build with extended-nodes-zlib | cargo build -p crustywad --features extended-nodes-zlib |
Test with extended-nodes-zlib | cargo 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
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
| Group | What is measured |
|---|---|
parse/from_bytes_strict | Wad::from_bytes — strict mode — on small (10 × 256 B), medium (100 × 4 KiB), and large (1 000 × 16 KiB) synthetic WADs |
parse/from_bytes_lenient | Wad::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_path | Wad::from_path and Wad::from_path_with_options on a tempfile-backed medium WAD; mmap variants when the mmap feature is enabled |
lump_access | lump(idx), lump_by_name (first-match and worst-case last-match), lump_bytes, lump_data, lumps().iter().count(), clone, into_bytes |
map_records | parse_records::<T> for all eight classic map-record types (Thing, Linedef, Sidedef, Vertex, Seg, Subsector, Node, Sector) against 1 000 records each |
write/build_strict | WadBuilder::build — strict mode — on small / medium / large synthetic WADs |
write/build_lenient | WadBuilder::build_with_options(&WriteOptions::lenient()) on the same sizes |
write/build_from_scratch | WadBuilder populated entirely at runtime (10 or 100 lumps) |
write/roundtrip | Wad::from_bytes → Wad::to_builder → WadBuilder::build end-to-end |
freedoom | Wad::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-blocking — fail-on-alert: false ensures benchmark regressions never fail the run
or block a merge.
Each run:
- Compiles and runs all Criterion bench targets with
--output-format bencher. - Uploads the Criterion HTML report as a downloadable GitHub Actions artifact
(90-day retention) named
criterion-html-report. - Appends trend data to the
gh-pagesbranch atdev/bench/viagithub-action-benchmark, which powers the chart page atcrustywad.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
writefeature 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:
| Change | Bump while at 0.x | Does a 0.MINOR caret pin auto-upgrade? |
|---|---|---|
| Breaking change (or MSRV raise) | minor — 0.8.0 → 0.9.0 | No — the consumer must opt in. |
| New backward-compatible API or feature | patch — 0.8.0 → 0.8.1 | Yes. |
| Bug fix | patch — 0.8.0 → 0.8.1 | Yes. |
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 no1.0.0to bump to. Breaking changes are instead signaled by a minor bump (e.g.0.1.0→0.2.0). The breaking-change examples below apply regardless of whether the release is0.MINOR.0or a futureMAJOR.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
stdor 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
msrvjob in CI builds and tests the workspace on the declared MSRV on every PR. The toolchain version is pinned explicitly in.github/workflows/ci.ymland does not auto-track[workspace.package].rust-version. A PR that raises the MSRV must update both therust-versionfield inCargo.tomland thetoolchain:pin in the workflow file, then bump the version of each affected crate (a minor bump) — both crates currently sharerust-version.workspace = true, so an MSRV bump affects both. Ifcrustywad’s version moves outsidecrustywad-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:
- Commits land on
mainvia merged PRs, following the Conventional Commits format (feat:,fix:,docs:, etc.). release-plzinspects the commit history and proposes a release PR with a version bump and an updatedCHANGELOG.md. Breaking changes must be marked (feat!:or aBREAKING CHANGE:footer) for the commit-derived bump to be correct; as a safety net,release-plzalso runscargo-semver-checksagainst the previously published version (semver_checkinrelease-plz.toml) so unmarked API breakage still produces the required minor bump rather than a patch.- The maintainer reviews and merges the release PR.
release-plzrunscargo publishautomatically after the release PR merges, in dependency order (crustywadbeforecrustywad-cli), and pushes thecrustywad-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.
| Scenario | Bump while at 0.x (current) | Bump at 1.0+ |
|---|---|---|
| Bug fix, no API change | patch | patch |
| New public type or function | patch | minor |
| New optional feature flag | patch | minor |
#[non_exhaustive] enum variant added | patch | minor |
| MSRV raised | minor | minor |
| Public type removed or renamed | minor | major |
| Function signature changed | minor | major |
| Exhaustive enum variant added | minor | major |