Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

What crustywad provides

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

When to use it

Use crustywad when you need to:

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

WAD format overview

Every WAD starts with a 12-byte header:

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

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

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

See the Doom Wiki for the full unofficial spec.

Next steps

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

Getting Started

Adding crustywad to your project

Add the crate to your Cargo.toml:

[dependencies]
crustywad = "0.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

Constructors

Wad provides several constructors depending on your input source:

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

Accessing lumps

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

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

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

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

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

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

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

Strict vs. lenient parsing

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

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

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

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

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

You can also use the shorthand constructors:

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

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

What each mode does

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

WAD kind

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

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

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

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:

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

Parsing records

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

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

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

let things: Vec<map::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. a THINGS lump whose byte count is not divisible by 10).
  • MapParseError::Binrwbinrw failed to decode a record from the byte stream.

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

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

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

Lenient mode

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

cwad --lenient info damaged.wad

Example output when the WAD magic is unrecognized:

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

Output formats

All 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

CodeMeaning
0Success
2I/O error or parse error (malformed WAD, missing file, etc.)
3Usage 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

FeatureDefaultPurpose
mmapnoMemory-mapped file loading via memmap2
freedoom-testsnoIntegration tests against local Freedoom WAD fixtures
writenoWAD 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 emits WriteWarning::NameTruncated.
  • WadKind::Unknown magic: strict mode returns WriteError::UnknownMagicStrict; lenient mode writes the raw 4-byte magic.

Common cargo invocations

GoalCommand
Build with all featurescargo build --workspace --all-features
Build with mmap onlycargo build -p crustywad --features mmap
Test with all featurescargo test --workspace --all-features
Test with mmap onlycargo test -p crustywad --features mmap
Test with Freedoom fixturesCRUSTYWAD_FREEDOOM_DIR=… cargo test -p crustywad --features freedoom-tests
Build with writecargo build -p crustywad --features write
Test with writecargo test -p crustywad --features write
Full CI checkjust 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 --format routing as a candidate for this diagram. The current CLI has no --format flag; 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 no 1.0.0 to bump to. Breaking changes are instead signaled by a minor bump (e.g. 0.1.00.2.0). The breaking-change examples below apply regardless of whether the release is 0.MINOR.0 or a future MAJOR.0.0.

Examples of breaking changes:

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

What is not a breaking change

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

MSRV Policy

The current minimum supported Rust version (MSRV) is 1.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 msrv job in CI builds and tests the workspace on the declared MSRV on every PR. The toolchain version is pinned explicitly in .github/workflows/ci.yml and does not auto-track [workspace.package].rust-version. A PR that raises the MSRV must update both the rust-version field in Cargo.toml and the toolchain: pin in the workflow file, then bump the workspace version (a minor bump, since all workspace crates share a single version via version.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:

  1. Commits land on main via merged PRs, following the Conventional Commits format (feat:, fix:, docs:, etc.).
  2. release-plz inspects the commit history and proposes a release PR with a version bump and an updated CHANGELOG.md.
  3. The maintainer reviews and merges the release PR.
  4. Once publishing is enabled, release-plz runs cargo publish automatically after the release PR merges, in dependency order (crustywad before crustywad-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

ScenarioPatchMinorMajor
Bug fix, no API changeyes
New public type or functionyes
New optional feature flagyes
MSRV raisedyes
Public type removed or renamedyes
Function signature changedyes
Exhaustive enum variant addedyes