Initial commit, AI-genned
authorLucian Mogosanu <lucian@mogosanu.ro>
Mon, 29 Jun 2026 17:30:46 +0000 (20:30 +0300)
committerLucian Mogosanu <lucian@mogosanu.ro>
Mon, 29 Jun 2026 17:30:46 +0000 (20:30 +0300)
14 files changed:
.gitignore [new file with mode: 0644]
CHECKLIST.md [new file with mode: 0644]
JOURNAL.md [new file with mode: 0644]
prompt.md [new file with mode: 0644]
spec/formats/conventions.md [new file with mode: 0644]
spec/formats/vga-image.md [new file with mode: 0644]
tools/openpt/__init__.py [new file with mode: 0644]
tools/openpt/build.py [new file with mode: 0644]
tools/openpt/decode_vga.py [new file with mode: 0644]
tools/openpt/palette.py [new file with mode: 0644]
tools/openpt/pnm.py [new file with mode: 0644]
tools/openpt/rle.py [new file with mode: 0644]
tools/openpt/verify.py [new file with mode: 0644]
tools/openpt/vga.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..31a24b7
--- /dev/null
@@ -0,0 +1,2 @@
+/artifacts/*
+/PIZZA
diff --git a/CHECKLIST.md b/CHECKLIST.md
new file mode 100644 (file)
index 0000000..c57ac64
--- /dev/null
@@ -0,0 +1,44 @@
+# openpt — reverse-engineering checklist
+
+Living map of work across sessions. Status: `[ ]` todo · `[~]` in progress ·
+`[x]` done · `[?]` open question / blocked. Newest detail in
+[JOURNAL.md](JOURNAL.md).
+
+## Phase 0 — scaffolding
+- [x] Directory layout (`spec/`, `tools/`, `artifacts/`)
+- [x] Pure-stdlib tooling baseline (no third-party deps; PNM not PNG)
+- [x] Journal + checklist for cross-session continuity
+- [x] One-command "regenerate all artifacts" entry point (`python3 -m openpt.build`)
+
+## Phase 1 — asset/data formats (no disassembly needed)
+- [x] **VGA image** container + shared RLE codec — `spec/formats/vga-image.md`,
+      `conventions.md`; verified 145/145 files
+- [?] VGA **palette selection**: which of the 71 `PALETTE.E` palettes per image
+- [?] VGA **transparency** index (colorkey) value + semantics
+- [ ] `.PC` character graphics (`CHR/*.PC`) — different header (`00 68 ...`), runs
+      of `0xfc`; format unknown
+- [ ] `.PC` structured data (`DAT/CITY*.PC`) — city records (contains "Paris")
+- [ ] `.BOB` sprite collections (count + offset table; e.g. `BOB/GAESTE.BOB`)
+- [ ] `.DAT` maps/districts (`DAT/BEZ*.DAT`, `DAT/MAP*.DAT`) — tile grids
+- [ ] `.FNT` bitmap fonts (`FONTS/*.FNT`)
+- [ ] `.MIS` mission definitions (`M/*.MIS`)
+- [ ] `.PIZ` hiscores (`D/HISCORES.PIZ`)
+- [ ] `T/*.E` text/dialog strings → structured extraction
+- [ ] Confirm whether `.PC`/`.BOB` reuse the VGA RLE codec
+
+## Phase 2 — DOS environment spec (`spec/environment/`)
+- [ ] LE/Watcom/DOS4GW executable & memory model
+- [ ] VGA mode (resolution, palette, page flipping)
+- [ ] sound stack, input
+
+## Phase 3 — game mechanics (needs disassembly; DEFERRED decision)
+- [?] Disassembly approach: own LE loader vs. Capstone/Ghidra (decide when here)
+- [ ] main loop & daily simulation tick
+- [ ] economy, customers, recipes/popularity, staff/managers, mafia missions
+
+## Phase 4 — consolidation
+- [ ] `glossary.md` (German↔English domain terms)
+- [ ] top-level spec overview tying the layers together
+
+## Reimplementation (JS) — deferred until spec matures
+- [ ] (intentionally not started; spec-first)
diff --git a/JOURNAL.md b/JOURNAL.md
new file mode 100644 (file)
index 0000000..403ade5
--- /dev/null
@@ -0,0 +1,50 @@
+# openpt — work journal
+
+Chronological log of findings and decisions. Append newest at the bottom. The
+high-level status map is in [CHECKLIST.md](CHECKLIST.md).
+
+---
+
+## 2026-06-29 — Session 1: project shape + VGA image format
+
+**Decisions (with the user):**
+- Goal: reverse-engineer Pizza Tycoon into a **language-neutral, human-readable
+  spec first**; reimplementation (JS or otherwise) deferred.
+- Spec = knowledge, not artifacts. Format docs are format-neutral prose using the
+  **trio**: layout table + pseudocode + test vector. Each claim tagged with
+  provenance + confidence.
+- Tooling is first-class and documented: **pure Python stdlib, zero third-party
+  deps**. Output uses **PNM (PGM/PPM), not PNG**. Avoid DOSBox if we can (soft).
+- Notation: prose now, formalize (e.g. Kaitai) later only if worth it.
+- Disassembly approach for `PT.EXE` is the one big DEFERRED decision; not needed
+  for the asset/data phases.
+
+**Recon of `PIZZA/`:**
+- `PT.EXE` (470K) = LE executable, Watcom C/C++32 + DOS/4GW (flat 32-bit).
+- Engine loads external data by filename templates baked in `PT.EXE`
+  (`chr\c%de.pc`, `bob\typ%de.bob`, `dat\city%d.pc`, `%s.mis`, …; `e` = English).
+- Custom formats inventoried: `.VGA`/`.PC` images, `.BOB` sprites, `.DAT` maps,
+  `.FNT` fonts, `.MIS` missions, `.PIZ` hiscores, `T/*.E` plain-ASCII strings.
+
+**VGA image format — DONE (high confidence):**
+- Container: `u16 width, u16 height, u32 size`, then `size` RLE bytes
+  (`size == filesize - 8`). Decompresses to exactly `width*height` palette
+  indices, row-major. Verified header on all files.
+- RLE codec (shared, see `conventions.md`): control byte `c`; top bit clear =
+  literal run of `(c&0x7f)+1` bytes; top bit set = repeat next byte
+  `(c&0x7f)+1` times. **Both counts biased by 1.**
+- Established the bias values **empirically** via `openpt.verify`, probing all 4
+  bias combos against all 145 `.VGA` files: only `lit+1, run+1` gives
+  145/145 exact `width*height`. (Hand-analysis had guessed the repeat count
+  wrong — worth noting the empirical loop caught it.)
+- Visually confirmed: `GFX/BACK1.VGA` (a pizza on a table), `Z/ZB1.VGA` (a
+  competition medallion), `GFX/BACK10.VGA` (restaurant interior). Geometry exact.
+- Palette: `GFX/PALETTE.E` = 71 palettes × 256 RGB, **6-bit DAC** (0..63),
+  scale ×255/63 to 8-bit.
+
+**Open questions raised (see CHECKLIST):** which palette pairs with which image
+(not positional); the transparency/colorkey index; whether `.PC`/`.BOB` reuse
+this RLE codec.
+
+**Artifacts:** `artifacts/*.pgm` (index-as-gray) and `*.ppm` (color). Tools:
+`tools/openpt/{rle,vga,palette,pnm,decode_vga,verify}.py`.
diff --git a/prompt.md b/prompt.md
new file mode 100644 (file)
index 0000000..3d7af97
--- /dev/null
+++ b/prompt.md
@@ -0,0 +1,29 @@
+# AI prompt: reverse Pizza Tycoon and reimplement in JS
+
+I want to do an experiment, the kind of which tends to head in different
+directions at the same time. Here are some points of view which describe what I
+want to do:
+
+- examine technological artifacts from the DOS era, and their technical
+  limitations
+- understand how a DOS game was coded
+- reverse engineer the spec of a game from its binary form
+- use the spec to reimplement the game with a relative level of fidelity versus
+  the original
+- use javascript as the target technology; discern how much of the fidelity is
+  owed to (partially) emulating aspects of the original DOS environment and how
+  much to JS
+- explore, see where this leads
+
+although Pizza Tycoon (found in the `PIZZA` folder) is a relatively small game
+(~7MB in size), it has a certain level of complexity that I expect would make
+it unfeasible for this sort of task to be achieved in a single shot. so for
+this prompt, I want you to scope this complexity by exploring the game, and
+devise a plan for a. reverse engineering the game, which should result in a
+human-readable spec that anyone could use to implement the game; and b.
+reimplementing the game in JavaScript -- I'm choosing JavaScript only because
+it's a popular technology nowadays, but in principle someone should be able to
+reimplement the game in, say, Python, or a whole different technology in the
+future, if they wished so.
+
+are you able to help me with that?
diff --git a/spec/formats/conventions.md b/spec/formats/conventions.md
new file mode 100644 (file)
index 0000000..76a9f74
--- /dev/null
@@ -0,0 +1,87 @@
+# Format conventions
+
+Shared notation and primitives used by all `spec/formats/*` documents.
+
+## Reading this spec
+
+Each format is described as a **trio**:
+
+1. **Layout** — an offset/type table of the byte structure.
+2. **Algorithm** — any non-trivial transform (e.g. decompression) as pseudocode.
+3. **Test vector** — a real byte snippet with its expected decoded result, so an
+   implementation in any language can self-check.
+
+Every non-trivial claim carries **provenance** and **confidence**:
+
+- provenance: `observed` (decoded bytes / watched the game), `disassembled`
+  (read it in `PT.EXE`), `inferred` (deduced from README/data/context).
+- confidence: `high` / `medium` / `low`.
+
+The prose here is normative and language-neutral. The Python under `tools/` is a
+*reference implementation* of this prose — one way to realize it, kept runnable
+so the spec stays verifiable — not a second source of truth.
+
+## Primitives
+
+- **Endianness:** little-endian throughout (Watcom C/C++32, x86). *(disassembled
+  toolchain + observed in every multi-byte field; confidence high.)*
+- `u8` = unsigned byte; `u16` = 2-byte LE unsigned; `u32` = 4-byte LE unsigned.
+- Pixel data is **row-major**, top row first, one `u8` palette index per pixel.
+- Colors are stored as **6-bit VGA DAC** values (0..63); see
+  [vga-image.md](vga-image.md) and the palette discussion there.
+
+## The RLE codec  *(provenance: observed; confidence: high)*
+
+A single byte-oriented run-length scheme is shared by the VGA image files (and,
+pending confirmation, likely other image containers). It interleaves two kinds
+of run, distinguished by the top bit of a **control byte** `c`:
+
+| `c & 0x80` | meaning | bytes consumed after `c` | output |
+|-----------:|---------|--------------------------|--------|
+| `0` (clear) | **literal** run | `(c & 0x7f) + 1` | those bytes, verbatim |
+| `1` (set)   | **repeat** run  | `1` | that byte, repeated `(c & 0x7f) + 1` times |
+
+Both counts are stored **biased by 1** (encoded value = count − 1), so one
+control byte expresses a run length of 1..128.
+
+### Algorithm (pseudocode)
+
+```
+decode(input) -> output:
+    i = 0
+    while i < len(input):
+        c = input[i]; i += 1
+        n = (c & 0x7f) + 1
+        if c & 0x80:                 # repeat run
+            v = input[i]; i += 1
+            output.append(v repeated n times)
+        else:                        # literal run
+            output.append(input[i : i+n]); i += n
+    return output
+```
+
+Consumers that know the expected pixel count (`width*height`) may stop once it
+is reached; trailing bytes, if any, are padding.
+
+### Test vector
+
+Input (first payload bytes of `Z/ZB1.VGA`):
+
+```
+0e 40 41 41 42 43 43 44 45 45 46 46 47 47 48 48 82 4a 08 4b 4c 4c 4d 4d 4e 4e 4f 4f
+```
+
+Decodes to 27 bytes:
+
+```
+40 41 41 42 43 43 44 45 45 46 46 47 47 48 48 4a 4a 4a 4b 4c 4c 4d 4d 4e 4e 4f 4f
+```
+
+Walk-through: `0e`→literal of 15 (`40..48`); `82`→repeat `4a` ×3; `08`→literal
+of 9 (`4b..4f`).
+
+### Verification
+
+`python3 -m openpt.verify PIZZA` decodes all **145** shipped `.VGA` files; with
+the bias values above, every file decodes to exactly `width*height` pixels
+(145/145 exact, 0 short, 0 over). No other bias combination matches any file.
diff --git a/spec/formats/vga-image.md b/spec/formats/vga-image.md
new file mode 100644 (file)
index 0000000..22ac8db
--- /dev/null
@@ -0,0 +1,74 @@
+# VGA image container (`*.VGA`)
+
+RLE-compressed, 8-bit palettized still images. Used for backgrounds, panels and
+scene art. Shipped under `GFX/*.VGA` (e.g. `BACK*.VGA`) and `Z/*.VGA` (e.g.
+`ZB*.VGA`) — 145 files total.
+
+*(Note: the `.PC` extension is **not** this format; it is overloaded by the game
+for both character graphics and structured data — see future `pc-*.md`. `.VGA`
+is the format documented here.)*
+
+## Layout  *(provenance: observed; confidence: high)*
+
+| offset | type | field | notes |
+|-------:|------|-------|-------|
+| 0 | u16 | `width`  | pixels |
+| 2 | u16 | `height` | pixels |
+| 4 | u32 | `size`   | length of the RLE payload; always `filesize - 8` |
+| 8 | u8[`size`] | `payload` | RLE stream (see [conventions.md](conventions.md#the-rle-codec)) |
+
+The payload decompresses to exactly `width * height` bytes, one palette index
+per pixel, row-major from the top-left. The `size` field is redundant with the
+file size in every shipped file, but is the authoritative payload length.
+
+### Algorithm
+
+```
+parse(file) -> image:
+    width, height, size = read u16, u16, u32   # offsets 0,2,4
+    payload = file[8 : 8 + size]
+    pixels  = rle_decode(payload)              # see conventions.md
+    assert len(pixels) == width * height
+    image = { width, height, pixels }          # pixels are palette indices
+```
+
+### Test vector
+
+`Z/ZB1.VGA`: header `64 00 30 00 3b 12 00 00` → `width=100`, `height=48`,
+`size=0x123b=4667` (file is 4675 bytes = 4667 + 8). Payload decodes to 4800
+bytes = 100×48. (First 27 decoded bytes given in
+[conventions.md](conventions.md#test-vector).)
+
+## Palette  *(provenance: observed + inferred; confidence: medium)*
+
+Pixels are indices into a 256-color VGA palette. The game ships a palette bank
+at `GFX/PALETTE.E`:
+
+- 54528 bytes = **71 palettes** × 768 bytes; each palette is 256 RGB triples.
+- Values are **6-bit** (0..63), i.e. VGA DAC range. Scale to 8-bit for display:
+  `v8 = round(v6 * 255 / 63)`.
+
+**Open question — which palette goes with which image.** Rendering `GFX/BACK1.VGA`
+under palette 0 yields a recognizable but mis-tinted picture, so the
+image→palette association is *not* implicit/positional. It is presumably chosen
+by the game per screen/scene (candidate sources: a table in `PT.EXE`, or a
+per-scene load). Tracked in [CHECKLIST](../../CHECKLIST.md). Until resolved, the
+reference tools can emit a palette-independent grayscale PGM (gray = raw index)
+to verify geometry, or a PPM under a caller-chosen palette index.
+
+## Open questions
+
+- **Transparency:** scene images show a uniform "background" index (rendered as
+  cyan/orange under palette 0) that is almost certainly a transparent/colorkey
+  index rather than a real color. Value and handling TBD. *(inferred; low.)*
+- **Palette selection** per image (above).
+- Whether other extensions (`.PC` character art, `BOB` frames) reuse this exact
+  RLE codec (they appear to share the scheme; headers differ). *(inferred; low.)*
+
+## Reference tooling
+
+- `tools/openpt/vga.py` — parser (this layout).
+- `tools/openpt/rle.py` — the shared codec.
+- `tools/openpt/palette.py` — `GFX/PALETTE.E` bank loader.
+- `tools/openpt/decode_vga.py` — CLI: `.VGA` → PGM/PPM.
+- `tools/openpt/verify.py` — conformance check over all shipped `.VGA` files.
diff --git a/tools/openpt/__init__.py b/tools/openpt/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tools/openpt/build.py b/tools/openpt/build.py
new file mode 100644 (file)
index 0000000..82e7eaf
--- /dev/null
@@ -0,0 +1,42 @@
+"""Regenerate all derived artifacts from the original PIZZA/ data.
+
+Single entry point so artifacts are reproducible and need not be committed.
+Currently: decode every .VGA image to a palette-independent grayscale PGM
+(gray = raw palette index) under artifacts/vga/. As more formats are reversed,
+add their regeneration here.
+
+  python3 -m openpt.build [PIZZA_DIR] [ARTIFACTS_DIR]
+"""
+
+import glob
+import os
+import sys
+
+from . import pnm, vga
+
+
+def build_vga(pizza, artifacts):
+    out_dir = os.path.join(artifacts, "vga")
+    os.makedirs(out_dir, exist_ok=True)
+    files = sorted(glob.glob(os.path.join(pizza, "GFX", "*.VGA")) +
+                   glob.glob(os.path.join(pizza, "Z", "*.VGA")))
+    ok = 0
+    for f in files:
+        img = vga.VgaImage.parse(open(f, "rb").read())
+        if len(img.pixels) != img.width * img.height:
+            print("  WARN size mismatch:", f)
+            continue
+        name = os.path.splitext(os.path.basename(f))[0]
+        pnm.write_pgm(os.path.join(out_dir, name + ".pgm"), img.width, img.height, img.pixels)
+        ok += 1
+    print("vga: wrote %d/%d PGM files to %s" % (ok, len(files), out_dir))
+
+
+def main(argv):
+    pizza = argv[1] if len(argv) > 1 else "PIZZA"
+    artifacts = argv[2] if len(argv) > 2 else "artifacts"
+    build_vga(pizza, artifacts)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/tools/openpt/decode_vga.py b/tools/openpt/decode_vga.py
new file mode 100644 (file)
index 0000000..1f8ffa5
--- /dev/null
@@ -0,0 +1,42 @@
+"""CLI: decode a .VGA image to PNM.
+
+  python3 -m openpt.decode_vga <file.vga> <out> [--pgm] [--palette F.E] [--pal N]
+
+Default output is PPM (color) using palette N from gfx/palette.e. With --pgm,
+or when no palette is given, output a PGM where the gray value is the raw
+palette index (useful for verifying geometry independently of any palette).
+"""
+
+import sys
+
+from . import pnm, palette as palmod, vga
+
+
+def main(argv):
+    args = argv[1:]
+    if len(args) < 2:
+        print(__doc__)
+        return 2
+    src, out = args[0], args[1]
+    want_pgm = "--pgm" in args
+    pal_path = None
+    pal_idx = 0
+    if "--palette" in args:
+        pal_path = args[args.index("--palette") + 1]
+    if "--pal" in args:
+        pal_idx = int(args[args.index("--pal") + 1])
+
+    img = vga.VgaImage.parse(open(src, "rb").read())
+    print("%s: %dx%d, %d pixels decoded" % (src, img.width, img.height, len(img.pixels)))
+
+    if want_pgm or not pal_path:
+        pnm.write_pgm(out, img.width, img.height, img.pixels)
+    else:
+        bank = palmod.load_bank(open(pal_path, "rb").read())
+        rgb = pnm.indices_to_rgb(img.pixels, bank[pal_idx])
+        pnm.write_ppm(out, img.width, img.height, rgb)
+    print("wrote", out)
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv) or 0)
diff --git a/tools/openpt/palette.py b/tools/openpt/palette.py
new file mode 100644 (file)
index 0000000..f951802
--- /dev/null
@@ -0,0 +1,27 @@
+"""Parser for gfx/palette.e -- Pizza Tycoon's VGA palette bank.
+
+The file is a flat array of 6-bit VGA DAC palettes (values 0..63), each palette
+being 256 RGB triples (768 bytes). The shipped file holds 71 such palettes.
+
+6-bit DAC values are scaled to 8-bit for display: v8 = round(v6 * 255 / 63).
+"""
+
+PALETTE_BYTES = 256 * 3
+
+
+def _scale6to8(v):
+    return (v * 255 + 31) // 63
+
+
+def load_bank(blob):
+    """Return a list of palettes; each palette is a list of 256 (r,g,b) 8-bit."""
+    count = len(blob) // PALETTE_BYTES
+    banks = []
+    for p in range(count):
+        base = p * PALETTE_BYTES
+        pal = []
+        for c in range(256):
+            o = base + c * 3
+            pal.append((_scale6to8(blob[o]), _scale6to8(blob[o + 1]), _scale6to8(blob[o + 2])))
+        banks.append(pal)
+    return banks
diff --git a/tools/openpt/pnm.py b/tools/openpt/pnm.py
new file mode 100644 (file)
index 0000000..2b4650f
--- /dev/null
@@ -0,0 +1,32 @@
+"""Minimal PNM (Netpbm) writers -- pure stdlib, no PNG, no third-party deps.
+
+PGM (P5) = 8-bit grayscale; PPM (P6) = 8-bit RGB. Both are trivially readable
+binary formats that most image viewers and `file` understand. We deliberately
+avoid PNG to keep the toolchain dependency-free and the output format simple.
+"""
+
+
+def write_pgm(path, width, height, gray_bytes):
+    assert len(gray_bytes) == width * height, (len(gray_bytes), width * height)
+    with open(path, "wb") as f:
+        f.write(b"P5\n%d %d\n255\n" % (width, height))
+        f.write(bytes(gray_bytes))
+
+
+def write_ppm(path, width, height, rgb_bytes):
+    assert len(rgb_bytes) == width * height * 3
+    with open(path, "wb") as f:
+        f.write(b"P6\n%d %d\n255\n" % (width, height))
+        f.write(bytes(rgb_bytes))
+
+
+def indices_to_rgb(pixels, palette):
+    """Map palette indices -> flat RGB bytes using palette[i] = (r,g,b)."""
+    out = bytearray(len(pixels) * 3)
+    for k, idx in enumerate(pixels):
+        r, g, b = palette[idx]
+        o = k * 3
+        out[o] = r
+        out[o + 1] = g
+        out[o + 2] = b
+    return bytes(out)
diff --git a/tools/openpt/rle.py b/tools/openpt/rle.py
new file mode 100644 (file)
index 0000000..3d07e7b
--- /dev/null
@@ -0,0 +1,46 @@
+"""RLE codec used by Pizza Tycoon's VGA image files (gfx/*.vga, z/*.vga).
+
+This is the *reference implementation* of the algorithm documented in
+spec/formats/conventions.md. It is intentionally pure-stdlib and small so it
+reads like the pseudocode in the spec. It is one possible implementation, not
+"the" implementation -- the normative description lives in the spec.
+
+The scheme (verified empirically, see tools/openpt/verify.py):
+
+    control byte c:
+      c & 0x80 == 0  -> LITERAL run: copy the next (c & 0x7f) + 1 bytes verbatim
+      c & 0x80 != 0  -> REPEAT  run: output the next 1 byte (c & 0x7f) + 1 times
+
+    i.e. both counts are stored biased by 1 (encoded value = count - 1), so a
+    single control byte expresses a run of 1..128. Verified to decode all 145
+    shipped .VGA files to exactly width*height (see verify.py).
+"""
+
+
+def decode(data, *, lit_bias=1, run_bias=1, stop_at=None):
+    """Decode an RLE byte stream into raw bytes.
+
+    lit_bias / run_bias exist only so verify.py can probe the exact count
+    semantics; the verified production values are lit_bias=1, run_bias=0.
+    stop_at, if given, stops once that many output bytes are produced (used to
+    decode files that carry trailing padding after the image).
+    """
+    out = bytearray()
+    i = 0
+    n = len(data)
+    while i < n:
+        if stop_at is not None and len(out) >= stop_at:
+            break
+        ctrl = data[i]
+        i += 1
+        if ctrl & 0x80:
+            count = (ctrl & 0x7F) + run_bias
+            if i >= n:
+                break
+            out += bytes([data[i]]) * count
+            i += 1
+        else:
+            count = (ctrl & 0x7F) + lit_bias
+            out += data[i:i + count]
+            i += count
+    return bytes(out)
diff --git a/tools/openpt/verify.py b/tools/openpt/verify.py
new file mode 100644 (file)
index 0000000..db4ce66
--- /dev/null
@@ -0,0 +1,63 @@
+"""Empirically verify the VGA/RLE format against every shipped file.
+
+For each gfx/*.vga and z/*.vga: read header (w,h,size), confirm size == filesize-8,
+decode the payload, and check the decoded length reaches width*height. We probe
+the four plausible count-bias combinations so the *data* tells us the exact
+semantics rather than us guessing. Run:  python3 -m openpt.verify <PIZZA dir>
+"""
+
+import glob
+import os
+import struct
+import sys
+
+from . import rle
+
+HEADER = struct.Struct("<HHI")
+
+
+def vga_files(pizza):
+    pats = ["GFX/*.VGA", "Z/*.VGA"]
+    out = []
+    for p in pats:
+        out += sorted(glob.glob(os.path.join(pizza, p)))
+    return out
+
+
+def main(argv):
+    pizza = argv[1] if len(argv) > 1 else "PIZZA"
+    files = vga_files(pizza)
+    print("found %d .VGA files" % len(files))
+
+    # 1) header self-consistency: declared size == filesize - 8
+    bad_hdr = 0
+    for f in files:
+        blob = open(f, "rb").read()
+        w, h, size = HEADER.unpack_from(blob, 0)
+        if size != len(blob) - 8:
+            bad_hdr += 1
+            print("  HDR MISMATCH %s: size=%d filesize-8=%d" % (f, size, len(blob) - 8))
+    print("header size field == filesize-8 for %d/%d files" % (len(files) - bad_hdr, len(files)))
+
+    # 2) which (lit_bias, run_bias) decodes ALL files to exactly width*height?
+    print("\nprobing count-bias variants (decoded length vs width*height):")
+    for lit_bias in (0, 1):
+        for run_bias in (0, 1):
+            exact = short = over = 0
+            for f in files:
+                blob = open(f, "rb").read()
+                w, h, size = HEADER.unpack_from(blob, 0)
+                want = w * h
+                got = len(rle.decode(blob[8:8 + size], lit_bias=lit_bias, run_bias=run_bias))
+                if got == want:
+                    exact += 1
+                elif got < want:
+                    short += 1
+                else:
+                    over += 1
+            print("  lit_bias=%d run_bias=%d -> exact=%-3d short=%-3d over=%-3d"
+                  % (lit_bias, run_bias, exact, short, over))
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/tools/openpt/vga.py b/tools/openpt/vga.py
new file mode 100644 (file)
index 0000000..973433e
--- /dev/null
@@ -0,0 +1,30 @@
+"""Parser for Pizza Tycoon's VGA image container (gfx/*.vga, z/*.vga).
+
+Layout (little-endian), documented normatively in spec/formats/vga-image.md:
+
+    offset 0  u16  width  (pixels)
+    offset 2  u16  height (pixels)
+    offset 4  u32  size   (bytes of RLE payload that follow; == filesize - 8)
+    offset 8  ...  RLE-compressed 8-bit palette indices, row-major,
+                   width*height pixels after decompression
+"""
+
+import struct
+
+from . import rle
+
+HEADER = struct.Struct("<HHI")
+
+
+class VgaImage:
+    def __init__(self, width, height, pixels):
+        self.width = width
+        self.height = height
+        self.pixels = pixels  # bytes, length width*height, palette indices
+
+    @classmethod
+    def parse(cls, blob):
+        width, height, size = HEADER.unpack_from(blob, 0)
+        payload = blob[8:8 + size]
+        pixels = rle.decode(payload, stop_at=width * height)
+        return cls(width, height, pixels)