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.