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

Converting maps

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

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

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

Doom → UDMF

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

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

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

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

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

UDMF → Doom

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

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

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

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

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

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

Round-tripping: not symmetric

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

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

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

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

Strict vs. lenient conversion

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

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

Tier 1 — structurally impossible: errors in both modes

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

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

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

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

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

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

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

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

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

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

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

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

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

Doom 64 maps

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

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

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

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

Error handling

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

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

From the CLI

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

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

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

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

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