Skip to content

PAN Presentation Files (*.PAN)

Animated scene container format used for all intro and ending sequences. Opened sequentially at startup (OPENING2.PAN through OPENING9.PAN) and again for the ending (CREDITS.PAN, DEATH.PAN, FIN0A.PANFIN5A.PAN). Audio is paired separately in .DGT files.

Reverse-engineered from DARKLAND.EXE runtime tracing and static analysis. No prior public documentation of this specific format is known. Despite sharing the .PAN extension with other MicroProse titles, the Darklands structure does not match the PANI-tagged format used in Covert Action or Railroad Tycoon.

Format status: fully decoded. Corpus-validated across all 15 assets and 2,068 frame records. Runtime SHA1 verification confirms byte-for-byte agreement between static replay and live DOSBox framebuffer output.

Extension
*.PAN
Location
Game root
Byte order
Little-endian (16-bit)
Size
Variable — compressed; logical stream opens with magic 0x0A5A + frame table + 768-byte palette
Compression
Custom LZ bitstream — stateful decoder at 0DFC:0070; control bits via RCR BP,1
Magic
0x0A5A at logical stream offset 0x00 (post-decompression)
Status
Documented
Source
RE — no prior public docs; corpus-validated 15 assets / 2,068 frames; runtime SHA1-verified

Format Overview

The format is a five-layer stack:

PAN file bytes
  → 0DFC:0070 stateful stream decoder
  → logical stream
  → 5A frame-end table
  → 768-byte embedded VGA palette
  → 42/1 frame-delta records
  → 320×200 indexed framebuffer

Layer 1: Compressed Stream

The file-level decompressor is the runtime routine at 0DFC:0070. It is stateful across calls: each invocation fills a destination buffer and returns the number of bytes emitted. The next call resumes from the preserved bitstream state. Related routines in the same segment:

AddressRole
0DFC:002AOpen stream and initialize decoder state
0DFC:000CRefill 0x1000-byte file input buffer at A897
0DFC:0059Close stream handle
0DFC:006AReturn persistent status word [08BC]
0DFC:0070Decode one output span

The decoder maintains a 16-bit control word in BP and a bit counter in DX. Control bits are consumed via RCR BP,1. When the counter is exhausted, a new little-endian control word is loaded from the input stream.

Span grammar

def decode_span(out):
    while True:
        if next_bit() == 1:
            out.append(read_u8())                       # literal byte
            continue

        if next_bit() == 1:
            word = read_u16le()
            backref = 0xE000 | ((word >> 11) << 8) | (word & 0x00FF)
            count3  = (word >> 8) & 0x07
            if count3 != 0:
                count = count3 + 2
            else:
                m = read_u8()
                if m <= 1:
                    status = m ^ 1
                    return                              # span terminates here
                count = m + 1
            copy_from_output(out, signed16(backref), count)
            continue

        count   = 2 + (next_bit() << 1) + next_bit()
        backref = 0xFF00 | read_u8()
        copy_from_output(out, signed16(backref), count)

Critical: when the inner byte m is 0 or 1, the span terminates. These values do not encode copies. m = 0 → status becomes 1 (final span); m = 1 → status becomes 0 (caller may invoke 0070 again). Misreading m = 1 as a copy was the root cause of all earlier failed parse models.


Layer 2: Logical Stream Header

Every decoded logical stream begins with a 5A table:

0x0000  u16le   magic word = 0x0A5A
0x0002  u16le   frame count N
0x0004  u16le[] cumulative frame-end offsets — N entries

Frame-end positions are cumulative from logical offset zero:

record_end[0] = delta[0]
record_end[i] = record_end[i - 1] + delta[i]

The layout of the remainder of the logical stream:

table_end           = 4 + 2 * N
palette_offset      = table_end
first_record_offset = table_end + 768

Example (OPENING8.PAN, 231 frames):

table end:    0x01D2
palette:      0x01D2..0x04D2
first record: 0x04D2
stream end:   0x83412

Layer 3: Embedded VGA Palette

768 bytes immediately following the 5A table. 256 RGB triplets, each component a 6-bit VGA DAC value in the range 0..63.

Convert each component to 8-bit RGB:

rgb8 = (dac6 << 2) | (dac6 >> 4)

The first 16 entries are the standard EGA/VGA base colors. Every PAN asset in the corpus contains a complete, valid 768-byte palette.


Layer 4: Frame Records

Each frame record begins at the previous record’s end, with the first record at first_record_offset. Every record opens with a 4-byte header:

42 00 01 00

The bytecode payload begins immediately after that header and runs to the record end as defined by the 5A table. All records in the corpus terminate with the end16 opcode.


Layer 5: 42/1 Frame-Delta Bytecode

The payload updates a 320×200 indexed framebuffer (64,000 bytes, row-major, one byte per pixel). The destination cursor starts at offset 0 for each record.

Byte opcodes

RangeOpcodeAction
0x01..0x7Flit8 NCopy next N literal bytes to framebuffer; cursor += N
0x80..0xFFskip8 Ncursor += (opcode − 0x80)
0x00fill8Next byte = count; next byte = value; write value N times; cursor += N

Extended opcodes (introduced by 0x80, followed by a u16le word)

Word rangeOpcodeAction
0x0000end16Terminate record
0x0001..0x7FFFskip16cursor += word
0x8000..0xBFFFlit16Copy (word − 0x8000) literal bytes; cursor += count
0xC000..0xFFFFfill16Fill (word − 0xC000) cells; value = next byte

Note: 0x80 is a zero-count skip8, so it always introduces an extended word. The distinction between skip8 (0x81..0xFF) and the extended prefix (0x80) is unambiguous.


Reference Parser

def parse_pan(path):
    logical = decode_all_0070_spans(path)

    assert read_u16le(logical, 0) == 0x0A5A
    n = read_u16le(logical, 2)

    total = 0
    ends = []
    for i in range(n):
        total += read_u16le(logical, 4 + 2 * i)
        ends.append(total)

    table_end   = 4 + 2 * n
    palette_rgb = decode_vga_palette(logical[table_end : table_end + 768])

    records = []
    start = table_end + 768
    for end in ends:
        assert logical[start : start + 4] == b"\x42\x00\x01\x00"
        records.append((start, end, logical[start + 4 : end]))
        start = end

    return palette_rgb, records


def replay(palette_rgb, records):
    fb = bytearray(320 * 200)
    frames = []
    for _, _, payload in records:
        execute_42_1(payload, fb)
        frames.append(bytes(fb))
    return frames

Corpus

AssetFramesLogical stream
CREDITS.PAN1320x49410
DEATH.PAN1840x4AAA5
FIN0A.PAN1250x796F0
FIN1A.PAN1070x241D1
FIN2A.PAN640x19E71
FIN3A.PAN750x1BCE2
FIN4A.PAN1400x2C74E
FIN5A.PAN700x27559
OPENIN6Z.PAN700x68782
OPENING2.PAN3580xAACC2
OPENING3.PAN1000x5145A
OPENING4.PAN1900x269A9
OPENING7.PAN1670x377EC
OPENING8.PAN2310x83412
OPENING9.PAN550x21A0C

Total: 15 assets, 2,068 frame records, 0 boundary mismatches.


Player Architecture

The PAN player lives in overlay segment 0B2E:

AddressRole
0B2E:0150Loader: strips extension, appends .PAN, routes to decompressor
0B2E:0465Entry-table copy into working buffer
0B2E:05CAMain scene interpreter — top-level command dispatch
0B2E:0508Fade/tick progression path
0B2E:0648Branch into 42/1 render family
0B2E:067BFar call into blit helper thunk
0B2E:068EContinuation address computation
0B2E:0568Flat-address/range update block

Load chain:

0B2E:0150 → 0DFC:002A → 0E3A:0052 → 0EC4:2C9E

Palette and fade helpers

ThunkTargetRole
11E3:18A12036:0047Blank display before blit
11E3:18972036:0000Upload palette and cache it
11E3:18792036:0061Restore display after blit
11E3:18AB2036:0126Advance one fade step
11E3:18832050:000EMain 42/1 blit worker
11E3:188D2050:002ENon-42/1 record worker

Runtime Validation (OPENING8.PAN)

Live DOSBox A000 captures versus static replay SHA1:

RecordLogical boundaryResult
00x04E96c43ae8cf75ac763dabe08970603729ac2e9b97db
50x0AE6E0fa0318fe15fb73696a903461fb5983529404497
600x277F628761466278129ee75c8a38ebaf0303442d8f235

Full static replay final framebuffer: c8640a0b692a40ca7e94841f074c0e7eb25899cc

Physical pool rebase (records 69–76): constant delta 0x26013 between physical pool offsets and logical record ends. Pool slice SHA1 matches static: 07e6da286075f4bce05e2d462bfdc6182e93d0c3.


Open Questions

  1. Exact mechanics of the 0318/0465 physical pool-window rebase path — does not affect static parsing or rendering.
  2. Palette fade and DAC carry timing between PAN assets at runtime.
  3. Runtime asset ordering for the finale sequence.

Confidence

High. Format fully decoded for static parsing and rendering. Corpus-validated across all 15 assets and 2,068 frame records with zero mismatches. Runtime SHA1-verified at multiple frame boundaries in OPENING8.PAN. Documented in devlog #028.