torchwright-doom-e1m1 / tools /txt_to_png.py
physicsrob's picture
Publish E1M1 38-layer seed-0 bundle
b9a8681 verified
Raw
History Blame Contribute Delete
5.42 kB
"""Pure-stdlib canonical Doom text to PNG decoder shipped with bundles.
What this tool does: execute the cursor/pixel protocol the model emitted —
every cursor set, direction mark, and run width in the stream is a model
output token; the tool applies them (cursor bookkeeping), looks each color
up in the bundled palette, and blits last-write-wins. The cursor's default
advance is +Y (wall columns) until a ``setCursorDirectionX`` token flips it
to +X (flat spans), exactly as PROTOCOL.md specifies.
What this tool never does: geometry, visibility, ordering, lighting, or
texture selection — those all happened inside the transformer before this
tool ever runs.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import struct
import zlib
from pathlib import Path
def _sha_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _load_bundle(bundle: Path) -> tuple[dict, dict, dict]:
manifest = json.loads((bundle / "doom_bundle_manifest.json").read_text())
if not manifest.get("validation", {}).get("complete"):
raise ValueError("Doom bundle manifest is not complete")
vocab = json.loads((bundle / "doom_vocab.json").read_text())
palette = json.loads((bundle / "doom_palette.json").read_text())
files = manifest.get("files", {})
for name in ("doom_vocab.json", "doom_palette.json"):
expected = files.get(name, {}).get("sha256")
if expected and _sha_bytes((bundle / name).read_bytes()) != expected:
raise ValueError(f"Doom bundle hash mismatch for {name}")
return manifest, vocab, palette
def _rows(raw: str, vocab: dict) -> list[int]:
mapping = {word: row for row, word in enumerate(vocab["words"])}
out = []
for word in raw.split():
try:
out.append(mapping[word])
except KeyError:
raise ValueError(f"unknown canonical Doom word: {word!r}") from None
return out
def _pixels(rows: list[int], vocab: dict, palette: list[list[int]]):
records = vocab["rows"]
x = y = None
dx, dy = 0, 1
pixels: dict[tuple[int, int], tuple[int, int, int]] = {}
for row in rows:
record = records[row]
kind = record["type"]
values = record["values"]
if kind == "setCursorDirectionX":
dx, dy = 1, 0
elif kind == "setCursorDirectionY":
dx, dy = 0, 1
elif kind == "setCursorX":
x = int(values["x"])
elif kind == "setCursorY":
y = int(values["y"])
elif kind == "pixel" and x is not None and y is not None:
# A healthy stream always sets the cursor before its first pixel
# (PROTOCOL.md); the None-guard only tolerates truncated streams.
# Every cursor set, direction mark, and width applied here is a
# model-emitted token — this tool just executes them.
width = int(values["w"])
channels = palette[int(values["color"])]
if len(channels) != 3:
raise ValueError("Doom palette entry is not RGB")
rgb = (int(channels[0]), int(channels[1]), int(channels[2]))
for offset in range(width):
pixels[(x + offset, y)] = rgb
if dx:
x += width
else:
y += dy
return pixels
def _write_png(path: Path, width: int, height: int, pixels) -> None:
raw = bytearray()
for y in range(height):
raw.append(0)
for x in range(width):
raw.extend(pixels.get((x, y), (0, 0, 0)))
def chunk(kind: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
signature = b"\x89PNG\r\n\x1a\n"
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
path.write_bytes(
signature
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ chunk(b"IEND", b"")
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Decode canonical Doom text to PNG")
parser.add_argument("--bundle", type=Path)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--provenance", type=Path)
args = parser.parse_args(argv)
bundle = args.bundle or Path(__file__).resolve().parent.parent
manifest, vocab, palette_blob = _load_bundle(bundle)
raw_bytes = args.input.read_bytes()
rows = _rows(raw_bytes.decode("utf-8"), vocab)
screen = manifest["screen"]
pixels = _pixels(rows, vocab, palette_blob["colors"])
_write_png(args.output, int(screen["width"]), int(screen["height"]), pixels)
if args.provenance:
ids_bytes = json.dumps(rows, separators=(",", ":")).encode()
args.provenance.write_text(
json.dumps(
{
"raw_text_sha256": _sha_bytes(raw_bytes),
"row_ids_sha256": _sha_bytes(ids_bytes),
"row_vocab_fingerprint": manifest["row_vocab_fingerprint"],
},
indent=2,
sort_keys=True,
)
+ "\n"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())