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.1"
Enable optional features as needed:
[dependencies]
crustywad = { version = "0.1", 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>(())
}
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:
| 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::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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
pub struct Vertex {
pub x: i16,
pub y: i16,
}
}
Sector
#![allow(unused)]
fn main() {
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.
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 the WAD kind (Iwad or Pwad) and total lump count.
$ cwad info doom.wad
kind: Iwad
lumps: 1264
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.
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 three subcommands support the --format / -F flag.
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
Iwad,1264
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 |
2 | I/O error or parse error (malformed WAD, missing file, etc.) |
3 | Usage error (unknown subcommand, invalid flag value, missing required argument) |
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 |
write | no | WAD serialization — WadBuilder, WriteError, WriteOptions, WriteWarning |
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.1", 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.
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 tests/fixtures/freedoom:
just test-freedoom
# Override the fixture directory:
just test-freedoom dir=/path/to/freedoom
# Or run cargo directly:
CRUSTYWAD_FREEDOOM_DIR=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.
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.1", 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.
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 |
Build with write | cargo build -p crustywad --features write |
Test with write | cargo test -p crustywad --features write |
| Full CI check | just ci |
See the justfile for
available just recipes including feature-specific aliases.
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)"]
end
subgraph cli["crustywad-cli (binary crate)"]
mains["main.rs\ncwad — info · list subcommands"]
end
cli -->|"cargo dependency"| lib
mmrs -. "feature = mmap\nadds memmap2 dependency" .-> memmap2(["memmap2\n(external crate)"])
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)"]
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.
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~
}
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
}
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
CLI Flow
Audience: Library users
The cwad binary exposes two subcommands. The --lenient flag is global and switches the parser to lenient mode for the entire invocation. Warnings are always written to stderr; normal output goes to stdout.
Note: Issue #95 referenced
--formatrouting as a candidate for this diagram. The current CLI has no--formatflag; this diagram reflects the implemented interface only.
flowchart TD
A["cwad [--lenient] <subcommand> <path>"]
B{"--lenient flag?"}
C["ParseOptions::strict()\n(default)"]
D["ParseOptions::lenient()"]
A --> B
B -- "absent" --> C
B -- "present" --> D
C & D --> E{"subcommand"}
E -- "info" --> F["Wad::from_path_with_options(path, opts)"]
E -- "list" --> F
F --> G{"parse result"}
G -- "Err(ParseError)" --> H["stderr: error message\nexit code 1"]
G -- "Ok(Wad)" --> I{"subcommand"}
I -- "info" --> J["stdout: kind\nstdout: lump count"]
I -- "list" --> K["stdout: index · filepos · size · name\n(one row per lump)"]
J & K --> L["stderr: one line per ParseWarning\n(lenient mode; empty in strict)"]
L --> M["exit code 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
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 → RejectLump\n(stub — not yet parsed)"]
Map --> BLOCKMAP["BLOCKMAP → BlockmapLump\n(stub — not yet parsed)"]
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 (Thing, Linedef, Sidedef, Vertex, Seg, Subsector, Node, Sector) are defined in crates/crustywad/src/map.rs and decoded via parse_records::<T>. Items marked stub (RejectLump, BlockmapLump) have zero-sized placeholder types and are not parsed via parse_records. 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. While the crates are
currently at 0.y.z — which SemVer treats as explicitly unstable — this project uses
patch, minor, and major increments as compatibility signals as documented on this page.
A 0.y.z version is not a license to make arbitrary breaking changes in patches.
Patch releases (0.MINOR.PATCH)
A patch release fixes a bug without changing any public API. It is safe for all existing callers to upgrade without modification.
Examples of patch changes:
- Correcting incorrect byte offsets in a parser
- Fixing a panic or incorrect error variant in an existing code path
- Updating documentation without changing behavior
- Updating a dependency to a compatible patch version
Minor releases (0.MINOR.0)
A minor release adds new functionality in a backward-compatible way. Existing callers compile and run without modification on the same or a newer supported toolchain. (MSRV bumps are also minor releases — callers on a compiler older than the new MSRV will need to upgrade their toolchain.)
Examples of minor changes:
- Adding a new public type, function, or method
- Adding a new feature flag that is off by default
- Adding a new variant to a non-exhaustive enum
- Raising the MSRV (see MSRV policy below)
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.85.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.
- MSRV bumps are need-driven. The MSRV will only be raised when there is a concrete need (for example, a required dependency or language feature), and only to a toolchain version that has been stable for a reasonable period.
- 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 workspace version (a minor bump, since all workspace crates share a single version viaversion.workspace = true).
Versioning Model
Current state: shared workspace version
Both crates currently use version.workspace = true, inheriting their version from
[workspace.package] in the root Cargo.toml. A single version bump increments the
version for both crates simultaneously.
Dependency constraint: crates/crustywad-cli/Cargo.toml pins the library with an explicit
caret requirement (e.g., crustywad = { version = "0.1.0", ... }), required by
cargo-deny’s wildcards = "deny" setting (which disallows * version requirements).
version = "0.1.0" resolves as ^0.1.0 (>=0.1.0, <0.2.0), so patch bumps within
the same minor series are satisfied automatically. When the workspace version moves outside
that range (e.g., to 0.2.0), this field must be updated manually before merging —
otherwise cargo build and crates.io publishing will fail.
Planned: independent per-crate versioning
Per ADR-0011,
the chosen long-term strategy is independent per-crate versioning: each crate will carry
its own explicit version field rather than inheriting from the workspace. This migration
is a required step before enabling crates.io publishing. Until then, both crates share the
workspace version as described above.
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.- The maintainer reviews and merges the release PR.
- Once publishing is enabled,
release-plzrunscargo publishautomatically after the release PR merges, in dependency order (crustywadbeforecrustywad-cli).
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: Publishing to crates.io is currently disabled while credentials and release infrastructure are being finalized (see ADR-0011 for the full publish workflow design).
Version Compatibility Table
| Scenario | Patch | Minor | Major |
|---|---|---|---|
| Bug fix, no API change | yes | ||
| New public type or function | yes | ||
| New optional feature flag | yes | ||
| MSRV raised | yes | ||
| Public type removed or renamed | yes | ||
| Function signature changed | yes | ||
| Exhaustive enum variant added | yes |