Skip to content

Captive Technical

Daniel Nylander edited this page Jul 29, 2026 · 24 revisions

Captive technical notes

Runtime model

Captive is implemented as a grid-based first-person crawler. The engine keeps logical DungeonLevel arrays at 64×32 cells. A cell has a type, per-face wall textures, floor/ceiling textures, ornaments and small object fields. The viewport renders the current level while HUD, inventory, terminal, shop and puzzle systems operate on the same GameState.

PL5 graphics

A PL5 image is 40,000 bytes: 320×200 pixels at five bits per pixel. Every five input bytes encode eight output pixels using a bespoke bit layout, not a normal linear bitstream. pl5_decode() expands it to indexed pixels; palette values are converted to ARGB for renderer textures.

The decoder has a dedicated regression test. Bounds checks reject a truncated payload instead of reading beyond it.

DOS renderer observation log

The verified DOS executable has SHA-256 71bcf404103f1ac2920800a8bc166939bb49a1204cf51bebce8aca7dd5faafde before LZEXE unpacking. Static analysis of its unpacked 16-bit code shows a 320×200 VGA presentation path and two distinct PL5 blitters. One copies decoded pixels unconditionally; the other preserves destination pixels for transparent source values. Both write four VGA planes when planar output is active. The executable also contains the original game-screen, roof, wall and door asset references.

These observations validate the resource-to-framebuffer path and are useful for checking the PL5 decoder. They do not yet identify the first-person projection tables, draw ordering, wall/door state encoding or creature placement. OpenCaptive therefore keeps the default dungeon viewport unpainted until those parts can be reproduced from original behaviour rather than an invented perspective approximation.

The renderer's preparation path also reads map cells with a 64-byte row stride, then rotates the sampling direction from the party orientation. It builds a near-field 5×5 cell window in a work buffer with an eight-byte row stride before dispatching draw operations. That establishes the original traversal window and map row width; it does not yet reveal the screen-space projection or the resource table selected for each cell type.

ANM animation

ANM starts with a 768-byte VGA palette of 6-bit RGB triples. A little-endian word at offset 768 marks the command end. Frame records are read backwards from end of file; each ends in a little-endian total-size word. Frame deltas use:

non-zero byte  => XOR that byte into the current output position
zero byte      => next byte is a skip count
zero + zero    => end of frame

The target is a 64,000-byte 320×200 chunky frame buffer. Reconstructing each frame from XOR deltas preserves the original incremental animation behaviour.

RNC Method 1

Atari ST and Amiga resources may begin with RNC\x01. The header contains big-endian unpacked and packed lengths plus CRC16 fields. Method 1 uses three Huffman tables per sub-block. The decoder validates lengths before allocating or copying and is kept separate from container readers.

Media containers

  • Atari ST: FAT12 disk image; the BIOS parameter block begins at offset 11.
  • Amiga: 80 tracks × 2 sides × 11 sectors × 512 bytes (880 KiB), with OFS or FFS filesystem structures.
  • DOS-style asset sets: may be extracted or ZIP-packed; the VFS identifies required content by SHA-256.

Architect map generation

The original map model is a single 2,048-byte, 64×32 allocation divided into sixteen 16×8 physical sections. Logical floors are assigned across those sections; changing level changes the logical floor offset, not the allocation shape. The modern engine exposes those regions as levels[], which is an API adaptation rather than proof of byte-for-byte MapGen parity.

The documented mission/base seed is:

seed = ((mission - 1) × 11) + base

The first base is seed zero. Architect uses the sparse physical sections 2, 6 and 10 (one-based numbering) and starts the player at (30,0). OpenCaptive implements and tests that special case. For maps 1–4, the documented usable section sets progressively expand: row one plus 6–7; rows one/two plus 10–11; the first three rows; then the first three rows plus 14–15.

The original generator has 30 ordered stages. The documented order includes floor layout, offsets, root position, unconditional walls, elevators, no-touch zones, digging/rooms, root directions, dead-end smoothing, fire, generators, doors, puzzles, traps, encounters, decorations and exterior generation. Current map_generate_base() preserves the allocation dimensions, early masks, root special case and deterministic API, but it is not yet a byte-identical implementation of those 30 stages. In particular, the current PRNG arithmetic and feature placement must not be treated as recovered original code until they are validated against original MapGen output.

The technical reference used for this boundary is the documented MapGen introduction and its linked stage pages. New MapGen work must add an original-output fixture or an independently reproducible reference before claiming parity.

Campaign and combat

The mission objective is generator destruction, not elapsed time or creature count. Completing every generator advances to the next mission; mission ten sets victory. Combat uses grid line-of-sight: walls and closed doors block both droid and enemy attacks. Normal doors require interaction to change to floor.

Clone this wiki locally