Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Captain Bible reverse-engineering

Project repository: https://github.com/peterkelly/captain-bible-re

This book records the evidence, methods, and results of reverse-engineering Captain Bible in the Dome of Darkness. The supplied copy is a DOS program whose main executable is CB.EXE. The work begins with a reproducible FreeDOS virtual machine before moving to static and dynamic analysis.

Statements in this book should distinguish direct observations from inferences. Commands and their important results are preserved in the progress log.

Current scope

The playable QEMU environment is complete. Static analysis has identified the packer and run time, reconstructed the executable, mapped startup and major support routines, and recovered the command-line, export, input, and save paths. Dynamic analysis now uses a QEMU TCG plugin to trace game-originated DOS and sound-driver calls without modifying CB.EXE, correlating file activity and runtime addresses with the reconstructed program. The resource container, graphics, scene bytecode, audio, text, saves, maps, combat, conversation, and final progression all have reproducible inspectors or archive-backed regressions.

Focused input/save traces, representative gameplay screens, and a live combat memory capture now supplement the earlier startup evidence. Remaining questions concern semantic names and branch-level behavior that the current evidence does not distinguish. Those boundaries are recorded in PLAN and in the relevant system chapters.

Environment and Running the Game

Host tools

The first inventory found:

  • QEMU 11.0.2 (qemu-system-i386 and qemu-img)
  • Rizin 0.9.1
  • mdBook 0.5.3

The host is macOS on Apple Silicon. QEMU therefore runs the i386 guest using software emulation rather than hardware virtualization.

Guest operating system

The selected guest is the stable FreeDOS 1.4 release. The FreeDOS project recommends its LiveCD for installation in a virtual machine. Its published minimum is an Intel-compatible processor, 640 KiB of memory, and at least a 20 MB hard disk.

The repository-specific setup instructions require a noninteractive build rather than the FreeDOS installer. tools/setup_freedos_image.py downloads and verifies the official LiteUSB archive, extracts the largest image member, preserves its boot code and filesystem, and builds a new 1 GiB raw disk with a single active FAT16 LBA partition.

The base image is:

build/freedos/freedos.img

It has a 1 MiB partition offset (LBA 2048), so its mtools path is:

build/freedos/freedos.img@@1048576

The builder initially produces a clean FreeDOS base image. run.sh clones it to a separate persistent play image, copies CB/ to C:\CBDOME, installs CuteMouse from the LiteUSB package set, and replaces the clone’s boot scripts so the game starts automatically. At the user’s request, the complete game was also copied into the current base image at C:\CBDOME; rebuilding the base image will remove that additional copy.

Run the game

From the repository root:

./run.sh

The persistent play disk is build/captain-bible/captain-bible.img. Use ./run.sh --setup-only to prepare the disk without opening QEMU or ./run.sh --rebuild to replace it from the current CB/ tree. Rebuilding discards saves stored only in the old play disk.

For deterministic comparison, supply an unsigned decimal initial state from 0 through 65,535:

./run.sh --rng-seed 1

This mode never edits CB/CB.EXE. Immediately before QEMU starts, the launcher reconstructs and signature-checks the unpacked executable, installs the portable RNG, clones the persistent disk into a unique run directory below build/qemu-deterministic-rng/, injects the patched executable only into that clone, and copies it back out for a byte-for-byte check. QEMU runs the clone with -snapshot, so guest writes are discarded at exit. An existing persistent play image is unchanged unless --rebuild is also supplied; initial setup may create it. A normal launch removes the unique run directory after QEMU exits. Use ./run.sh --setup-only --rng-seed 1 to build and verify the disposable image without opening QEMU and retain its printed directory for inspection.

When booting the current base image directly instead, enter CD \CBDOME followed by CB. The normal ./run.sh path needs no DOS commands because its derived play image starts the game automatically.

The QEMU machine provides:

  • a Pentium-class i386-compatible CPU with 16 MiB RAM;
  • standard VGA in a visible Cocoa window with zoom-to-fit enabled on macOS;
  • a PS/2 mouse served through the CuteMouse DOS driver;
  • Sound Blaster 16 digital audio; and
  • AdLib-compatible FM synthesis.

The supplied SOUND.1 identifies itself as a Sound Blaster 16 driver, and SOUND.2 identifies itself as a Sound Blaster Pro FM driver. These match the emulated devices.

Verification result

The base image passed a screenshot-free boot smoke test. A temporary clone wrote FREEDOS_READY to C:\BOOT.OK from both patched boot-script paths, and the marker was read with mtools after QEMU stopped. A separate bounded launch of the play image reached the Captain Bible title screen at 640×400.

The user subsequently verified the complete interactive path: keyboard and mouse input work in the visible QEMU session, and the game exits normally through its menus. This closes the environment/playability check independently of the automated boot and title-screen evidence.

Initial game requirements

The supplied MANUAL.TXT says to change to the installation directory and run CB. It reports that the game needs approximately 500 KiB of conventional memory. Its optional command-line switches and player-name prefix are now recovered from game_main and documented in the static-analysis and save-game chapters.

Reproducing the Results

This chapter provides one end-to-end path through the repository. Individual format chapters explain the evidence and field meanings; this page answers the practical question “which commands recreate and verify the results?”

The original game files remain under CB/ and are intentionally not tracked. Generated disk images, extracted resources, memory dumps, rendered media, and the HTML book remain under ignored build/ directories. The Python tools, tests, Rizin names, checked symbol catalog, and Markdown sources are tracked.

1. Check the input copy

The supplied executable and main container used for this research are:

FileBytesSHA-256
CB/CB.EXE64,2992b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0
CB/DD1.DAT1,866,068a395fcf9f19d655a6440b5b8ab213983eb7d34a99810b763a9c95360f98f9562

Verify them on macOS with:

shasum -a 256 CB/CB.EXE CB/DD1.DAT

The file-inventory chapter records every supplied file, size, timestamp, hash, and current identification.

2. Build and run the DOS environment

Construct or verify the FreeDOS base and persistent play image without opening QEMU:

tools/setup_freedos_image.py
./run.sh --setup-only

Play with the required visible, zoom-to-fit Cocoa display and silent host audio:

./run.sh

Use ./run.sh --rebuild only when deliberately replacing the persistent play image and its saves. The environment chapter documents the image layout, mtools offset, boot scripts, and guest path.

To run DOS with the portable deterministic generator and seed 1:

./run.sh --rng-seed 1

The corresponding Rust launch is:

cargo run --release --manifest-path rust-engine/Cargo.toml -- \
  --data CB --rng-seed 1

The DOS command reconstructs a patched executable and injects it into an ignored disposable disk clone. It never writes the original packed executable. After normal setup, it also leaves the persistent play image unchanged unless combined with an explicit --rebuild; initial setup may create that image. Seed 1 produces the first six generator outputs 19511, 30543, 10098, 22502, 1941, 10629 in both engines.

3. Reconstruct and inspect the executable

Recover the EXEPACK-compressed load module:

tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE

When the recorded title dump is present, independently compare relocation and the live process image:

tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE \
  --memory-dump build/dumps/title-physical-1m.bin \
  --load-segment 0x627

Load recovered names and verify the checked symbol catalog:

rizin -b 16 -i analysis/cb.rz build/analysis/CB_UNPACKED.EXE
tools/inspect_symbol_map.py

Then verify every BIN dispatch target and operand-reading path against the unpacked executable, symbol catalog, decoder, and original archive:

tools/audit_bin_opcodes.py

The executable and symbol-map chapters define load offsets, file offsets, segment translation, confidence levels, and handler-address auditing. The checked per-opcode report is analysis/opcode-audit.tsv.

4. Extract the resource archive

List and expand all 369 directory entries:

tools/extract_dd1.py --list CB/DD1.DAT
tools/extract_dd1.py --extract-all build/dd1/all CB/DD1.DAT

Repeated archive names receive numeric prefixes in all-member output, so no member overwrites another. The extractor validates declared sizes, payload magic, compression boundaries, and exact input consumption.

5. Reproduce graphics results

Inspect frame descriptors, render a canvas, and regenerate the annotated full-screen gallery:

tools/render_art.py build/dd1/all/003_LOGO.ART --list
tools/render_art.py \
  build/dd1/all/003_LOGO.ART \
  --palette build/dd1/all/002_LOGO.PAL \
  --canvas --scale 2 \
  --output build/graphics/logo.png
tools/render_fullscreen_gallery.py \
  CB/DD1.DAT \
  --output build/graphics/full-screen-gallery.png

These paths cover all 143 ART members and infer scene-selected palettes from decoded BIN commands. The graphics chapter records descriptor validation and the independent QEMU framebuffer comparison.

6. Inspect scripts and gameplay systems

The general BIN decoder supports display objects, choices, animations, and actions:

tools/inspect_bin.py build/dd1/all/005_INTRO.BIN
tools/inspect_bin.py build/dd1/all/001_LOGO.BIN --objects
tools/inspect_bin.py build/dd1/all/327_BOSS.BIN --choices
tools/inspect_bin.py \
  build/dd1/all/337_COMBAT7.BIN --animations --actions

Inspect the world grid and late-game road graph with their specialized tools:

tools/inspect_map.py CB/DD1.DAT --map CE --rooms
tools/inspect_map.py CB/DD1.DAT --map CE --hall-features
tools/inspect_unibot.py build/dd1/all/315_CP2.BIN

The scene, object, conversation, combat, state, maps, and endgame chapters connect those views to executable runtime tables and progression behavior.

7. Reproduce audio and text results

Decode a sound effect, validate an XMIDI container, and join a verse index to its companion text stream:

tools/convert_abt.py \
  build/dd1/all/306_D003.ABT \
  --output build/audio/d003.wav
tools/inspect_xmi.py build/dd1/all/267_MUS001.XMI
tools/inspect_midpak_ad.py CB/SOUND.4
tools/inspect_text_resources.py \
  CB/DD1.DAT --data-dir CB \
  --translation N --bank A --record 0

The audio decoder covers all 41 ABT members; the XMI parser covers all 32 music members; and the timbre inspector validates all 181 installed OPL patches. The text inspector covers every translation and bank pairing. Their chapters record the live PCM and QEMU-export comparisons.

8. Inspect saves and mutable state

Decode the fixed label index, one state, its descriptors, and named variables:

tools/inspect_save.py CB/DDGAMES.SV0
tools/inspect_save.py CB/DDGAMES.SV3 --descriptors
tools/inspect_save.py CB/DDGAMES.SV9 --variables
tools/inspect_map.py \
  CB/DD1.DAT --map CE --compare-save CB/DDGAMES.SV3

These commands connect the 2,752-byte save layout to script state, text descriptors, and the mutable 768-byte map.

9. Repeat dynamic tracing when needed

Launch the visible QEMU session with the game-filtered DOS-call plugin:

./run.sh --trace-dos

The dynamic-analysis and sound-driver chapters document generated paths, deterministic segment assumptions, preserved startup hashes, live AX capture, and the int 16h/21h/33h/66h record formats. This is an interactive capture, not part of the fast test suite.

For a controlled scene-entry experiment, patch both scene names in a research copy of a state and inspect a subsequent physical-memory dump:

tools/patch_save_scene.py input.SVQ COMBAT1 output.SVQ
tools/patch_save_scene.py input.SVQ ROOM3 output.SVQ --coordinate 13 6
tools/inspect_runtime_tables.py memory.bin \
  --data-segment 0x14e1 \
  --bin build/dd1/all/343_COMBAT1.BIN

The combat-runtime chapter states the provenance limitation: the preserved COMBAT1 capture entered through a patched hall quick-save, not by walking to the encounter naturally. The loaded action and animation tables nonetheless match the selected BIN resource byte for byte in every compared definition. The optional coordinate pair patches variables 11 and 12 in both serialized variable blocks and was used to probe coordinate-sensitive handlers.

10. Run the complete noninteractive verification

From the repository root:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py
tools/audit_bin_opcodes.py
bash -n run.sh tools/build_qemu_dos_trace.sh
mdbook build docs
test -f docs/book/index.html

The tests read original inputs directly from CB/ and do not depend on a previous extraction directory. The documentation checker requires every book chapter to appear exactly once in SUMMARY.md, validates local links and anchors, and confirms that repository commands in shell examples exist and are executable. The final HTML is written to docs/book/.

Result boundaries

The noninteractive suite proves deterministic parsing, structural invariants, known byte-level regressions, documentation integrity, and book generation. It does not claim to automate subjective gameplay checks. The completed interactive pass is recorded separately because it used a visible QEMU session, monitor input, screenshots, guest save files, and physical-memory dumps rather than the fast suite.

Executable Reconstruction

File identity

The supplied CB/CB.EXE is a 64,299-byte, 16-bit DOS MZ executable. Its SHA-256 is:

2b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0

The filesystem timestamp is 1996-12-24 23:32 in the host’s +0700 time zone. The MZ header has no outer relocations and points to 0F79:0010, only 395 bytes before the end of the file. The load module’s entropy is approximately 7.004 bits per byte. These are consequences of executable compression, not evidence that the game code is encrypted.

Microsoft EXEPACK

The bytes at file offset 0xF990 are a 16-byte Microsoft EXEPACK header. The RB signature, the decompression loop, and the 22-byte Packed file is corrupt error message identify the format. Microsoft’s MS-DOS Encyclopedia describes EXEPACK as LINK’s compression of repeated byte runs and the relocation table. David Fifield’s independently maintained EXEPACK implementation documents and implements the specific header and backward decompression algorithm used here.

FieldValue
Packed MZ entry0F79:0010
EXEPACK header file offset0xF990
Compressed load bytes63,376 (0xF790)
Complete EXEPACK block411 bytes (0x019B)
Decompressor and message277 bytes (0x0115)
Reconstructed load bytes75,264 (0x12600)
Real entry0000:CB5C
Initial stack1A40:1388
Reconstructed minimum allocation0x091A paragraphs
Reconstructed relocation count43

The packed relocation table is 118 bytes: 32 bytes of counts for 16 groups plus 43 two-byte offsets. The recovered load-module relocation offsets are:

0034c 0033c 00334 002ac 000af 000aa 034d4 034c8 035fb 0429c
04286 04275 0424f 04246 0422c 041fc 0414e 04143 0412e 04129
04115 040f0 043da 043cf 043ba 0439d 04392 0437d 04363 04335
04320 04316 042ed 05146 07615 0973a 09ae8 09e8c 0ab31 0cb67
0cbf1 12214 122a8

tools/analyze_cb_exe.py implements the relevant MZ and EXEPACK decoding with the Python standard library. It emits a 75,776-byte conventional MZ file with SHA-256:

4875f83d6d2ba9c1cc4f058e351e453010c6a5976e1b15976b676689f9747643

That output is byte-identical to the result from Fifield’s EXEPACK 1.4.0 at source revision f715ed19285565d636e78182fc19df62c0fa64b9.

QEMU memory verification

QEMU was run with the required visible Cocoa display and its monitor and GDB server enabled. Once the title screen appeared, the VM was stopped and the first MiB of physical memory was saved:

build/dumps/title-physical-1m.bin

The captured register state located the process precisely:

ItemValue
PSP segment0617
Load/CS segment0627 (physical 0x06270)
Title-screen CS:IP0627:C614
Title-screen DS=ES=SS14E1
Relative data-segment base0xEBA0
Physical data-segment base0x14E10

After adding load segment 0x0627 at all 43 MZ relocation sites, the rebuilt load module and the QEMU process memory have an identical prefix of 0x905A bytes. There are 5,612 differing bytes in the full 75,264-byte comparison; inspection shows runtime-initialized tables, loaded resource metadata, and BSS state. Known static strings occur at the predicted relocated addresses, including the Microsoft run-time banner at physical 0x14E18. This makes the QEMU snapshot a strong independent check of both decompression and relocation.

Address convention and memory model

All function addresses in this book and analysis/cb.rz are linear offsets from the DOS load-module base, not file offsets. Add 0x200 for the unpacked file offset. At the captured load segment, add physical base 0x6270 for a physical address.

The C startup sets DS to load segment plus 0x0EBA, so a source-level data reference such as DS:08DA corresponds to load offset 0xF47A. Rizin does not automatically perform this segment addition and may present such immediates as references into low code addresses.

The evidence is consistent with the Microsoft C small memory model: ordinary code and data pointers are 16-bit, all normal C calls are near within one code segment, and explicit far pointers are used for loaded resources and driver interfaces. The initialized data contains:

MS Run-Time Library - Copyright (c) 1988, Microsoft Corp

This identifies the Microsoft C run time and Microsoft LINK/EXEPACK, but does not by itself prove an exact compiler release.

Static Analysis Findings

Working with the disassembly

Generate the unpacked executable and apply the current symbol map:

tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE
rizin -b 16 -i analysis/cb.rz build/analysis/CB_UNPACKED.EXE

Rizin’s first recursive pass identifies approximately 340 candidate functions. Several large candidates cross jump tables or data and are false merges, so the names below are limited to routines whose implementation and call sites provide direct evidence.

Program entry and main

The real entry at 0xCB5C is Microsoft C startup code. It checks for DOS 2.0, resizes the process allocation, clears BSS, constructs argc, argv, and envp, and calls the near function at 0x8A82 with all three values. That function is therefore main.

The high-level static flow is:

  1. If argv[0] has a drive-qualified path, 0x8A09 changes to that drive and directory.
  2. main initializes resource-name buffers including LOGO, seg, and DDGAMES, then parses all remaining arguments.
  3. 0x3363 verifies VGA, reads SOUND.5, loads the configured SOUND.1 through SOUND.4 drivers, opens DD1.DAT, and initializes subsystems.
  4. 0x7F58 reads the save index, and 0xB818 loads RUN.ART as resource slot 253.
  5. 0x7D2B creates a new session and initializes the menu/game state.
  6. main repeatedly updates audio and dispatches states 0, 1, and 2 to the menu/game loop, reset path, and restore path. It has no ordinary return.

Runtime random source

Startup initialization at 0x3363 calls the clock helper at 0x02CF. That helper reads the DOS local time and returns hour * 3600 + minute * 60 + second; startup masks the low word with 0x7FFF and passes the result to the runtime seed function at 0xE954. The seed is zero-extended into the 32-bit state at DS:3968. New Game does not repeat this startup initialization.

The runtime function at 0xE966 updates that state with state * 0x000343FD + 0x00269EC3, retaining the low 32 bits, and returns (state >> 16) & 0x7FFF. Opcode 0x82 divides that result by its immediate and stores the remainder. The X text-component shuffle calls the same function twice for each of 20 swaps. Unused opcode 0x8B also shares the stream: it takes the result modulo the loaded descriptor count, scans cyclically to the next set state byte, clears it, and stores 3,000 at DS:005C.

The call-reference audit found one additional dormant consumer at 0x649F. While flag 36 and the DS:005C timer established by unused opcode 0x8B are nonzero, each status refresh subtracts the current batched timer delta, calls 0xE966, and uses the result’s low two bits as X/Y flip flags for zero-based STUFF.ART frame 27 at (10,10). The controller invokes this refresh before and after the scene VM with the same elapsed delta, so an already-active timer normally consumes two values per update. A refresh that expires the signed timer still draws, then clamps it to zero; clearing flag 36 freezes the timer and suppresses the draw. No shipped scene executes opcode 0x8B, so this path does not alter ordinary-game call ordering.

Portable deterministic replacement

The comparison launcher replaces both the self-contained DOS generator above and the two separate coordinate-indexed reads used by opcodes 8E and 91; it does not merely hold the clock seed fixed. tools/patch_deterministic_rng.py first reconstructs the verified unpacked MZ, then applies four length-preserving, signature-checked edits at load-module offsets:

OffsetReplacement
0xE957Replace mov ax,[bp+4] with mov ax,seed, so the existing seed routine ignores the clock argument and stores the requested 16-bit state.
0xE966Replace the 38-byte Microsoft-compatible generator with the portable 16-bit generator, padded with NOPs in the original extent.
0x5905Replace opcode 8E’s unchecked coordinate calculation and memory read with one call to the portable generator and an eight-bit reduction.
0x5971Apply the same replacement to opcode 91.

The portable recurrence is:

state = (state * 0x6255 + 0x3619) modulo 2^16
value = state >> 1

Both handlers use value & 0xFF. The multiplier is one modulo four and the increment is odd, so the state period is all 65,536 values. The patch fits in place, overlaps no relocation, preserves the original calling convention, and requires no code cave. It is a deliberate comparison/reference patch rather than a claim about the original executable’s implementation.

Command-line parser

The parser in main lowercases the option letter and handles exactly the six switches described by MANUAL.TXT:

SwitchStatic behavior
-tSets the no-mature-topics flag.
-bXMaps K to 0, N to 1, R to 2, and L or T to 3.
-cSets the no-combat flag.
-idirectoryCopies the suffix after -i and appends a backslash for configuration and sound-driver paths.
-sXfilenameInitializes data, applies translation X, and calls the export routine with the filename after X.
-gXXComputes 10 * argv[i][2] + argv[i][3] - 0x210, the decimal two-digit export mask.

An argument without a leading hyphen replaces the DDGAMES save prefix and implements the manual’s per-player name/path option. An unknown switch calls the game’s message path with Huh?.

Text export

Function 0x5F92 opens the requested file in text-write mode. It parses the game’s record tags and writes labeled sections. Direct bit tests recover the full -gXX mask:

BitValueOutput
01Lie/verse number (#00 template)
12CYBER LIE:
24PARAPHRASE: / lock text
38CONVERSATION WITH VICTIM:
416Communications-room material, represented by several record tags
532VERSE:

The routine writes the heading CAPTAIN BIBLE IN DOME OF DARKNESS, iterates the available records/buildings, and uses Microsoft C fread/write and text stream helpers. This is implementation evidence for the manual’s export feature rather than merely a string search. The text-format chapter documents the extensionless verse indexes, companion DDL* streams, loader, inspector, and complete QEMU export validation.

Save files

The default player prefix is DDGAMES; a non-option argument replaces it. The .SV0 index is nine 27-byte label buffers, normal states are .SV1 through .SV9, and F10/F9 use the independent .SVQ state. Static copy direction separates checkpoint and live fields in each fixed 2,752-byte state. The scalar fields include translation, music, effects, and checkpoint/live text-bank values; the 66 ten-byte descriptors connect exactly to the recovered text resources. The save-game format chapter gives the full 15-block layout, filename logic, supplied-file comparison, error behavior, and reproducible inspector.

Input and hardware support

0x90D4 uses BIOS video interrupt 10h functions 1A00h and 12h to classify the display adapter. Startup requires return value 2 and otherwise prints VGA not detected.

0x8E0A checks interrupt vector 33h and calls the mouse reset function. Functions at 0x8D50 and 0x8D5D show and hide the mouse cursor. 0x8D79 reads mouse motion and buttons, clamps coordinates to 320×200, and accumulates button press/release bits. The event combiner at 0x7BED merges this with the keyboard path and returns internal event codes used by 0x875D, including Escape, Enter/click, pointer movement, and the four extended arrow codes. The position belongs to this persistent input subsystem, so scene loading and New Game do not reset it.

The independent startup framebuffer comparison isolates the cursor’s exact 19 changed pixels at center (160,100): 16 palette-index-1 crosshair pixels at axis offsets 3 through 6, and a three-pixel palette-index-15 upper-right shadow. This live overlay is separate from ART resources and is drawn above scene and interface output.

The keyboard wrapper tests BIOS interrupt 16h service 01h and consumes an available word with service 00h. It therefore receives BIOS typematic entries for a held key rather than maintaining a game-specific repeat timer. Every repeated word follows the same menu or gameplay dispatcher as its initial key press.

Converter 0x0010 returns a nonzero BIOS ASCII byte directly and otherwise adds 80h to the BIOS scan byte. This preserves modifier distinctions that a host adapter cannot infer from the physical key alone: Shift+Enter is still 0Dh, Ctrl+Enter is 0Ah, Alt+Enter is extended, and Alt+Escape is extended while the plain, Shift, and Ctrl Escape forms remain 1Bh.

The executable consequently has no separate keypad dispatcher. BIOS keypad Enter produces the same Enter value; navigation-mode keypad keys produce the same Home, End, arrow, and page scan values; and numeric-mode keypad keys produce printable digits or punctuation. Shift reverses the effective Num Lock state before those BIOS values reach converter 0x0010.

The executable also contains wrappers around DOS interrupt 21h, BIOS video interrupt 10h, keyboard interrupt 16h, and a loaded-driver interface on interrupt 66h. The sound-driver chapter assigns all 34 CD 66 sites to DIGPAK or MIDPAK services and records their register contracts.

Installation lock file and sound drivers

Startup reads all four bytes of SOUND.5 into a local structure. The fields are installation policy rather than sound-hardware selection:

OffsetSizeUse
02Bible-translation lock, applied only when no command-line lock was supplied; 0070h means unlocked.
21Mature-topic marker; only DBh permits mature topics, otherwise the no-mature flag is forced.
31Value ORed into the no-combat flag.

The supplied file is 01 00 00 00: translation value 1, forced no-mature mode, and no installation-level combat restriction. This also explains why -b, -t, and -c cannot relax installation locks: startup preserves an already selected command-line translation and ORs the two restriction flags.

The no-mature flag does not remove runtime Bible descriptors. Map loader 0x034F uses it to clear connected kinds 1 through A when either cell parameter is at least E0; exporter 0x5F92 separately skips descriptors whose selector is at least E0.

The following four files are independent driver components loaded into far memory. SETSOUND.BAT records their original configured names:

Installed fileOriginal nameIdentification
SOUND.1soundrv.comDIGPAK Sound Blaster 16 digital driver, Audio Solution 3.40
SOUND.2midpak.advMiles Design Sound Blaster Pro FM music driver
SOUND.3tmidpak.comMIDPAK resident music package, Audio Solution 3.0
SOUND.4midpak.adMIDPAK timbre/instrument data

The dynamic trace confirms that load_file_into_far_memory at 0xACDA opens each file, measures it with seek calls, allocates its paragraph-rounded size, reads it in chunks, and closes it.

Main resource archive

The lookup routine at 0x99AB uppercases the requested base name and extension, scans an in-memory table in 24-byte steps, seeks the persistent DD1.DAT handle to the record’s 32-bit offset, and verifies the two-byte GC payload signature. The loader at 0x97D0 then selects either the raw far-copy path at 0x9BEF or the dictionary decoder at 0x9CA4 from the record marker.

The latter initializes 256 literal dictionary entries, reconstructs codes from groups of low bytes plus high-bit plane bytes, and expands prefix/suffix chains through 0x9D98. Static reconstruction plus extraction of every declared member establishes the full directory and compression format; see the dedicated DD1.DAT chapter.

Palette and artwork rendering

All PAL resources are raw 256-entry VGA DAC tables. The BIOS palette path at 0xA017 uses video function 1012h; the retrace-synchronized path at 0xA032 writes the same six-bit RGB triplets through ports 03C8h and 03C9h.

Every ART frame has a 12-byte descriptor containing signed X/Y origins, unsigned width/height, and a 32-bit pixel offset. The direct frame routine at 0xB99C multiplies the requested index by 12, reads width at offset 4, height at 6, and the far pixel displacement at 8. The pixel body is row-major and eight bits per pixel. Low-level blitters provide both opaque copying and an index-0 transparent path. The graphics-format chapter records validation of all 143 resources and the exact QEMU framebuffer comparison.

Scene bytecode interpreter

The 62 BIN members contain scene programs interpreted by 0x451B. The routine reads opcodes 0x01 through 0x91 and dispatches through a 145-entry table at 0x59AB, containing 134 distinct handler addresses. Shared readers at 0x3A1E, 0x3A30, and 0x3A64 consume bytes, little-endian words, and pointer-capable strings from a far resource cursor; four resource-name handlers use separate inline-only string loops. An independent control-flow audit recovers the operand layout and dispatch effect for all 145 commands. Of those, 122 occur across 25,829 decoded commands.

initialize_scene at 0x6631 appends .BIN, loads the resource, and starts the interpreter at file offset zero. update_scene_threads at 0x7997 resumes stored file offsets. Directly identified handlers load .ART and .PAL members, select XMI music, change scenes, manage timing, manipulate variables, and implement absolute jumps, calls, and returns. The dedicated scene-bytecode chapter documents the complete structural schema, startup sequence, mixed code/data regions, inspection tool, and QEMU memory check.

Unibot and final sequence

The final programs form a fully recoverable script state machine. GANTRY mirrors seven victim-rescue flags into seven crew-present flags; CP1 counts them and refuses departure until all seven are aboard. ROBOT clears the five powerups, initializes the Unibot position and heading, and enters CP2.

The 256-byte trailer of CP2.BIN stores a reciprocal 16-node road graph with four compass headings, seven pylon endpoints, one Tower endpoint, node types, and lower-right-map coordinates. Seven persistent variables record pylon rescues. An incomplete pylon encounter or premature Tower assault enters OVER; all seven allow the alternating FACE/CP3 confrontation. Its correct study response reaches KABLAM and WIN, while the wrong response sets state 9 and reaches OVER. A separate flag makes the road-network Annoy Cyber’s verse loss a one-time event. The dedicated Unibot chapter gives every table entry, variable, branch, and reproducible inspection command.

Scene display objects

Scene programs append up to 100 ten-byte display records at DS:A2AC, with the current count at DS:00E2. Direct object records contain signed X/Y, 8.8 scale, an ART-slot/visibility byte, one-based ART frame, render flags, and a type byte. Other types connect animation sequences or command threads to the same update list. The ART-slot high bit hides an object; two low bits in the separate flags byte flip its axes.

The reset function at 0x3AD2 releases every render slot and clears the count when changing scenes. The update function at 0x3AFF dispatches records by type, and 0xBCAC submits direct objects to the ART renderer. A visible, silent QEMU capture of LOGO.BIN contained 13 records whose type order and direct fields exactly match all 13 linear display definitions in the script. The dedicated scene-display-object chapter documents the layout, commands, QEMU addresses, inspector output, and boundary between display state and scripted gameplay state.

Combat animation and actions

The seven COMBAT*.BIN programs contain 214 animation definitions with 2,596 nine-byte steps and 27 selectable action targets. Animation runtime records begin at DS:6EBA with a 12-byte stride; they retain the first/current BIN step offsets, timing, linked slot, mode, and render slot. Dedicated opcodes start, link, stop, wait for, and branch on those animations.

Selectable actions use a separate ten-byte table at DS:480E. Each record contains an absolute BIN target, screen coordinates, a selector-string offset, and an active byte. The first ART resource in every combat is COMBTAGS; rendering its four frames identifies selectors .11 through .14 as ATTACK, DEFEND, RETREAT, and COMBAT. Pointer and keyboard paths search active targets and start a BIN scheduler slot at the selected target.

Random branches, the Sword and Shield flags, opcode-0x81 faith loss, and map/progression changes live in script state. Every Retreat target jumps around the victory mutation into a shared exit. Six ordinary combats mark the current map cell as kind 0xB; the exceptional guard program uses kind 0xA, copies parameter B to A, omits faith loss, and never sets the combat-active flag. COMBAT7 implements the Zapper reward by ending a meter flash at faith 10,000. No enemy-health field exists in the display or action-target records. The combat-runtime chapter documents the tables, commands, action outcomes, shared epilogue, and remaining dynamic validation boundary.

Conversation and choice flow

Scene scripts construct dialogue menus in a separate transient table. Opcode 0x45 clears the table, opcode 0x44 appends a six-byte target/text record, and opcode 0x46 presents the menu and suspends the scene thread. The record count is at DS:B428; records begin at DS:B116 and contain an absolute BIN target followed by a far pointer to the inline choice text. The generic text menu at 0x2556 returns the selected target through DS:7CBA, allowing the interpreter to resume directly at the chosen branch.

That renderer builds one 16-byte input record per wrapped row. The record’s first two words are (text_x + 72,row_y + 2), while words at offsets 8 and 10 place the SELECT sprite to the left or right of the text. Selector 0x6CE5 calls the approximate-distance helper at 0x3315, accepts the strictly nearest record at a distance no greater than 100, and rejects a nearest record whose marker byte at offset 15 is FE. The same machinery serves choices, Game Options, slot lists, and confirmation menus.

Dialogue opcodes 0x14, 0x48, and 0x4E share a presentation handler but serve distinct channels dominated by the adversary, other characters, and Captain Bible. Corpus analysis finds 40 choice definitions and 597 dialogue commands. A visible, silent QEMU capture of the five-choice BOSS.BIN menu matched every target and far text pointer from the static decode. Selecting the final row wrote target 0x095C and displayed the dialogue stored at that exact branch.

Conversation scripts also invoke the study-Bible browser. Opcode 0x7D selects a victim-conversation, paraphrase, or cyber-lie prompt, and opcode 0x49 requests the browser. A correct descriptor sets state flag 0x14; leaving without the expected match sets 0x15. The conversation-flow chapter documents the command lifecycle, runtime structures, BOSS memory correlation, study integration, and remaining boundaries.

World-map state

The archive contains 21 exact 768-byte map resources: levels A through G for Easy, Normal, and Difficult modes. Opcode 0x78 constructs their names and loads one into a mutable row-major 16×16×3-byte grid. Address calculations in the executable use 48*y + 3*x; the first cell byte contains independently used connection and location-kind nibbles, followed by two parameters.

The map screen also consults a 16-word exploration bitmap. Scene commands can process the current cell, mutate each cell field, normalize location kinds, and mark coordinates explored. The supplied SV3 and SV4 grids match CE.MAP except for four explained field changes. The dedicated world-map chapter gives the format, opcodes, save correlation, and inspection tool. It also decodes connection directions, five room classes and orientations, the seven hallway Cybers, Scripture stations, hidden Spider triggers, cleared encounters, level exits, and locked-room actions while keeping three environmental kinds explicitly unresolved.

Script state and progression

The two 200-byte save blocks are checkpoint and live copies of 100 signed script-variable words. BIN commands encode variables as even byte offsets within this block and provide copy, immediate assignment, signed comparison, branching, arithmetic, increment/decrement, and bitwise operations. Static handlers and complete-corpus validation identify 39 variables used by that core family.

Words 3 through 10 double as a 128-bit state-flag bank. Dedicated scene commands branch on, set, and clear flags. The executable rebuilds transient map flags 0x00..0x2F, while flags 0x30..0x34 are the five powerups and seven victim scenes set distinct rescue flags 0x3A..0x40. Variable 21 is faith on a 0–10,000 scale; opcode 0x81 applies difficulty-scaled loss. Separate text-descriptor state bytes connect obtained or completed text records to scene branches. The script-state chapter documents the complete recovered families and remaining semantic boundaries.

Digital effects and XMIDI music

Opcode 0x57 passes an effect number and rate to 0x417F. That routine formats D###.ABT, loads it from the archive, allocates the decoded sample count from the first word, calls the built-in decoder at 0x92E0, and submits the resulting PCM state to the DIGPAK interface on interrupt 66h.

The decoder implements absolute samples, run-length commands, and packed one-, two-, or four-bit adaptive delta blocks. Its helpers at 0x93BE, 0x94CB, and 0x956E add signed table deltas to the preceding sample and clamp to unsigned eight-bit PCM. All 41 resources decode exactly to 412,282 samples at 9,000 Hz. A QEMU breakpoint immediately before playback captured D003.ABT’s live 9,064-byte buffer; it is byte-identical to the host decoder.

Music function 0x4091 chooses MUS###.XMI or IBM###.XMI. All 32 resources are IFF/XMIDI files with FORM XDIR, one INFO sequence count, CAT XMID, and one FORM XMID containing TIMB and EVNT. The audio-format chapter documents both formats and their reproducible tools.

Startup initializes the family word at 0x76E8 to zero, then compares SOUND.2 offsets 0xD2..0xD4 with IBM at 0x35C3..0x35DE. A match changes the word to one; play_music_resource uses that word to choose the filename prefix. The supplied Sound Blaster Pro FM SOUND.2 has 00 20 02 there, so this installation selects the MUS family.

Checked symbol catalog

The complete checked map now contains 140 named functions, 26 named BIN handlers, and 9 data symbols. Each entry has its own evidence statement and a Verified or High confidence rating. The dedicated symbol-map chapter defines those levels, summarizes subsystem coverage, documents address translation, and gives commands that check the catalog against both analysis/cb.rz and Rizin’s resolved handler addresses.

Symbol and Function Map

The checked symbol catalog is analysis/symbol-map.tsv. It records every project-assigned name in analysis/cb.rz, rather than only a selected list of interesting routines. The current map contains 283 entries:

KindCountMeaning
Function140Application, driver API, decoder, rendering, and Microsoft C routines
BIN handler134Every distinct implementation address in the 145-entry scene-opcode dispatcher
Data9Strings and one recovered room-orientation table

All offsets are 16-bit linear offsets from the DOS load-module base. Add 0x200 to obtain an offset in CB_UNPACKED.EXE. For the recorded QEMU load at segment 0627, add physical base 0x6270. Initialized data references also require the documented DS base translation; the executable chapter gives the complete address convention.

Confidence scale

Each catalog row has its own evidence statement and one of these confidence levels:

LevelRequirementCurrent entries
VerifiedStatic semantics plus independent agreement with QEMU state, traced I/O, supplied saves, or exhaustive resource decoding163
HighInstruction behavior, callers, data layout, and cross-resource use uniquely support the name118
MediumBest current interpretation, but a material semantic ambiguity remains2

“Verified” does not mean source-level names were recovered: the executable has no debug symbols. It means the descriptive name has an independent check beyond recognizing the disassembly. “High” is still strong enough to load into Rizin. Unresolved candidate routines remain unnamed rather than being promoted to the catalog with speculative labels.

Coverage

The catalog groups evidence by subsystem. Counts include functions, handlers, and data symbols:

SubsystemEntriesPrincipal evidence
Bytecode141Complete opcode layouts, switch targets, and decoded BIN corpus
Runtime16Microsoft C startup banner, standard implementations, and call sites
Graphics14ART/PAL validation and QEMU framebuffer comparison
Saves10Exact supplied SV0/SV1–SV9/SVQ structures and copy directions
Text10All translation banks and byte-identical QEMU export
Audio41ABT/XMI validation, live PCM, INT 66h traces, and published driver ABI
State9Script corpus, saved words, flag masks, and faith behavior
Archive7Exact extraction of all 369 DD1 members
Input7Action tables, keyboard/mouse callers, and BIOS interfaces
Startup6Entry flow, DOS trace, configuration, and resource loads
Animation5Recovered runtime records and combat sequence corpus
Dialogue5Live choice table and study-Bible suspension sequence
Maps5All 21 maps, saved mutations, and room dispatch
Hardware3VGA/mouse BIOS checks and traced configuration open
Scene display3Live ten-byte record table and framebuffer path
Files1Traced DOS open/seek/read/close sequence

The TSV preserves one concise piece of evidence per individual entry. For example, decode_abt cites both exhaustive decoding and the live D003 PCM match, while normalize_map_cells remains High because its loop is clear but has not received an independent runtime capture.

Reproducible audit

Validate the catalog against every afn, fr, and named data flag in the Rizin script:

tools/inspect_symbol_map.py

The command rejects missing or extra names, changed function/data offsets, duplicate names, duplicate kind/offset pairs, unknown confidence labels, and empty evidence. It can also filter the readable listing:

tools/inspect_symbol_map.py --kind function
tools/inspect_symbol_map.py --confidence verified

BIN handler addresses originate in Rizin’s switch analysis, because cb.rz renames generated case flags rather than declaring those addresses directly. Regenerate and verify that final layer with:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c fl build/analysis/CB_UNPACKED.EXE \
  > build/analysis/cb-flags.txt
tools/inspect_symbol_map.py \
  --rizin-flags build/analysis/cb-flags.txt

The current Rizin run resolves all 134 distinct handlers at the cataloged offsets and emits no script errors. Archive-backed unit tests enforce the 140/134/9 counts and exact catalog-to-script coverage. The independent opcode audit additionally reads the dispatch table and handler control-flow graph from Rizin during its dedicated test.

Boundaries

Rizin’s recursive analysis currently proposes roughly 340 functions, but several candidates cross jump tables or data. The catalog therefore does not claim that 140 functions are the whole executable. They are the complete set of names supported by the reverse-engineering evidence so far.

The 134 handler names cover every distinct implementation needed to describe all 145 opcodes. The repeated addresses are the paired scaled-object command, five no-op values, three dialogue variants, four edge-transition callbacks, and the paired palette-loading command. Unused handlers keep low-level names when shipped scripts cannot establish a more specific gameplay role.

Dynamic Analysis

QEMU tracing workflow

tools/qemu_dos_trace.c is a QEMU TCG plugin that observes BIOS keyboard interrupt 16h, DOS interrupt 21h, mouse interrupt 33h, and sound-driver interrupt 66h without changing the game executable or disk image. Build and run it through the normal launcher:

./run.sh --trace-dos

Trace mode keeps the required Cocoa display visible, uses QEMU’s silent audio backend, and creates these ignored artifacts:

build/qemu-trace/qemu_dos_trace.so
build/qemu-trace/dos-calls.log
build/qemu-trace/monitor.sock

The plugin filters on the current game code segment 0627 and remains dormant until the reconstructed entry point 0627:CB5C executes. That prevents an earlier FreeDOS program that temporarily occupies the same segment from polluting the trace. Each call record contains its exact CS:IP, physical linear address, live AX, argument registers, data segments, and an escaped pathname when the DOS API uses one. A paired return record preserves AX, the other general and segment registers, and carry.

QEMU 11.0.2 represents the first x86 register’s opaque plugin handle with value zero. The tracer tracks descriptor presence separately instead of mistaking that valid EAX handle for a missing register. It retains static MOV AH/AX inference only as a fallback for targets that do not expose EAX. Trace mode also sets one-insn-per-tb=on; without it, plugin register reads describe the beginning of a longer translation block rather than the interrupt boundary.

For a focused instruction-boundary memory probe, invoke the plugin directly with a code offset and a data-segment range:

-plugin build/qemu-trace/qemu_dos_trace.so,\
log=build/qemu-trace/probe.log,cs=0x627,start=0xCB5C,\
probe=0x5913,probe-offset=0x008A,probe-size=0x0100,calls=off

probe is an IP within the filtered code segment. probe-offset and probe-size select up to 4,096 bytes from the live data segment. Each PROBE record contains the instruction-boundary registers and a hexadecimal memory snapshot. calls=off suppresses interrupt-call tracing for faster probes; the default remains calls=on. This mode produced the exact opcode 8E and 91 DOS coordinate-indexed-object-overrun captures documented in the scene-bytecode chapter.

Preserved startup capture

The verified capture ran from process startup into the first story text. The monitor stopped at:

RegisterValue
CS:IP0627:C668
DS=ES=SS14E1
FS0617 (PSP)
GS04C7

This independently repeats the earlier load and data segment values. The screen contains the opening narration beginning “There once was a city far from us in place and time,” establishing the visible runtime point. QEMU also saved the first MiB of physical memory to build/qemu-trace/startup-physical-1m.bin.

ArtifactSHA-256
dos-calls.logf8013fb529444c409a6309a5bbc57336d674382f4e20dcde9185a4d67658e3c9
startup-physical-1m.bin7fee3fdda30db225711d0db84d1f292efb9b087c4a91deb2e035025cd31bf71e
startup-screen.png85a46bbf6345d5cd88393596706ded3daadbbe0ecb9853cdd0bcecf610077c79

Comparing the captured memory with the independently unpacked and relocated load module again produces the exact 0x905A-byte static prefix and 5,612 runtime differences across 75,264 bytes, matching the earlier title capture.

The trace contains 195 completed game DOS calls. Its high-level file timeline is:

First callActivity
13Change directory to C:\CBDOME.
22Open and read the four-byte SOUND.5 installation-lock file.
29Load SOUND.1.
43Load SOUND.2.
68Load SOUND.3.
90Load SOUND.4.
105Probe DD1.DAT, then reopen it as the persistent main-data handle.
121Read DDGAMES.SV0, the 243-byte save-slot index.
135 / 142Load DDLC twice during early resource initialization.
181Reopen DDGAMES.SV0 before the story introduction.

All listed returns have carry clear. The path sequence is runtime evidence, not a prediction from embedded strings.

Sound-driver correlation

SOUND.5 is read in requests of one byte and three bytes. Each driver file is then opened, queried, measured with three seek calls, allocated in DOS memory, read in chunks, and closed. The observed allocation request exactly matches the file size rounded up to a 16-byte paragraph:

FileBytesAllocation (BX)Paragraph bytes
SOUND.14,824012E4,832
SOUND.216,26303F916,272
SOUND.313,312034013,312
SOUND.43,62200E33,632

This connects the static far-pointer loader at 0xACDA with the DOS API calls inside the Microsoft C low-level I/O routines. The trace supports the names libc_lowio_close (0xDA82), libc_lowio_seek (0xDAA2), libc_lowio_open (0xDB1C), libc_lowio_read (0xDCC0), and libc_lowio_write (0xDD9E).

Live decoded effect capture

For an independent codec check, launched QEMU with the same visible Cocoa display and silent audio devices, plus its GDB remote stub. A breakpoint at physical 0xA499 stopped Captain Bible at 0627:4229, after D003.ABT had been decoded and immediately before its state was submitted to interrupt 66h.

The state at DS:A0DE pointed to 5A45:0000 and declared 9,064 samples at 9,000 Hz. The 9,064-byte physical-memory dump from 0x5A450 exactly matches the independently implemented ABT decoder. Both have SHA-256 ca97ad22acf3cc39d078b619168fa026deb1606082999bfb8b9a1aac4957422b. The dedicated audio-format chapter records the codec and conversion tool.

Main container correlation

Startup first opens and closes DD1.DAT through normal stream setup, then opens it again as DOS handle 5. Subsequent resources cause seeks and reads on that handle; no DOS open is made for the static resource name RUN.ART. This is direct evidence that names such as RUN.ART refer to members indexed inside DD1.DAT, not sibling files in the DOS directory.

DDLC, by contrast, is opened as a separate DOS pathname twice during the captured interval. Later static and format analysis identifies it as tagged companion text bank C; DDLA through DDLG and DDLR use the same recovered record stream. The direct opens are therefore runtime confirmation that DDL text lives beside the main container, while the extensionless verse indexes live inside DD1.DAT. The text-format chapter documents their exact join.

Focused input and save capture

A later visible, silent QEMU run added int 16h and int 33h to the same instruction-boundary tracer. The game polls BIOS keyboard service 0101 at 0627:E9DC, mouse motion service 000B at 0627:8D8A, and mouse position/buttons service 0003 at 0627:8DCD. These sites independently match the statically named input wrappers.

Moving the monitor mouse changed the returned position from X/Y 0140:0064 to 01D0:0088, or (320, 100) to (464, 136). Holding the left button then produced repeated service-0003 returns with BX=0001 at the new position. This proves that the guest driver state reaches the game’s own polling path, not merely that QEMU accepted host events.

F10 produced BIOS scan/ASCII word 4400 first through the non-consuming service and then through service 0000. The next DOS activity opened the existing DDGAMES.SV0, then created and wrote DDGAMES.SVQ. After QEMU stopped, the quick save was exactly 2,752 bytes with SHA-256 5e329e21f32d2e6c3e564d3a3ad717ab07ad55aaedde2587725756945597e43f. The before/after .SV0 hashes were identical, confirming that quick save does not rewrite the normal-slot label index.

The normal in-game Escape/Save Game path ended name entry with BIOS word 1C0D and then rewrote the 243-byte DDGAMES.SV0 followed by the selected 2,752-byte DDGAMES.SV2. The low-level writes follow the recovered state layout: 200-byte snapshot and live blocks, 66 flags, 660 bytes of text descriptors, four 20-byte strings, five words, and two 768-byte maps. Parsing the copied slot with tools/inspect_save.py recovered the expected settings, bank C, INTRO/seg scene strings, and named script-variable state. This connects BIOS input, the menu path, write_save_state, Microsoft C low-level I/O, and the two on-disk formats in one run.

Adding more interrupt vectors exposed a tracer bookkeeping bug: an interrupt instruction inside DOS could have the same registered return callback active while a game call was pending. Return records from the affected capture are therefore not used for DOS-result claims; its call-entry paths and write arguments remain valid. The plugin now stores the pending call’s exact linear return address and ignores callbacks at every other CS:IP. A fresh bounded run recorded 119,824 paired calls—39,903 keyboard, 115 DOS, and 79,806 mouse—with no false DOS-internal return.

Representative interactive coverage

The visible Cocoa session exercised and captured these distinct paths:

SystemObserved evidence
StartupMain title, landscape title transition, and difficulty selector.
StoryOpening narration and multi-step commander conversation.
ExplorationCaptain Bible on the exterior platform and inside a hall.
StudyF1 Bible interface reporting no loaded verses.
NavigationF2 map interface with the current node graph.
StateF3 faith overlay reporting 100 percent.
Menus/savesGameplay options, normal-save slot list, and name entry.
CombatCOMBAT1 action screen, A-key attack effect, and defeat screen.

Function keys were sent only after normal gameplay began; an earlier F1 during the introductory sequence advanced into the difficulty selector instead of opening the Bible. The distinction is useful evidence that the same input is routed by scene state rather than handled as an unconditional global hotkey. The combat chapter records the controlled scene-entry provenance and table dump in detail.

File Inventory

All supplied game files have the host-interpreted timestamp 1996-12-24 23:32:00 +0700. The macOS .DS_Store file is host metadata and is excluded. file(1) descriptions are heuristic; in particular, its “Arhangel archive” label for DDLE is not treated as a format identification.

Programs, data, and support files

FileBytesSHA-256Current identification
CB.EXE64,2992b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0EXEPACK-compressed DOS MZ program
CB.ICO76681488f25bc2bfcdfbbb20091c2d9c3c48fb8be2ad5305dbc00c561f179a1780b32×32, 16-color Windows icon
CB.PIF2,88509818ec07f74adaf49ce5fb1ac31ea7be3fb5c157d04816217647db3d615013aWindows Program Information File
DD1.DAT1,866,068a395fcf9f19d655a6440b5b8ab213983eb7d34a99810b763a9c95360f98f9562Main indexed resource container
DDLA10,065b667b10536f2a7e9cb8b7d92afb8ef764fd2a4872703cb575129b05cf5616572Tagged companion text bank A
DDLB8,25344a5ea34fed950e8265c1eb9eaa7e151941d98e934a39324d52de9d544dc364aTagged companion text bank B
DDLC4,020bc2ab554cf0dfdd999c7ac6e357551d693f900dc3f0bc67c8248dff99216e560Tagged companion text bank C
DDLD14,973bf4176fee554a9613a0423c5b5a2df07976ae1171950ba992296071da0220006Tagged companion text bank D
DDLE10,4895a7de313baf42f50a47e06774aedc4e98969fc969673ea740b8cd27a77d3ea47Tagged companion text bank E
DDLF10,257b4ab910df4ff59433ffcdca44f450cd91e50c10b540f0d810826e4e6e8610ffcTagged companion text bank F
DDLG9,993ab9e8ab9cb8c1bd8d1944dcc8383d045f9ddd7ee8670549d430a037c5299a4b6Tagged companion text bank G
DDLR696067cfcfc63f07545e29d26a1d1773d2bd596d397663fd9716014ed4a48b28cc4Tagged victim-conversation text bank R
MANUAL.TXT36,3845f2f583be150e5e6c73a5a760e1847b3986e8635e86175b4f82d8c9f70368a42Plain-text game manual
SETSOUND.BAT5971d863472ef14a164966a93180bcb785ce32c0ce17a0321b6caf0892930fa18a1Sound configuration/install batch file
SOUND.14,824286ca4901bc2d3c18982b398ea5cef77920e4e542a834c6113902768c5d0e080DIGPAK Sound Blaster 16 COM driver
SOUND.216,263491896220f4d7284bcd213180bcb785ce32c0ce17a0321b6caf0892930fa18a1Miles Design Sound Blaster Pro FM driver
SOUND.313,31269796e9ceb0340bcb03799b49dfdbb4d604c3b22d8a2014e757faef07921427aMIDPAK COM package
SOUND.43,6223350419d0cec0c6c197d91e72340abb133f4de7cf00bff493c1f7ab9fb2ccef8MIDPAK timbre data
SOUND.5467abdd721024f0ff4e0b3f4c2fc13bc5bad42d0b7851d456d88d203d15aaa450Installation locks: 01 00 00 00

Supplied saves

FileBytesSHA-256
DDGAMES.SV02436f460832b488527a5cdc06d5860c10ff68509c9063ce0e88376757634f81ae43
DDGAMES.SV12,75226a409ea52ec2f21363408d7ae8bf886bca70c318ec751b39b01e947776c9e54
DDGAMES.SV22,752666cb666bec4290812cc8d2142755d58593e108f9d18daf47771f695de73237d
DDGAMES.SV32,75233d2f0ea672255e30db017eb5b74f14a521bd0000eaf497d5c8179ba92a19cd8
DDGAMES.SV42,7522102f392e43881001fc6b61097143ff09d9f869d1f7cdd7fc37b8102cdbe35d3
DDGAMES.SV52,752238d409176200f50dd009cb739f7f6393491f3b0b92e7ee998a549ea4cea947d
DDGAMES.SV62,75255c20b1824cbd7ea203dae82286a1999d1bd733b2e3f67a6a80fc44dcdd3cdee
DDGAMES.SV72,752b339db79febc4e8fec6880325a0b3c5fc64abdd63ab813cde033071aa70aedde
DDGAMES.SV82,75255c20b1824cbd7ea203dae82286a1999d1bd733b2e3f67a6a80fc44dcdd3cdee
DDGAMES.SV92,7526139575d1cf94a76d64a730d7bf06dd9da7c2f1b5a66e5f386ef05afff04a0f3

SV6 and SV8 are byte-identical. SV0 is the nine-record slot index; SV1 through SV9 use the fixed state layout documented in the save-game format chapter.

DD1.DAT Resource Container

Container directory

DD1.DAT is the game’s main named-resource archive. The supplied file is 1,866,068 bytes and has SHA-256 a395fcf9f19d655a6440b5b8ab213983eb7d34a99810b763a9c95360f98f9562. Its first little-endian word is the member count, followed immediately by fixed 24-byte records:

Record offsetSizeMeaning
0x008Zero-padded ASCII base name.
0x081Storage marker: 0 is raw and 1 is compressed.
0x093Zero-padded ASCII extension, without a dot.
0x0C4Absolute payload offset, little endian.
0x104Expanded size.
0x144Stored size, including the two-byte payload magic.

The file declares 369 records. Consequently, the directory occupies 2 + 369 * 24 = 0x229A bytes, exactly the offset of the first payload. Payloads are contiguous in directory order, and the last one ends exactly at the end of the container. Every payload starts with ASCII GC; lookup code at 0x99AB seeks to the recorded offset and verifies this word before returning the member.

The recovered population is:

ExtensionMembers
ART143
BIN62
ABT41
PAL37
no extension33
XMI32
MAP21

There are 38 raw members containing 203,303 expanded bytes and 331 compressed members containing 5,340,520 expanded bytes. The complete archive expands to 5,543,823 bytes. Three names occur twice: GANTRY.PAL, HOLEA.ART, and NG. Each repeated pair also has identical extracted content, but its two directory slots are retained rather than silently deduplicated.

Representative records connect the static directory to the QEMU trace:

IndexMarkerPayload offsetStoredExpandedName
010x229A5761,041BOSS2.ART
110x24DA431640LOGO.BIN
8210x6F69C16,13853,213RUN.ART

The startup trace’s seek to 0x24DA and its 431-byte stored and 640-byte expanded values match LOGO.BIN exactly. RUN.ART, which appears as a static name passed to the art loader, is likewise present only as archive member 82; DOS never opens it as a separate path.

Payload encodings

For marker 0, the bytes following GC are the resource verbatim. The stored size is therefore always the expanded size plus two.

Marker 1 selects the dictionary decoder at load offset 0x9CA4. It is an LZW-family scheme with an unusual byte-plane representation for code bits:

  1. Dictionary codes 0 through 255 are literal bytes. Each has a prefix of -1 and a suffix equal to the byte value.
  2. At the start of a dictionary pass, the decoder reads and emits one literal, stores it as the prefix of entry 0x100, and sets its code counter to 0x101.
  3. It reads the next code, saves that code as the prefix for the following dictionary entry, recursively emits the referenced phrase, and saves that phrase’s first byte as the suffix of entry counter - 1. This one-slot offset implements the normal LZW special case where the current phrase can refer to the entry being completed.
  4. When the counter reaches 0x1001, the dictionary pass restarts with a new literal. There is no clear code in the stream.

Codes do not form a conventional contiguous bitstream. Every group of up to eight codes stores all required high-bit planes first, followed by the eight low bytes. Bit 0 of each plane byte belongs to the first code in the group, bit 1 to the second, and so on. One plane supplies code bit 8, the next bit 9, up to four planes for the 12-bit dictionary range. The number of planes grows as the counter passes 0x100, 0x200, 0x400, and 0x800.

The implementation in tools/extract_dd1.py follows the assembly’s precise prefix/suffix update order. It also rejects truncated streams, undefined codes, dictionary cycles, over-expansion, unused compressed bytes, invalid directory padding, noncontiguous payloads, bad GC magic, and trailing container data. Regression outputs include:

MemberBytesExtracted SHA-256
LOGO.BIN6408580d3ff93c6e775aa71334c50762ffde2b1f42a320ee362f5608bd8cbc51424
RUN.ART53,213c4b00d2e31e9dec81cc419dc577086b143a546a4a0b618dbe5600df4e5fd4ac0

Extractor usage

List the directory:

tools/extract_dd1.py --list CB/DD1.DAT

Extract one uniquely named member:

tools/extract_dd1.py \
  --extract RUN.ART \
  --output build/dd1/RUN.ART \
  CB/DD1.DAT

Extract all members with their directory indices preserved:

tools/extract_dd1.py --extract-all build/dd1/all CB/DD1.DAT

The all-members form creates names such as 082_RUN.ART; the numeric prefix prevents duplicate archive names from overwriting one another. A duplicate can also be selected explicitly with --index and --output.

Executable routines

Load offsetCurrent nameEvidence
0x97D0archive_load_memberLooks up a record, then dispatches marker 0 to the raw reader or marker 1 to the decoder.
0x99ABarchive_lookup_memberUppercases and splits a requested name, scans 24-byte records, seeks to the payload, and checks GC.
0x9BEFarchive_read_raw_memberCopies the declared expanded length to a caller buffer in far-memory chunks.
0x9C5Farchive_refill_inputRefills the decoder’s archive input buffer.
0x9CA4archive_decode_memberInitializes literals, reads bit planes and codes, and builds the dictionary.
0x9D98archive_expand_codeRecursively walks prefix links and emits suffix bytes.

These names refer to offsets within the unpacked load module, using the same address convention as the rest of this book.

Palette and Artwork Formats

PAL: VGA DAC entries

Every one of the 37 PAL members is exactly 768 bytes: 256 consecutive red/green/blue triplets. Each component is in the inclusive range 0 through 63, matching the VGA’s six-bit DAC. There is no header. The host renderer expands a component with bit replication, eight_bit = (six_bit << 2) | (six_bit >> 4), so both endpoints remain exact.

The executable confirms this interpretation in two independent paths:

  • vga_load_palette_bios at 0xA017 invokes video BIOS function 1012h with 256 entries, start index 0, and a far pointer to the triplets.
  • vga_write_palette_range at 0xA032 waits for vertical retrace through port 03DAh, writes a starting index to 03C8h, then sends three unmodified bytes per entry to 03C9h.

There are 35 unique palette payloads. The two copies of GANTRY.PAL are identical, and RICH.PAL is identical to 1.PAL. The game also changes palette ranges at runtime for fades and color cycling, so a base PAL preview does not necessarily reproduce every animated color at a particular instant.

ART: descriptors and indexed pixels

An ART member begins with one or more fixed 12-byte frame descriptors. It has no explicit descriptor count; the first descriptor’s pixel offset is also the end of the descriptor table, so the count is first_offset / 12.

Descriptor offsetSizeTypeMeaning
0x002signed little-endianHorizontal origin or anchor offset.
0x022signed little-endianVertical origin or anchor offset.
0x042unsigned little-endianWidth in pixels.
0x062unsigned little-endianHeight in pixels.
0x084unsigned little-endianAbsolute pixel-data offset in this member.

Each descriptor is followed indirectly by exactly width * height bytes of row-major, eight-bit palette indices. Pixel blocks are contiguous in descriptor order and occupy the rest of the resource without padding.

This layout validates without exception across all 143 ART members:

PropertyValue
Total frame descriptors1,178
Total indexed pixels4,850,699
Largest frame table63 frames in MAP.ART
Full 320×200 frames at origin (0, 0)11

Signed origins matter. For example, RUN.ART frame 0 has origin (-28, -5) and size 46×61; these values position a running sprite relative to an entity, not directly on the screen. LOGO.ART demonstrates a multi-piece screen:

FrameXYWidthHeightPixel offset
08854124740x48
194801121070x2420
214791381650x52F0
320715101510xABE2
4-3-3790xC001
522138275300xC040

The object renderer treats the 8.8 scale word as a divisor. It computes frame dimensions and origins as value * 256 / scale, so 0x0200 halves artwork. For a horizontal reflection it computes anchor_x - scaled_origin_x - scaled_width; it does not reuse the normal anchor_x + scaled_origin_x position. This is directly visible in LOGO.BIN, which reflects frame 3 at X=303 to form the left half of the dome.

The descriptor itself does not specify transparency. The draw-call flags choose the copy operation. blit_rect_transparent_zero at 0xA106 tests each source byte and advances the destination without writing when the index is 0. blit_rect_opaque at 0xA136 copies every byte. The fast VGA copy at 0xA0C9 copies rows into segment A000h using a 320-byte screen stride.

Layering uses the stable render slot allocated by the scene’s mixed display list, rather than controller update order. The compositor at 0xC000 walks those slots in increasing order. This lets the direct LOGO.BIN dome pieces at display indices 7 through 9 occlude the moving actor at index 4, producing the oval entry and exit mask without a dedicated ellipse-clipping routine.

STUFF.ART: font and interface labels

The game does not obtain its dialogue font from the video BIOS. Startup calls the routine at 0xAE42, which loads STUFF.ART, retains frame 0’s pixel pointer and width, and constructs a 95-entry glyph-offset table at DS:A1E8. Frame 0 is a 257-by-14 atlas containing only source values 0, 1, and 2. The first seven rows hold bytes 21h through 60h; the offset resets to atlas row 7 for byte 61h (a) and continues through byte 7Fh.

The proportional widths live in the executable at DS:3462, indexed by character minus 21h. Renderer 0xAF36 copies exactly seven scanlines and advances by the glyph width plus one. Space and control bytes advance three pixels without copying. The modal compositor later doubles each logical pixel in both axes, yielding seven-row glyphs at 640-by-400 output with a 16-output-pixel line pitch. This explains why a fixed 5-by-7 host font drawn at one horizontal pixel per source column appeared narrow and tall.

The text-object renderer at 0xBE7E remaps the atlas values through a three-palette-index style. The ten recovered triplets are:

1:  1,  7,  3    2:  1, 37,  4
3: 16, 15, 17    4: 15, 84, 84
5:  0, 64, 70    6:  0, 32, 37
7: 15, 86, 90    8: 15, 74, 69
9: 15, 32, 36   10: 15,  1,  8

The live BOSS capture confirms style 2 for normal dialogue: its glyph pixels are framebuffer indexes 1, 37, and 4. State 1 uses style 1 for unselected choice rows and style 2 for the selected row. Character and Captain Bible messages enter state 2 and use style 2; adversary messages enter state 7 and use style 7. The Computer Bible prompt above the book also uses style 7 for an L cyber lie, style 9 for a P paraphrase, and style 10 for * victim-conversation text.

Several interface captions are complete indexed sprites in the same resource, including frame 16 GET VERSE, frame 28 SELECT, frame 29 CONTINUE, frame 30 UNLOAD, and frame 31 COMPUTER OFF. Their descriptors include the signed origin used to overlap the associated panel or row. In particular, SELECT is (-12,-3,24,7) and CONTINUE is (-17,-3,35,7). These sprites include their own letters and borders and are drawn with index zero transparent. Transparent-pixel matching against the preserved VGA dumps finds one unique placement for each: CONTINUE at logical top-left (220,81) with anchor (237,84), and SELECT at (154,69) with anchor (166,72).

Frames 7 through 10 are the pointer-hover navigation arrows Up, Down, Left, and Right. Their descriptors are respectively (-5,-1,11,10), (-5,-9,11,10), (0,-5,10,11), and (-9,-5,10,11). The selector-overlay routine draws one of these transparent frames at the active target’s authored logical coordinate.

The top status row is another fixed subset of STUFF.ART. Frame 4 is the Computer Bible cross at descriptor origin (4,1), frame 32 is the Map control at (23,1), frames 22 through 26 are descending Faith-meter states at (44,3), and frames 17 through 21 are Sword, Shield, No Trap, Candle, and Flight. Frame 11 is the disk indicator at (297,1). The positive origins are already logical screen positions, so the UI draws these frames at anchor zero and uses their nontransparent bounds as pointer targets.

Zero-based frame 27 is a dormant transient-status overlay. Unused opcode 8B starts its timer; the status renderer anchors the frame at logical (10,10) and derives its X/Y flip flags from the shared random source. See Runtime random source for the controller cadence.

QEMU framebuffer validation

INTRO.ART consists of one descriptor (0, 0, 320, 200, 12). Compared its 64,000 pixel bytes directly with physical VGA memory at 0xA0000 in the preserved startup dump. Of those bytes, 63,648 are identical. Every one of the 352 differences falls in one of two known live overlays visible in the QEMU screenshot:

OverlayDiffering pixelsDifference bounding box
Animated floppy/save icon333X 297–316, Y 1–17
Mouse cursor at screen center19X 154–166, Y 94–106

After excluding those rectangles, the extracted resource and live VGA memory are byte-for-byte identical. This establishes the descriptor dimensions, row-major order, one-byte pixels, and mode-13h screen placement independently of visual interpretation.

The runtime screenshot also maps each used pixel index to a consistent RGB value. Most static entries match TITLE.PAL; entries 243 through 254 differ because the opening text cycles that palette range while the story is shown.

Rendering tool

tools/render_art.py validates the complete descriptor and pixel layout and requires a separately extracted PAL for rendering. List descriptors with:

tools/render_art.py build/dd1/all/003_LOGO.ART --list

Composite all frames at their signed origins on a 320×200 canvas:

tools/render_art.py \
  build/dd1/all/003_LOGO.ART \
  --palette build/dd1/all/002_LOGO.PAL \
  --canvas --scale 2 \
  --output build/graphics/logo.png

Render one animation frame or all frames separately:

tools/render_art.py \
  build/dd1/all/082_RUN.ART \
  --palette build/dd1/all/025_TITLE.PAL \
  --frame 0 --scale 2 \
  --output build/graphics/run-0.png

tools/render_art.py \
  build/dd1/all/082_RUN.ART \
  --palette build/dd1/all/025_TITLE.PAL \
  --all-frames build/graphics/run-frames \
  --scale 2

Individual frames use index 0 as PNG transparency by default, matching the color-keyed game path. --opaque-zero preserves it as a visible palette color and selects opaque composition. --width and --height change canvas size; --scale applies nearest-neighbor integer scaling.

Exactly 11 frames have origin (0, 0) and dimensions 320×200. The scene programs select the following palettes before loading them:

Archive indexART frameScene-selected palette
006INTRO.ART frame 0TITLE.PAL
063PRAY.ART frame 0PRAY.PAL
073OVER.ART frame 01.PAL
090LAW1.ART frame 0LAW.PAL
093KABLAM1.ART frame 0KABLAM.PAL
097SPEAKER.ART frame 01.PAL
100HOLE.ART frame 0HOLE.PAL
122DOME.ART frame 0DOME.PAL
130DENY1.ART frame 0DENY.PAL
133CULTA.ART frame 01.PAL
165BOSS.ART frame 0BOSS.PAL

tools/render_fullscreen_gallery.py discovers those frames directly from the archive, infers each ART-to-PAL association from the resource-loading commands in the BIN scene programs, and labels every image with its archive index, resource name, frame number, and palette. Generate the native-pixel contact sheet with:

tools/render_fullscreen_gallery.py \
  CB/DD1.DAT \
  --output build/graphics/full-screen-gallery.png

Pass --scale 2 for a nearest-neighbor enlarged sheet. KABLAM1.ART appears almost black under KABLAM.PAL, but is not an empty image: it contains 16 distinct pixel indices and acts as a base for later overlay frames.

Executable routines

Load offsetCurrent nameEvidence
0x9FF7vga_set_dac_entryWrites an index to 03C8h and one RGB triplet to 03C9h.
0xA017vga_load_palette_biosLoads 256 triplets with BIOS video function 1012h.
0xA032vga_write_palette_rangeWrites a caller-selected range during vertical retrace.
0xA0C9blit_rect_to_vgaCopies an opaque rectangle with a 320-byte destination stride.
0xA106blit_rect_transparent_zeroCopies rows while skipping pixel value 0.
0xA136blit_rect_opaqueCopies rows without a color key.
0xAE42Font initializationLoads STUFF.ART frame 0 and builds the glyph-offset table.
0xAF36Proportional glyph rendererCopies seven atlas rows using the executable width table.
0xBE7EStyled text-object rendererRemaps atlas values 0, 1, and 2 through a palette-index triplet.
0xB620update_palette_effectApplies bounded component offsets and submits changed palette ranges.
0xB99Cdraw_art_frame_opaqueIndexes a far ART pointer by frame * 12 and uses width, height, and pixel offset.

Offsets use the unpacked load-module convention documented elsewhere in this book.

Scene Bytecode

Overview

The 62 expanded BIN members are the game’s scene programs. Most are pure bytecode streams with no header. They select art and palettes, start music, change scenes, update variables, branch, call subroutines, and coordinate animation. The executable’s interpreter dispatches opcodes 0x01 through 0x91 through a 145-entry near-pointer table at load offset 0x59AB.

The operand layout and an evidence-based handler name have now been recovered for every dispatched opcode. Linear decoding identifies 25,829 commands in 64 code regions and uses 122 of the 145 possible opcodes. Names for commands that do not occur in the shipped scripts describe their direct engine effects rather than claiming an unobserved gameplay role.

An independent second pass reads the 145 dispatch pointers directly from the unpacked executable and finds 134 distinct implementation addresses. It walks every handler’s control-flow graph, records all operand-reader calls, and joins the results to the complete shipped corpus. The checked result is analysis/opcode-audit.tsv; tools/audit_bin_opcodes.py regenerates and verifies it.

Runtime model

initialize_scene at 0x6631 appends .BIN to a scene base name, loads that archive member into far memory, installs its base and initial cursor, resets scene-thread state, and calls execute_bin_commands at 0x451B with file offset zero. Later, update_scene_threads at 0x7997 resumes active command streams by passing their saved file offsets back to the same interpreter.

Four words in the data segment hold the current input state:

DS offsetRuntime exampleInterpretation
0x00F60x004BCurrent file-relative bytecode offset.
0x00F80x4C13Segment containing the current bytecode cursor.
0x00FA0x0000Offset of the loaded resource’s far-memory base.
0x00FC0x4C13Segment containing the resource base.

The interpreter sets its cursor to base + requested_offset, fetches one byte, converts opcode 1 to dispatch index 0, range-checks through 0x91, and calls the corresponding handler. Branch and call targets are absolute offsets within the expanded BIN member, rather than relative displacements.

Three shared operand readers make the encoding unambiguous:

Load offsetCurrent nameOperation
0x3A1Ebin_read_u8Read one byte and advance the far cursor.
0x3A30bin_read_u16Read one little-endian word.
0x3A64bin_read_cstring_offsetReturn a base-relative string offset: consume an inline NUL-terminated string, or consume FF plus an explicit 16-bit offset.

Four other handlers implement their own byte-at-a-time inline string loops: opcodes 0x01, 0x0D, 0x4D, and 0x6D. Those loops do not recognize the 0xFF offset escape. Keeping the two encodings separate prevents an art, palette, or scene name beginning with CP437 byte 0xFF from being misinterpreted as a pointer.

Operand schema

The decoder records each opcode with a compact schema:

MarkerEncoding
BUnsigned byte.
HUnsigned little-endian 16-bit word.
zInline NUL-terminated CP437 string.
pString pointer encoded inline as a NUL-terminated string, or as FF plus an explicit 16-bit resource-relative offset.
9Opaque nine-byte animation record skipped by opcode 0x07.
sAn additional word only when the preceding H, interpreted as signed, is negative.

The conditional BHs form is used by opcodes 0x11, 0x12, and 0x17 through 0x1A. For example, bytes 11 01 F8 FF E6 01 contain byte 1, signed word -8, and therefore the extra word 0x01E6.

The complete machine-readable table is OPCODE_SCHEMAS in tools/inspect_bin.py. It contains one entry for every value from 0x01 through 0x91; tests extract the resources directly from CB/DD1.DAT and exercise every known code region against that table.

Top-level selector input

The main input dispatcher at 0x875D separates confirmation input from exploration actions with global action-selection word DS:004C. When it is zero, Enter (0x0D) or the primary-button event (0x7E) sets the opcode-64 latch at DS:7CC0. When it is nonzero, those events instead query render slot 0x124, require the current selector index at DS:00EE to be nonnegative, and call the selector activator at 0x851E. A press with no current selector does nothing; it does not leak into the confirmation latch.

ASCII Space reaches the cycling routine at 0x6939 only while action selection is enabled. That routine refreshes the current selector with update_action_selector_overlay, then advances through two tables. Kind zero visits the 16-byte scene-thread records at DS:7DC0; kind one visits the 10-byte opcode-3A records at DS:4810. Exhausting either table wraps into the other. An actor needs its byte at +7 set, while an action needs its byte at +8 set. Both require signed X less than 320 and signed Y less than 200. The successful record writes X and Y+4 to the logical pointer words and calls the overlay updater again.

The four extended arrow events have the same action-selection split. 0xC8, 0xD0, 0xCB, and 0xCD search for selector keys u, d, l, and r while DS:004C is nonzero. While it is zero, the branches at 0x889C, 0x88BD, 0x88F7, and 0x88D7 instead subtract or add eight to the corresponding logical pointer word.

Opcode 02 initializes the scene-thread selector X/Y fields to 30000 and its string pointer to -1; its navigation X/Y live elsewhere in the same record. Opcode 10 installs the on-screen selector anchor and string. Consequently an unlabeled navigation record such as GANTRY’s opening remains clickable through the navigation hit-test, but its untouched off-screen selector fields keep it out of proximity hover and Space cycling.

The standalone Bible routine at 0x1C88 is a blocking top-level interface. It saves action-selection state at 0x1D5F, clears DS:004C at 0x1D66, and services its own input loop before returning. The map, options, save, and status-notice dispatchers follow the same nested-interface model rather than returning to the scene controller on every host frame.

Independent audit

The second pass does not use successful linear decoding as proof of operand width. tools/audit_bin_opcodes.py instead combines five independent views:

  1. the exact 145-word dispatch table at load offset 0x59AB;
  2. every reachable path through the interpreter’s 1,817 Rizin instructions;
  3. calls to the byte, word, and pointer-string readers, plus the four inline string loops and opcode 0x07’s direct nine-byte cursor advance;
  4. the handler aliases and per-symbol evidence in analysis/cb.rz and analysis/symbol-map.tsv; and
  5. all 25,829 decoded commands in all 64 known code regions.

The audit expands conditional schema BHs into both legal paths, BH and BHH. It also records the dialogue handler’s intentional no-read retry path: while a modal message is active, the command suspends without consuming its p operand; after the modal state clears, it consumes that same pointer and continues. No other handler is allowed an undeclared operand path.

Run the complete comparison with:

tools/audit_bin_opcodes.py

The report records, for every opcode value, the dispatch address, handler symbol and confidence, declared schema, independently observed read paths, script-variable operand positions, use count, resource set, and first shipped site. Twenty-three opcode values are absent from shipped code: 03, 0E, 13, 18, 1B, 2E, 30, 37, 39, 47, 4A, 4B, 4F, 50, 56, 5E, 65, 66, 6E, 6F, 8B, 8F, and 90.

Identified commands

These handler meanings have direct static support from their callees or clear control-flow behavior:

OpcodeOperandsCurrent nameEvidence
0x01zload_artPasses the name to load_art_resource, which appends .ART.
0x05nonereturn_minus_oneTerminates interpretation with return value -1.
0x02BHHHcreate_scene_threadInitializes a thread slot and appends a type-0x02 display record.
0x03BBHHBadd_native_scale_display_objectUnused. Appends a directly rendered object with implicit scale 0x0100.
0x04 / 0x43BBHHHBadd_scaled_display_objectAppends a directly rendered object with frame, ART slot, X, Y, scale, and flags.
0x06Hbegin_animation_sequenceCreates animation state and appends a type-0x06 display record.
0x079animation_stepAdvances over a fixed nine-byte step retained for later animation updates; it does not require a preceding sequence.
0x08BBstart_animationReads animation, mode, then starts the selected slot.
0x5FBBBstart_linked_animationReads animation, linked animation, mode, then starts the selected slot.
0x09Bstop_animationStops an animation and releases its render slot.
0x0Dzzchange_sceneSelects a new scene and secondary segment name.
0x0FHadjust_thread_delaySubtracts the operand from the current scheduler slot’s delay word at offset +0x0C.
0x13Hremove_dialogue_choiceUnused. Removes the first six-byte choice record with the matching target.
0x14pshow_adversary_dialogueUses the adversary presentation channel; all ten uses are in FACE.BIN.
0x1EHHcopy_variableCopies one signed script-variable word to another.
0x1FHHset_variableStores an immediate in a script variable.
0x20HHjump_if_zeroSelects an absolute target when a variable is zero.
0x21HHjump_if_nonzeroSelects an absolute target when a variable is nonzero.
0x22HHHjump_if_variables_equalCompares two variables and jumps when they are equal.
0x23HHHjump_if_variable_equalsCompares a variable with an immediate and jumps when they are equal.
0x24HHHjump_if_variables_not_equalCompares two variables and jumps when they differ.
0x25HHHjump_if_variable_not_equalCompares a variable with an immediate and jumps when they differ.
0x26HHHjump_if_variable_greater_than_variableSigned-compares two variables and jumps when the first is greater.
0x27HHHjump_if_variable_greater_thanSigned-compares a variable with an immediate and jumps when the variable is greater.
0x28HHHjump_if_variable_less_than_variableSigned-compares two variables and jumps when the first is less.
0x29HHHjump_if_variable_less_thanSigned-compares a variable with an immediate and jumps when the variable is less.
0x2AHHadd_variableAdds one script variable to another.
0x2BHHadd_to_variableAdds an immediate to a script variable.
0x2CHHsubtract_variableSubtracts one script variable from another.
0x2DHHsubtract_from_variableSubtracts an immediate from a script variable.
0x2EHHmultiply_variablesUnused. Signed-multiplies a script variable by another variable.
0x2FHHmultiply_variableSigned-multiplies a script variable by an immediate.
0x30HHdivide_variablesUnused. Signed-divides a script variable by another variable.
0x31HHdivide_variableSigned-divides a script variable by an immediate.
0x32Hincrement_variableIncrements a numbered variable.
0x33Hdecrement_variableDecrements a numbered variable.
0x34HcallSaves a return offset and jumps to an absolute target.
0x35nonereturnResumes the saved bytecode return offset.
0x36Bset_text_record_stateSets persistent descriptor byte +4 selected by record identifier.
0x37Bclear_text_record_stateUnused. Clears persistent descriptor byte +4 selected by record identifier.
0x38BHjump_if_text_record_setSelects a target when a record state is set.
0x39BHjump_if_text_record_clearUnused. Selects a target when a record state is clear.
0x3AHHHpadd_action_targetReads target, x, y, label and appends one selectable screen action.
0x3BBenable_action_targetSets one action record’s active byte.
0x3CBdisable_action_targetClears one action record’s active byte.
0x3DHjumpReplaces the cursor with an absolute file offset.
0x3EBHstart_scene_thread_atFreshly activates a BIN scheduler slot at an absolute target, clears stale scheduling state, and runs it before the caller continues.
0x3FBwait_for_animationContinues for animation state 0, 5, or 6; otherwise suspends and retries the command.
0x41noneenable_action_selectionEnables screen-action input for the current scene.
0x42nonedisable_action_selectionDisables screen-action input for the current scene.
0x44Hpadd_dialogue_choiceAppends an absolute target and far text pointer to the choice table.
0x45noneclear_dialogue_choicesClears the choice count and dialogue state.
0x46nonepresent_dialogue_choicesSuspends the thread until the selected choice supplies a new BIN target.
0x48pshow_character_dialoguePresents the character/boss/victim dialogue channel.
0x49nonerequest_study_bibleRequests the modal study interface and suspends the current thread.
0x4Dzload_paletteCalls load_palette_resource, which appends .PAL.
0x4Epshow_captain_bible_dialoguePresents Captain Bible’s dialogue channel, also reused for some captions.
0x52Bplay_musicBuilds MUS### or IBM### and loads an XMI member.
0x55nonesnapshot_stateCopies the live state into a retained buffer.
0x57BHprepare_sound_effectStops/releases the preceding sample, builds and decodes D###.ABT, and preformats it at the supplied rate without starting playback. Identifier/rate zero only stops and releases.
0x58nonestart_prepared_sound_effectStops any active instance and starts or restarts the retained preformatted sample.
0x59nonewait_for_sound_effectWith usable digital playback, yields and retries while playback is active. Without it, subtracts 100 from the calling thread’s delay and advances past the command.
0x60nonenopContinues directly to the next command.
0x61Bstop_scene_threadClears one BIN scheduler slot’s active byte.
0x65BBclear_display_object_framesUnused. Reads first, count and clears frame byte +7 for records [first, first + count).
0x66BBBBadvance_display_object_framesUnused. Reads first, count, minimum, maximum; increments selected frames and resets a result below minimum or above maximum to minimum.
0x6CHHHHrotate_palette_rangeAdvances a script-variable phase by a signed step, wraps it within an inclusive palette-index range, and rotates that range’s mapping.
0x6Dzload_paletteUses the same palette-loading path as 0x4D.
0x70noneunload_last_artReleases the most recently loaded art slot.
0x72noneyield_scene_threadStores -1 in the current scheduler slot’s delay word and returns the following BIN offset; the next controller update can resume there.
0x73BHjump_if_state_flag_clearSelects a target when a boolean state flag is clear.
0x74BHjump_if_state_flag_setSelects a target when a boolean state flag is set.
0x75Bclear_state_flagClears one identifier in the 128-bit state bank.
0x76Bset_state_flagSets one identifier in the 128-bit state bank.
0x77noneprocess_current_map_cellCalls the current-cell handler, which consults the cell and its neighbors.
0x78Bload_mapCombines a level letter with the current E/N/D difficulty code and loads a .MAP member.
0x7AHHpatch_bin_byte_from_variableWrites the low byte of a script variable to an absolute offset in the current BIN resource.
0x7BHset_current_map_cell_kindPreserves the cell’s high nibble and ORs it with the variable’s low byte; shipped callers supply low-nibble kind values.
0x7CHset_current_map_cell_parameter_aWrites the current cell’s second byte from a script variable.
0x7DBHconfigure_study_promptSelects a companion-text component and record selector for the next study screen.
0x7Enoneblackout_paletteStarts an immediate black palette effect before a scene transition.
0x7FHset_current_map_cell_parameter_bWrites the current cell’s third byte from a script variable.
0x80BHjump_if_animation_activeSelects a target when an animation state byte is nonzero.
0x81Hreduce_faithSubtracts a difficulty-scaled immediate from faith unless no-combat mode is active.
0x82HHset_variable_random_moduloAdvances the shared DOS runtime generator and stores its 15-bit result modulo the immediate in a script variable.
0x85Bhide_display_objectSets the high hidden bit in a display record’s ART-slot byte.
0x86Bshow_display_objectClears the high hidden bit in a display record’s ART-slot byte.
0x87nonenormalize_map_cellsApplies recovered location-kind and parameter transitions across the grid.
0x88noneclear_text_record_statesClears persistent byte +4 in all 66 text descriptors.
0x89nonemark_current_map_cell_exploredSets the current X bit in the current Y exploration row.
0x8Enonesync_current_cell_flags_23_to_27The DOS handler uses a coordinate-derived index into an 80-byte object and copies bits 0 through 4 into state flags 0x23 through 0x27; the clean-room behavior draws a random byte instead.
0x8FHHand_variablesUnused. ANDs a destination with another variable.
0x90HHand_variableUnused. ANDs a destination with an immediate.

Formerly structural commands

The final unnamed-handler pass connected the remaining 51 opcode values to their consumers. Thirteen of these values never occur in the shipped command regions; their rows are marked unused and rely on static handler behavior. Ten already-named values are also absent, producing the audit’s overall total of 23 unused opcodes.

OpcodeOperandsRecovered nameOperation
0x0Anonewait_for_scene_thread_movementYields the current BIN thread until primary movement state is 0 or 2.
0x0BBBadd_navigation_edgeAppends an undirected two-node edge in the insertion order used by the bounded iterative-deepening route finder.
0x0CBBpadd_scene_entryAssociates an entry/segment string with the initial/current node byte followed by the destination node byte used after change_scene.
0x0E, 0x4A, 0x4B, 0x56nonenopUnused. All four jump-table entries point at the interpreter’s continue loop.
0x10BHHpconfigure_scene_thread_actionGives a scene thread selector X/Y coordinates and an action-label pointer; newly created scene-thread selectors start enabled.
0x11BHsadd_navigation_arrival_handlerMaps a destination node to a BIN target and optional explicit thread slot.
0x12BHsadd_navigation_departure_handlerMaps a source node to a BIN target and optional explicit thread slot.
0x15Bselect_study_recordSelects the text descriptor expanded by study placeholders and clears both success continuations.
0x16HHHset_palette_adjustment_range_from_variableFills an inclusive signed palette-brightness adjustment range with one script-variable value and schedules an update.
0x17BHsadd_forward_edge_departure_handlerAdds a callback for starting traversal in an edge’s stored first-to-second order.
0x18BHsadd_reverse_edge_departure_handlerUnused. Adds a callback for starting traversal from the stored second node to the first.
0x19BHsadd_reverse_edge_arrival_handlerAdds a callback for completing traversal from the stored second node to the first.
0x1ABHsadd_forward_edge_arrival_handlerAdds a callback for completing traversal in stored first-to-second order.
0x1BHprime_primary_scene_thread_timerUnused. Stores the negated operand in the primary motion timer and sets a shared transition latch; motion updates consume the latch by selecting state 4 or 6 and clearing it.
0x1CBenable_scene_thread_actionEnables one scene thread as an input selector.
0x1DBdisable_scene_thread_actionDisables one scene thread as an input selector.
0x40Bset_scene_thread_motion_stateWrites the current thread’s motion state. State 0 makes the scene-motion updater release that thread’s normal actor render slot; state 2 immediately runs the updater.
0x47Bset_modal_menu_selectionUnused. Seeds the selection consumed and reset by the modal text-menu path.
0x4CBfill_screenFills the complete 320-by-200 framebuffer with one palette index.
0x4FBBconfigure_study_navigation_successUnused. Selects a study record and the navigation node entered after success.
0x50noneclear_study_record_selectionUnused. Clears both active study-record selector words.
0x51BHBconfigure_study_thread_successSelects a study record plus the BIN target and thread slot started after success.
0x53Bset_scene_thread_originInitializes the primary navigation object’s nodes and X/Y/scale without replacing its retained destination, then starts a minimum same-node controller traversal.
0x54Bmove_scene_thread_toRetains a desired navigation node; starts movement while idle or replaces only the destination while an edge remains active.
0x5AHjump_if_digital_audio_fallbackJumps when the digital-driver word is zero, effects are disabled, driver-state bit 0 is clear, or the fallback word is nonzero. Falls through only for a usable driver with a zero fallback word.
0x5BBset_scene_thread_directionSelects one of four movement orientations and its sprite/render offset.
0x5CBBBconfigure_captain_bible_dialogueWrites Captain Bible text X, text Y, and wrap width.
0x5DBBBconfigure_character_dialogueWrites character text X, text Y, and wrap width.
0x5EHset_deferred_scene_thread_targetUnused. Sets a target that the main loop later starts in scheduler slot 2.
0x62Hstore_mouse_xStores the current mouse X coordinate in the selected script variable.
0x63Hstore_mouse_yStores the current mouse Y coordinate in the selected script variable.
0x64Hjump_if_confirm_pressedConsumes the Enter-or-click latch and jumps to an absolute target when set.
0x67nonerequest_restore_saved_gameLeaves the scene loop through mode 2, which restores retained save buffers.
0x68Hadjust_variable_1280_onceApplies one correction: subtract 1,280 above 640, then add 1,280 if the result is below -639. It does not loop for values more than one interval away.
0x69HHload_bin_wordLoads a little-endian word at an immediate current-BIN offset into a variable.
0x6AHHpatch_bin_word_from_variableWrites a variable word to an immediate current-BIN offset.
0x6BBload_text_bankReplaces the active companion-text bank and clears all 66 descriptors.
0x6EBstart_primary_scene_thread_overlayUnused. Loads and starts a resource-driven transient overlay for scene thread zero.
0x6Fnonewait_for_primary_scene_thread_overlayUnused. Yields while that transient overlay remains active.
0x71HHload_bin_word_indirectUses one variable as the BIN offset, loads a word, and stores it in another variable.
0x79noneclear_navigation_handlersClears only scene-entry, edge-transition, node-arrival, and node-departure callback counts. It neither clears the navigation-edge table nor cancels active movement.
0x83HBHcopy_text_record_component_to_binSelects a text record through a variable and copies one component to an immediate BIN offset.
0x84HHload_bin_byteSign-extends a byte at an immediate current-BIN offset into a variable.
0x8ABHjump_if_animation_finishedJumps when the selected animation is in state 0, 5, or 6.
0x8Bnoneconsume_random_text_recordUnused. When variable zero is 2, chooses rand() % count, scans cyclically past state-zero descriptors, clears the first set state byte, and stores 3,000 at variable offset 5C.
0x8CHjump_if_no_combatJumps when the installation’s SOUND.5 no-combat flag is set.
0x8DHjump_if_file_missingAppends the mutable save suffix to the active player prefix, opens the result as rb, and jumps on failure. Its sole shipped site tests .SV0, with target equal to fallthrough.
0x91HHset_variable_current_cell_byte_moduloThe DOS handler reduces its coordinate-indexed object byte modulo the immediate; the clean-room behavior reduces a random byte instead.

The movement opcodes share the controller recovered at 0x6F46, 0x7469, and 0x779D. Opcode 0x53 uses the same edge initializer with identical start and end nodes; opcode 0x0A therefore continues waiting until that minimum traversal completes. Opcode 0x54 first compares its operand with the retained desired node. During active interpolation a different operand only replaces that byte. The current edge, timer, pose, phase, and callbacks remain intact, and route search runs again from the reached node after its arrival callbacks. The retained byte starts at 0xFF, persists across scene changes, and is not reset by opcode 0x53.

Navigation callbacks are fresh synchronous command-stream invocations: each one runs through its first yield or return before the dispatcher examines the next matching record. A same-slot callback’s delay does not pause the outer command interpreter that synchronously invoked it; if that outer invocation later yields, its cursor replaces the nested cursor while both delay changes remain accumulated. Initial movement searches, runs the source-node and directional-edge departure callbacks in that order, and then initializes the edge. State-five continuation searches, initializes the edge, and runs the directional-edge then source-node departure callbacks. Completion makes the controller inactive for request purposes before running the directional-edge then destination-node arrival callbacks.

Opcode 0x69 was previously recorded as a one-word instruction. Its handler actually reads an immediate BIN offset and a destination-variable offset. In CP2.BIN, the second word’s low byte happened to be opcode 0x40, so all streams still decoded while eleven destination operands appeared as phantom commands. Correcting the schema reduces the corpus total from 25,840 to 25,829 without changing the set of 122 opcodes genuinely used by shipped scripts.

DOS coordinate-indexed object overrun

The operand source used by opcodes 0x8E and 0x91 is not a MAP field and is not a separately loaded 16×16 resource. Both handlers calculate 16 * map_y + map_x and read from DS:008A. The initialized object at that address contains only 80 bytes, covering rows zero through four:

92 DF 1A 72 01 83 9C E5 00 B8 A8 01 2C F7 E2 AA
64 F3 43 CA 7D 7C 6A 51 37 A3 33 7A 70 1F 8B 4F
98 EF 83 6F 65 41 A3 0F C8 93 80 3F 1F 18 90 6E
AC 5D 3E 31 46 2C 3A 40 4E B1 13 C1 E5 DB 07 CA
5C 05 1A 21 EC D6 65 46 A8 65 B1 B3 AF A1 44 62

The handlers perform no bounds check. Rows five through fifteen therefore alias the initialized data and live globals which immediately follow the 80-byte object. Those aliases explain why a literal 256-byte zero replacement removed combat variation and made every Communications room choose the same initial face during reverse engineering. They are DOS implementation details, not clean-room compatibility requirements.

The live aliases identified while explaining the DOS captures are:

Grid coordinate(s)Original bytesLogical value
(6,5), (7,5)DS:E0 wordNumber of scene-loaded ART slots, excluding permanently loaded RUN.ART.
(8,5), (9,5)DS:E2 wordDisplay-record count.
(4,6), (5,6)DS:EE wordCurrent pointer-selector index, or -1.
(6,6), (7,6)DS:F0 wordPreviously rendered pointer-selector index, or -1.
(8,6), (9,6)DS:F2 wordCurrent selector kind: scene thread 0, action target 1.
(10,6), (11,6)DS:F4 wordPreviously rendered selector kind, initially -1.
(12,6), (13,6)DS:F6 wordCurrent BIN cursor immediately after the opcode byte.
(14,6), (15,6)DS:F8 wordCurrent BIN cursor segment.
(0,7), (1,7)DS:FA wordNormalized current-BIN base offset, normally zero.
(2,7), (3,7)DS:FC wordCurrent-BIN base segment.

The two segment words are allocator-derived. They were 42C5 in the focused fresh-load probes and 4C13 in a longer prior session, even though the resource-relative cursor behavior was identical. This is the only part of the lookup whose exact value can vary with the DOS heap history.

The clean-room engine treats this entire lookup as a DOS implementation detail. Every opcode-8E or opcode-91 execution advances the one portable runtime generator and uses the returned value’s low byte, regardless of map coordinate. Opcode 0x82, the text shuffle, unused opcode 0x8B, and the transient-status refresh started by 0x8B share that stream. The algorithm and call order are now explicit so, from the same initial state and inputs, a patched DOS run, Rust, and future implementations can replay the same branches without depending on resource addresses, selector state, or unrelated scene globals.

Instruction-boundary QEMU probes validate both consumers. COMBAT1.BIN reached 0627:5913 at map coordinate (0,4) with BX=0040, ART/display counts 6/35, and cursor 07F3, exactly one byte after its opcode-8E at 07F2. A patched ROOM3 save reached 0627:597F at coordinate (13,6) with BX=006D, ART/display counts 3/16, and cursor 169C, exactly one byte after opcode 91 at 169B.

The latter probe proves the problematic alias directly. DS:008A + 006D is DS:00F7, the high byte of the current BIN instruction pointer stored at DS:00F6; opcode 91 read 16h from the pointer value 169Ch. The deterministic DOS patch removes the whole coordinate calculation at 0x5905 and 0x5971 and routes each handler through the portable generator exactly once.

The pointer-capable p encoding is used by seven opcodes: 0x0C, 0x10, 0x14, 0x3A, 0x44, 0x48, and 0x4E. Only two shipped commands use its explicit-offset form: ROOM3.BIN at 0x181C (0x48) and 0x18CE (0x4E) both point back to string offset 0x0336. All other shipped p operands are inline strings.

Opcode 0x8D copies the same active player prefix and mutable suffix used by the save routines, then requests fopen(..., "rb"). At its only site, TITLE.BIN:0x012C, the initialized suffix is .SV0, so the constructed name is DDGAMES.SV0 with the default prefix. A failed open selects target 0x012F, which is also the next command; either result therefore reaches the same change_scene 'INTRO', 'seg'. The handler passes the returned stream to fclose after either path, including the null result on failure.

Opcode 0x7A deliberately modifies the loaded BIN buffer. Combat exits use it to replace the C in inline CHAL from the current level-letter variable; POWER.BIN replaces the digit in combat1 from the selected combat number. It is therefore a resource-name templating mechanism rather than a persistent-state write.

Opcode 0x6C calls rotate_palette_range at 0xB5A8. Its operands are inclusive minimum, inclusive maximum, signed step, and phase variable. The helper wraps the updated phase, fills the palette-index mapping across the range, and schedules a palette update. Opcode 0x7E calls start_palette_blackout at 0x1B6C; the next palette update writes an all black palette and counts down the effect state.

The suffix strings are present in the executable data segment and were also checked in the QEMU process image: .PAL at DS:0434, .ART at DS:0490, and .BIN at DS:0721. This corrects an early interpretation of bytes such as 4D 54 49 54 4C 45 00 as the string MTITLE: byte 0x4D is actually the palette opcode followed by the string TITLE.

Startup programs

The QEMU DOS trace and archive directory give this resource-load sequence:

LOGO.BIN -> LOGO.PAL -> LOGO.ART -> D003.ABT
TITLE.BIN -> TITLE.PAL -> TITLE.ART -> TITLE2.ART -> MUS001.XMI
INTRO.BIN -> INTRO.ART

The scripts involved decode completely:

ResourceExpanded bytesCommands
LOGO.BIN640114
TITLE.BIN43680
INTRO.BIN18439
MENU.BIN2,00499

INTRO.BIN begins by setting two small state values, loading TITLE.PAL, loading INTRO.ART, and drawing the opening sequence. Opcode 0x52 selects music index 1, which produces MUS001.XMI. At file offset 0x009A, opcode 0x0D carries strings dome and seg to enter the first gameplay scene.

The runtime dump contains all 184 INTRO.BIN bytes at physical address 0x4C130. The base pointer 4C13:0000 appears at physical address 0x14F0A, stored as offset word 0000 at DS:00FA and segment word 4C13 at DS:00FC. The live cursor is 4C13:004B; resource offset 0x004B begins opcode 0x42 and is exactly the boundary after the preceding return_minus_one command. This ties the static decoder’s command boundaries to live interpreter state.

Mixed code and data

Sixty members decode linearly from byte zero through end of file. Two members contain non-code regions:

  • CP2.BIN has commands at 0x0000..0x1D54, followed by a 256-byte structured data trailer at 0x1D55..0x1E54. It contains a 16-node four-heading adjacency table, node types, transition values, and lower-right map coordinates. The Unibot chapter decodes every entry.
  • ROOM3.BIN has command regions 0x0000..0x0336, 0x0C96..0x1754, and 0x1768..0x19DB. A 2,400-byte zero-filled block and a 20-byte zero-filled block separate them.

Opcode zero is invalid. The decoder deliberately reports it instead of guessing that arbitrary padding or embedded tables are executable commands. The later ROOM3.BIN entry points are therefore decoded explicitly rather than reached by a single linear sweep.

Inspection tool

After extracting the archive, inspect a complete stream with:

tools/inspect_bin.py build/dd1/all/005_INTRO.BIN

Use explicit bounds for embedded regions:

tools/inspect_bin.py \
  build/dd1/all/334_ROOM3.BIN --start 0x0c96 --limit 0x1754
tools/inspect_bin.py \
  build/dd1/all/334_ROOM3.BIN --start 0x1768
tools/inspect_unibot.py build/dd1/all/315_CP2.BIN

Output includes the file-offset range, opcode, current semantic name, and typed operands. Variable operands show both their word index and encoded byte offset, with recovered names such as var[21:faith]@0x002a. Other words with their high bit set are displayed in both unsigned hexadecimal and signed decimal forms. Every dispatched opcode now has a name; low-level names for unused commands deliberately describe state changes rather than inferred UI.

Add --objects to append a linear summary of commands which define display records, --choices to inventory dialogue target/text pairs, or --animations --actions to inventory animation sequences and selectable screen targets. Action summaries name the four combat selectors and the hall selectors for movement, Confront Cyber, Unlock, and Get Verse. The scene-display-object chapter documents the ten-byte runtime record, while the conversation-flow chapter documents the six-byte choice table. The combat-runtime chapter documents animation slots, action targets, and the BIN scheduler; the world-map chapter correlates the hall actions with map-cell features. These summaries warn that branches can change which definitions execute.

Executable routines

Load offsetCurrent name
0x034Fload_map_resource
0x0457normalize_map_cells
0x075Fshow_map_screen
0x0C6Cprocess_current_map_cell
0x1B6Cstart_palette_blackout
0x1C88show_study_bible
0x2556select_from_text_menu
0x2933show_dialogue_message
0x3AD2reset_scene_display_records
0x3AFFrender_scene_display_records
0x3B9Bresolve_animation_transform
0x3D08render_animation_slot
0x3DA8update_animation_slots
0x3F59start_animation_slot
0x3FDFstop_animation_slot
0x3A1Ebin_read_u8
0x3A30bin_read_u16
0x3A64bin_read_cstring_offset
0x4001load_palette_resource
0x4091play_music_resource
0x446Frender_study_prompt
0x451Bexecute_bin_commands
0x6631initialize_scene
0x6939cycle_action_selector
0x6A23update_action_selector_overlay
0x7997update_scene_threads
0x7A5Cstart_scene_thread
0x834Ehandle_study_bible_request
0x8558find_action_target_by_key
0xB5A8rotate_palette_range
0xB948release_render_slot
0xBCACrender_scene_display_object

Offsets use the unpacked load-module convention documented elsewhere in this book.

Scene Display Objects

Scope and terminology

The scene runtime maintains a 100-entry display list which connects BIN commands to animation, command threads, ART frames, and the renderer. Some records visually represent characters, enemies, or props, but the structure itself is a display list. Calling every entry a gameplay entity would be misleading: the same list also contains animation-group and thread records.

The recovered model is based on the bytecode handlers, the scene update and render paths, and a live QEMU capture of LOGO.BIN.

Runtime layout

The list count is a word at DS:00E2. Records begin at DS:A2AC, have a ten-byte stride, and are addressed as A2AC + index * 10. The update routine at load offset 0x3AFF rejects counts greater than 100, establishing the capacity.

For directly rendered record types 0x03 and 0x43, the layout is:

OffsetSizeMeaning
+02Signed X coordinate.
+22Signed Y coordinate.
+428.8 scale; 0x0100 is native size.
+61Loaded ART slot. Bit 7 is also the hidden marker.
+71One-based ART frame number; zero suppresses drawing.
+81Render flags; bits 0 and 1 flip the two axes.
+91Display-record type.

This assignment follows the arguments passed to the renderer at 0xBCAC. That routine selects a loaded ART resource with the low seven bits of byte +6, subtracts one from the frame number, applies coordinates and scale, and derives the two flip bits from byte +8. An ART-slot byte in 0x80..0xEF, or frame zero, releases/suppresses the render slot instead of drawing it.

Visibility is therefore encoded in the ART-slot byte rather than the separate render-flags byte. The slot byte should not be modeled as a plain array index without masking or checking its high bit.

Record types

Four type values are written by the recovered scene handlers:

TypeProducerRole
0x02Opcode 0x02Connects one of the 16-byte scene-command thread slots to the display/update list.
0x03Opcode 0x03Direct ART-frame object with an implicit native scale of 0x0100.
0x43Opcodes 0x04 and 0x43Direct ART-frame object with an explicit scale.
0x06Opcode 0x06Connects an animation sequence to the display/update list.

The update routine handles type 0x02 through the scene-thread path. Types 0x03 and 0x43 are submitted directly to the object renderer. Type 0x06 is owned by the animation-sequence path. The ten-byte fields not used by a type can contain zero or stale data and must not be interpreted as a direct object’s coordinates.

Type 0x02 records also participate in the primary pointer-target table even when no opcode 0x10 selector label was attached. The main input path around 0x87D30x8842 gives scene-thread targets priority over opcode-0x3A actions. Its selected-target dispatcher at 0x851E distinguishes a scene-thread target from an ordinary script action and writes the selected thread/node index into the movement controller rather than jumping to a BIN offset.

This matters in GANTRY.BIN. Navigation nodes 2 and 3 share geometry (190,70) and have no selector strings. Entry seg finishes on node 2, whereas arrival at node 3 enters CP1. The current unlabeled node is omitted from hit-testing, so table order selects node 3 when the player clicks the opening. Requiring a nonempty selector makes this scene appear locked even though its navigation graph and callback are complete. The missing label also means DOS displays no transient action arrow for this target.

The selected-target index is also what the main input path dispatches after Enter and what the Space-cycle routine uses as its starting record. These paths do not re-filter the target by selector-string presence. Consequently the unlabeled GANTRY opening is keyboard-activatable when the pointer is over it, and Space advances from rather than ignores an unlabeled current target.

Scene commands

The definition and direct frame-control commands are:

OpcodeOperandsEffect
0x02thread, x, y, scaleInitializes a 16-byte command-thread slot and appends a type-0x02 display record.
0x03frame, art, x, y, flagsAppends a native-scale type-0x03 display object; unused by shipped scripts.
0x04 / 0x43frame, art, x, y, scale, flagsAppends a scaled type-0x43 display object.
0x06delayBegins an animation sequence and appends a type-0x06 display record.
0x07nine-byte recordSupplies an animation step retained within the BIN stream.
0x65first, countSets frame byte +7 to zero for a consecutive display-record range; unused by shipped scripts.
0x66first, count, minimum, maximumIncrements each selected frame and resets values outside the inclusive range to minimum; unused by shipped scripts.
0x85indexSets ART-slot bit 7, hiding a direct display object.
0x86indexClears ART-slot bit 7, showing a direct display object.

The animation step is not copied when opcode 0x07 executes. The handler advances over its nine bytes; the animation state created by opcode 0x06 retains a BIN-stream location and consumes the records later. This explains why the command decoder originally could establish the nine-byte boundary before the animation lifecycle was understood.

Six consecutive 0x07 records at CP1.BIN:0x0026..0x0062 precede the first 0x06 in that resource. The DOS handler accepts them because it only advances the BIN cursor; they are not owned by an animation definition. Treating 0x07 as an append operation that requires an already created host animation incorrectly rejects the Unibot boarding scene.

The animation slot and step layouts, lifecycle commands, and linked-transform path are now documented in the combat-runtime chapter.

Victim scenes use 0x85 and 0x86 on groups of display indices when their visual state changes. For example, NAGE.BIN hides a set of crystal-related objects in a subroutine selected by progression flags. This is evidence for display visibility, not by itself for entity death or activation state.

Lifecycle

initialize_scene calls the reset routine at 0x3AD2 before loading and executing the next BIN resource. The reset routine visits every current display index, releases its render slot, and sets the count to zero.

The offset-zero loader invocation ends at opcode 0x05 before the requested entry traversal is started. That invocation remains stopped while the actor moves between the two nodes selected by the matching 0x0C record. Only the ordinary departure or arrival callbacks start a fresh command stream afterward. The entry movement request synchronously runs its departure callbacks before initializing the selected edge; they must not share the terminating loader invocation. Keeping the loader stream runnable at the byte after 0x05 makes an arrival handler execute once as fallthrough and a second time on actual arrival. Starting a departure callback inside the loader’s 0x05 handler instead lets that return deactivate the new invocation. CP1.BIN exposes the first error as a duplicated Unibot introduction, while FIRST.BIN exposes the second as a missing beamer materialization.

During scene updates, 0x3AFF walks the current list:

  • type 0x02 invokes the associated thread/update slot;
  • types 0x03 and 0x43 pass the recovered ten-byte fields to 0xBCAC;
  • other supported types are serviced by their dedicated animation paths.

The main frame update calls those controller paths separately, but call order is not layer order. Each path writes into the render slot reserved by its mixed display-record index. Direct rendering passes index + 1 at 0x3B720x3B76; animation rendering passes its saved display index plus one at 0x3D640x3D6B; scene-thread movement does the same at 0x763D0x7642. The dirty-region compositor at 0xC000 then advances through the 26-byte render slots in increasing order.

This distinction supplies the opening oval mask. In LOGO.BIN, the moving RUN.ART controller is display record 4, while the direct dome and bridge pieces are records 7 through 9. The later scenery covers the character outside the oval as it enters and leaves. It is display-list occlusion rather than a separate geometric clipping primitive. Regrouping records as direct, animation, and movement passes incorrectly forces the character above the mask.

The list is runtime scene state and is reconstructed from the BIN program. It is not serialized in the save file.

QEMU validation

The game was started with the repository’s visible and silent trace mode:

./run.sh --trace-dos

At the Bridgestone logo screen, QEMU reported DS=14E1. A one-megabyte physical-memory dump therefore placed the count at physical 0x14EF2 and the record table at physical 0x1F0BC. The live count was 13.

The three directly rendered records were:

IndexXYScaleFlagsFrameART slotType
730300x01001410x43
8300x01000410x43
9300x01000110x43

These values exactly match the three 0x43 commands at BIN offsets 0x0122, 0x012C, and 0x0136. Records 0–3 and 10 are the five animation sequences begun by opcode 0x06; records 4–6 and 11–12 are the five scene threads created by opcode 0x02. Thus all 13 definitions in the linear startup path match the live type order and count.

The dump and screen capture are retained as ignored analysis artifacts under build/qemu-trace/.

Inspector

Show the normal command listing followed by its linear display definitions:

tools/inspect_bin.py build/dd1/all/001_LOGO.BIN --objects

The summary includes source offset, type, and all definition fields known for that type. It follows linear file order; branches and calls can skip or repeat definitions at runtime, so indices outside a straight startup sequence are not guaranteed live indices.

Primary movement controller

The recursive search at 0x6DA5 and wrapper at 0x6EBD establish the route algorithm. The wrapper tries recursion limits zero through 19. At the limit, the recursive routine looks for one final direct edge, so the accepted route lengths are one through 20 edges. Every level scans the opcode-0x0B table in insertion order, testing a record’s stored first endpoint before its second. It has no visited-node set. The wrapper returns only the immediate next node and edge direction; updater 0x7469 calls it again at every intermediate node rather than retaining a complete route.

Request routine 0x779D keeps the desired destination in controller byte DS:7A45. While the interpolation count at DS:7A3A is nonnegative, another request writes only that byte. It does not restart the edge or disturb its coordinates, timing, animation phase, turn pose, or callbacks. Edge completion enters state 5, runs directional and node arrival callbacks, and performs the next search on a later controller update. This is why rapid pointer or keyboard navigation redirects Captain only after he reaches the next graph node. The byte is initialized to FFh, is not reset by opcode 0x53, and remains resident across scene replacement. Final idle resolution changes it to the current node.

Initial movement calls the source-node departure and then the directional edge departure before initializer 0x6F46. State-five continuation first initializes the selected edge, then calls its directional departure and the source-node departure. Completion makes the interpolation count negative, then calls the directional edge arrival and destination-node arrival. Handler 0x7A5C runs every matching callback synchronously as a fresh invocation through its first yield or return before scanning the next record. Arrival callbacks can therefore start and then retarget a new edge in one controller pass. The caller’s BIN cursor is saved separately, so a negative delay left by a same-slot nested callback does not prevent the outer command stream from continuing. If the outer stream later yields, its cursor replaces the nested cursor and its delay change accumulates with the callback’s.

Edge initializer 0x6F46 never resets the phase accumulator at DS:7A2C. Updater 0x7469 advances it modulo 0x0600 across intermediate edges and writes 0x0200 only in idle state 2. The initializer’s orientation table contains offsets 0, 0, 24, and 12; the four phase increments are all 28. When horizontal direction changes but that offset remains zero, a reflection change selects RUN.ART frame 20. A direct offset-12/offset-24 reversal, toward versus away, selects frame 19. Both set the transition countdown to 28 << 3, or 224 controller units. Horizontal/depth changes have no special turn pose. The raw table bytes were checked in the unpacked executable at file offsets 0xEF34 and 0xF4CA as well as through the instructions at 0x71BB..0x721E.

Opcode 0x53 at 0x5440 writes both current-node bytes, sets the previous edge index to -1, and calls 0x6F46 with identical start and end nodes. It therefore snaps to the requested geometry but remains in state 1 for a minimum same-node traversal. Its completion cannot match a directional edge callback because the edge index is -1, but it still dispatches the node-arrival callback. A following opcode 0x54 at 0x5468 merely queues its destination until this traversal finishes. An unreachable ordinary request takes the same minimum-traversal path before returning to idle. Opcode 0x53 does not replace the retained desired-node byte.

Controller timer rate

The hardware initialization at load offset 0x3600 installs the interrupt-8 handler at 0xAAE4. It programs PIT channel zero in mode 3 with divisor 0x26D7 (9,943), producing approximately 120 interrupts per second. Each interrupt increments the counter at 0x4B66. The elapsed-delta helper at 0x00B6 multiplies that counter by 24 and caps the result at 400 before the frame controller at 0x7A8D distributes it to animations, scene threads, and movement. The resulting controller rate is approximately 2,880 units per second, not one unit per millisecond.

The ordinary controller at 0x7ACB reads and resets that elapsed delta, calls the animation updater at 0x3DA8, and calls the dirty compositor at 0xC5CA before returning. The blocking input poll repeatedly invokes this controller, so under normal load each nonzero update contains one interrupt: 24 units, or about 8.33 ms, followed by one presentation.

The animation updater adds the complete controller delta to a sequence’s countdown. If more than one record became due, it catches up through those records and submits only the final resolved display slot. DOS can therefore skip an intermediate frame after a genuine stall, but its normal 24-unit cadence does not skip the explicit 30- and 40-unit sequences in COMBAT4.BIN. This distinguishes normal visual cadence from the controller’s intentional long-stall catch-up behavior.

Remaining questions

  • The opcode-0x02 16-byte interactive/display record still needs names for every field and a clearer distinction from the true BIN scheduler table.
  • Combat targeting uses a separate ten-byte screen-action table. No evidence yet connects direct display objects to collision or enemy health.
  • The last two bytes of each 12-byte animation slot and several detailed mode transitions remain unnamed.
  • A display record can visually represent a character, but no identity or gameplay-stat field exists in this ten-byte structure.

Relevant executable functions

Load offsetCurrent name
0x3AD2reset_scene_display_records
0x3AFFrender_scene_display_records
0x451Bexecute_bin_commands
0x6631initialize_scene
0xB948release_render_slot
0xBCACrender_scene_display_object

Offsets use the unpacked load-module convention.

Combat Runtime

Architecture

Combat is implemented by seven scene programs, COMBAT1.BIN through COMBAT7.BIN. The executable supplies generic animation, selectable-action, thread, random-number, sound, and faith-loss operations; each BIN program combines those primitives into an encounter. This is a script-driven system, not a conventional enemy object with a health field in the ten-byte display record.

Every combat program loads COMBTAGS.ART, COMBAT.ART, and one or more enemy-specific ART resources:

ProgramManual identityBytesCommandsAnimation sequencesStepsActionsEnemy ART bases
COMBAT1.BINMacho3,300625331824BIG, BIG2, BIG3, BIG4
COMBAT2.BINArmored7,230973305944HELMET
COMBAT3.BINMantis4,381782382604MANTIS, MANTIS2, MANTIS3
COMBAT4.BINSnake7,4341,073295814SNAKE, SNAKE2
COMBAT5.BINSpider6,501919345234CRAB
COMBAT6.BINLeech2,314351151633GUARD, GUARD2
COMBAT7.BINZapper4,289720352934ZAP, ZAP2, SPRK
Total35,4495,4432142,59627

The programs branch on script variables and progression flags, including the Sword and Shield power flags 0x30 and 0x31. They use opcode 0x81 to reduce the player’s faith. Encounter outcomes, enemy phases, and exits are therefore expressed as script control flow and persistent state changes. No separate enemy-health structure has yet been found.

Animation definitions

Opcode 0x06 begins an animation definition. Its word operand becomes the sequence interval. The immediately following opcode-0x07 commands are the steps retained in the BIN stream; executing 0x07 merely advances over its nine-byte payload.

Each step has this layout:

Payload offsetSizeMeaning
+01One-based ART frame.
+11Loaded ART slot.
+22Signed X coordinate.
+42Signed Y coordinate.
+628.8 scale; 0x0100 is native size.
+81Render flags.

The runtime animation table starts at DS:6EBA, has a 12-byte stride, and is counted by the word at DS:B114. The recovered fields are:

Record offsetSizeMeaning
+02BIN offset of the first step.
+22BIN offset of the current step.
+42Sequence interval/countdown input.
+62Linked or parent animation index.
+81Animation mode/state.
+91Associated render/display slot.
+102Internal timing state; exact split remains unnamed.

Opcode 0x08 reads animation, mode and starts the slot with parent/link value -1. Opcode 0x5F reads animation, linked animation, mode and supplies the explicit link. Opcode 0x09 stops a slot and releases its render slot. Opcode 0x3F suspends the current BIN thread unless the animation state is 0, 5, or 6. Opcode 0x80 branches when a chosen animation’s state byte is nonzero; opcode 0x8A branches for exactly the same three finished states accepted by 0x3F.

The executable routines at 0x3B9B, 0x3D08, 0x3DA8, 0x3F59, and 0x3FDF resolve linked transforms, render one slot, update all slots, start a slot, and stop a slot respectively. The updater advances through the BIN steps in ten-byte command units and implements animation modes 1 through 10. Disassembly of 0x3F59 shows that only modes 2, 4, 6, and 10 initialize at the final step; mode 8 starts at the first step. At the boundary, 0x3DA8 backs a mode-7 overrun up by one ten-byte record, retaining the final frame and changing to mode 8. A mode-8 underrun advances by one record, retaining the first frame and changing to mode 7. The complete observed behavior is:

ModeStartDirectionBoundary result
1FirstForwardStop and release after the final step.
2LastBackwardStop and release before the first step.
3FirstForwardWrap to first.
4LastBackwardWrap to last.
5FirstNoneRetain first in terminal state 5.
6LastNoneRetain last in terminal state 6.
7FirstForwardRetain last and change to mode 8.
8FirstBackwardRetain first and change to mode 7.
9FirstForwardRetain last and change to terminal state 6.
10LastBackwardRetain first and change to terminal state 5.

Selectable action targets

Opcode 0x3A appends a ten-byte action target to the table at DS:480E; the word at DS:6EA4 is its count:

Record offsetSizeMeaning
+02Absolute target offset in the current BIN program.
+22Screen X coordinate.
+42Screen Y coordinate.
+62Offset of a selector string in the current BIN.
+81Active flag.
+91Reserved/padding.

Opcode 0x3B enables one record and 0x3C disables it. Opcode 0x41 enables action selection globally; 0x42 disables it and clears pending selection state. The overlay routine at 0x6A23 scans active targets, compares their coordinates with the pointer, decodes the selector string, and draws the corresponding label. The keyboard path at 0x8558 searches the same table. A selected record is dispatched through the BIN-thread start routine at 0x7A5C.

The overlay scan first checks enabled scene-thread selectors and then the ordinary action-target table. Function 0x3315 computes max(abs(dx),abs(dy)) + floor(min(abs(dx),abs(dy))/2). Candidates above 0x46 are rejected, and strict less-than replacement preserves the first candidate on a tie. The chosen label is submitted in transient render slot 0x97; that slot is released when no target qualifies. For .u, .d, .l, and .r, the executable selects one-based STUFF.ART frames 8 through 11.

The numeric path at 0x6B94 through 0x6CCE interprets a selector .XY as loaded ART slot X and one-based frame Y. It submits that resource frame using the target’s X/Y anchor and the frame’s signed descriptor origin. This is why the combat names are not drawn with the font and why .23 elsewhere can use the same generic overlay path.

The keyboard matcher at 0x8558 normally compares the character after the dot. For .1N it instead compares N, exposing combat selector keys 1 through 4. The translation routine at 0x866B, with its switch table at 0x86E8, maps A and S to 1, D to 2, R to 3, G to v, and U to x. C first searches for an ordinary .c selector and falls back to 4 only when none is active. Other letters pass through. Both keyboard and overlay scans preserve scene-thread selector priority over ordinary action records.

COMBTAGS.ART contains four label frames. Rendering all four with 019_ZAP.PAL identifies the selector mapping independently of the scripts:

SelectorFrame labelDefinitions in seven combat programs
.11ATTACK7
.12DEFEND6
.13RETREAT7
.14COMBAT7

The world-map chapter documents the independent hall-kind and transition evidence for these manual identities. COMBAT6.BIN is the only program without a DEFEND target. For example, COMBAT7.BIN defines:

SourceTargetXYAction
0x0C060x0EC815161ATTACK
0x0C110x0EAB136153DEFEND
0x0C1C0x105315167RETREAT
0x0C270x0FA715762COMBAT

These coordinates are screen hotspots rather than enemy bounding boxes. The same generic table can represent selectable actions elsewhere, so its runtime name is deliberately broader than “combat buttons.”

BIN threads and synchronization

The command-stream scheduler uses 16-byte slots beginning at DS:8D44; the current slot index is at DS:7DB4. The fields proven so far include the BIN cursor at +0, a delay/timer at +0x0C, an active byte at +0x0E, and a status byte at +0x0F.

Opcode 0x3E activates a selected slot at an absolute BIN target and runs it immediately. Opcode 0x61 clears a slot’s active byte. This is the mechanism used after an action target has supplied a branch destination. It is distinct from the separate 16-byte record family created by opcode 0x02, whose type 0x02 entries also participate in the scene display/update list.

The shared start routine at 0x7A5C does more than replace the cursor. It sets the active byte, writes the target, primes the delay from the negated current controller delta, clears the status byte, and immediately invokes update_scene_threads for that slot. In host terms this is a fresh, ready invocation which runs before its caller continues. Action selection uses the same routine for slot zero. Retaining the idle loop’s existing negative delay instead leaves a combat choice dormant for hundreds or thousands of logical timer units.

Opcode 0x59 waits for a digital effect to finish. With usable playback it yields and retries while the driver reports an active effect. Without usable playback it subtracts 100 from the calling scheduler slot’s delay, advances past the command, and lets the ordinary per-thread timer provide the pause. Opcode 0x82 computes a runtime pseudorandom value modulo its first operand and stores the remainder in the script variable selected by its second operand. Together with animation waits, these operations let the combat scripts sequence visual attacks, sounds, and randomized branches without embedding those policies in an enemy structure.

Action and outcome control flow

The four actions are entry points into the same encounter program, not numeric combat operations. ATTACK disables the current choices and runs an enemy-phase-specific animation path. DEFEND changes the available timing window; when Shield flag 0x31 is set, the scripts leave the attack option available longer. COMBAT uses opcode 0x82 and branches on Sword flag 0x30 and Shield flag 0x31 to choose successful, harmless, and faith-damaging sequences. The exact visual phase that makes each enemy vulnerable remains encoded in scene-local animation and counter variables.

Opcode 0x81 contains the base loss used on Normal difficulty. The seven programs contain these loss sites:

ProgramBase faith-loss immediatesVictory map mutationRetreat target and exit entry
COMBAT1533, 2,011kind 0xB0x0C38 -> 0x0C9D
COMBAT2107, 102, 502kind 0xB0x1BAC -> 0x1BF6
COMBAT31,037, 531, 2,011, 1,703kind 0xB0x10C3 -> 0x10D6
COMBAT4596, 1,005kind 0xB0x1C9D -> 0x1CC3
COMBAT5213, 2,009kind 0xB0x18F7 -> 0x1902
COMBAT6nonekind 0xA; copy parameter B to A0x087B -> 0x087E
COMBAT7233, 207kind 0xB; restore faith0x1053 -> 0x105E

Each Retreat target is itself an unconditional jump to the common exit entry. It therefore skips the victory-only map mutation. The table records all static loss sites rather than claiming that every site executes in one fight; branches choose among them. Easy halves each immediate, Difficult multiplies it by four, and installation no-combat mode suppresses it.

COMBAT7 contains the manual’s exceptional Zapper reward directly. Its victory subroutine alternates faith between 1 and 10,000 five times, producing a visible meter flash and ending at full faith. This happens before kind 0xB is written to the defeated encounter’s map cell.

Shared encounter epilogue

Six programs set state flag 0x38 after defining their action table and clear it in the common exit. The Game Options input routine tests the same flag and disables the Automatic Combat menu target while it is set. Flag 0x38 is therefore the combat-active lock, while flag 0x37 stores the Automatic Combat setting itself. COMBAT6 is exceptional: it has no DEFEND target, never changes flag 0x38, contains no faith-loss opcode, and produces a different map transition. Its internally named GUARD encounter is the Leech Cyber covering a Scripture station: victory reveals the station and transfers its saved verse selector into the active field.

After victory or retreat, the programs clear current-cell parameter A and select a hall scene. Variable 67 chooses the literal GHALB or GHALS variants for two special cases. Otherwise opcode 0x7A patches the first byte of the inline CHAL string from variable 16, the current map-level letter, yielding the appropriate AHAL through GHAL resource name. Opcode 0x7E starts a palette blackout immediately before these scene changes.

All seven combat programs also expose a separate POWER scene-change entry. POWER.BIN is the in-combat study/power interface, not a game-over scene. On a successful selection it copies the current combat number, adds ASCII '0', patches the digit in the inline name combat1, and changes back to that encounter. The caller path into this special entry has not yet been fully recovered.

Inspection

Inspect animation definitions and action targets with:

tools/inspect_bin.py \
  build/dd1/all/337_COMBAT7.BIN --animations --actions

The summaries follow linear definition order. Calls and branches can skip or repeat definitions, and enable/disable commands determine which action targets are live at a particular moment.

Live COMBAT1 table validation

A visible, silent QEMU capture now validates the three runtime table families. The route was controlled rather than a natural walk to an encounter: a genuine hall quick-save contained snapshot/live scene names MENU/CHAL, and tools/patch_save_scene.py changed only both 20-byte scene-name fields to COMBAT1. The modified state round-tripped through the FAT image byte for byte. Loading it with F9 produced the Macho combat screen, and the A key started a visible green attack effect.

The first dump caught scene initialization between visible redraws, with counts 0 and 1, so it is not used for table comparison. A second one-MiB physical dump after the attack input had SHA-256 becc98dd2eba0bad502f1bf6b7aef4ef2638fa48d0e0e62688ad879085bc1654. QEMU reported DS=14E1; tools/inspect_runtime_tables.py therefore read the same data offsets documented above at physical base 0x14E10.

The stable capture contains four action records and 33 animation records. All four action target/X/Y/selector-offset tuples match the static COMBAT1 definitions. ATTACK, DEFEND, and RETREAT are active; the automatic COMBAT target is present but inactive. Every animation record’s first-step offset and interval matches its corresponding BIN definition, 33 of 33. Current-step, link, state, render-slot, and timing values are retained as live values rather than forced to equal their initial definitions.

The first ten 16-byte scheduler records were also saved. Current slot is zero; slots 0, 5, 7, and 8 have active byte 1. Slot 0 has cursor 0x0C35 and signed delay -824 at the sampled instant. The remaining opaque bytes are emitted in hex so later field naming will not lose evidence. The exact comparison command is:

tools/inspect_runtime_tables.py \
  build/formal-captures/combat-after-a-memory.bin \
  --data-segment 0x14e1 \
  --bin build/dd1/all/343_COMBAT1.BIN

This validates the table addresses, strides, counts, and statically comparable fields. It does not prove a natural map-to-encounter transition, nor does the controlled state preserve a normal combat outcome: the patched checkpoint had zero snapshot faith, and the run subsequently reached the “Don’t Give Up!” screen. Those provenance limits are intentional and recorded rather than being generalized to ordinary play.

Remaining questions

  • Name the final two bytes of each animation slot and every mode’s exact transition rule.
  • Separate the opcode-0x02 interactive/display record family from the true BIN scheduler slots field by field.
  • Correlate every randomized branch with the exact enemy phase and rendered successful, harmless, or damaging sequence.

Conversation Flow

Overview

Conversations are ordinary BIN control flow layered over two reusable user interfaces: a modal dialogue box and a text-choice menu. Scene scripts supply speaker text inline, build choices as target/text pairs, and branch to the target selected by the player. The same study-Bible screen used elsewhere in the game can be opened from a conversation and returns success or failure through state flags.

This system is distinct from the scene display list. Portraits and other characters can be display objects, but conversation branches are absolute BIN offsets and their transient menu records live in a separate table.

Dialogue commands

Three commands share the handler at load offset 0x52A3. The handler chooses presentation parameters from the opcode and stores a far pointer to text in the current BIN resource. The input layer then displays the modal box and waits for Enter or a click to advance it. Escape temporarily transfers control to Game Options without accepting the message.

OpcodeOperandsCurrent nameCorpus evidence
0x14pshow_adversary_dialogueTen uses, all in FACE.BIN, spoken by the Tower of Deception.
0x48pshow_character_dialogue306 uses in 12 resources for the boss, victims, and allies.
0x4Epshow_captain_bible_dialogue281 uses in 25 resources, normally Captain Bible’s side of a conversation.

Here p is either inline NUL-terminated CP437 text or byte 0xFF followed by an explicit 16-bit offset in the same BIN resource. ROOM3.BIN uses the offset form twice, at commands 0x181C and 0x18CE, to reuse text beginning at 0x0336. Those are the only explicit string offsets in shipped code.

The handler has a deliberate retry path that reads no operand. If a modal dialogue is already active, it suspends the command thread at the original command offset. Once the modal state clears, re-execution consumes p and continues. This no-read path is synchronization behavior, not an optional text operand.

Opcode 0x4E is a presentation channel rather than a hard type system. A few scripts reuse it for system or caption text such as Verse loaded: & and the credit screen. The speaker names describe the dominant, independently labelled use rather than a restriction enforced by the interpreter.

The formatter at 0x224F scans the display string for &. When present, it switches to the complete index string of the currently selected text record. That record is stored as citation|verse; while copying it into the temporary display buffer, the formatter replaces | with -. Thus Verse loaded: & expands to the reference and full verse. A literal | in the display template is not a separate verse-only placeholder.

The modal dialogue state word is at DS:8934. State 1 denotes a choice menu; higher positive states select dialogue-box artwork and behavior. Function show_dialogue_message at 0x2933 renders the frame, wraps the far text, and runs the input loop while continuing to update scene animation.

The routine also creates one transient input record at DS:0248. Its anchor is the horizontal text midpoint and logical text Y plus five. Each update passes that record to the shared nearest-object selector at 0x6CE5, which uses the distance helper at 0x3315 and rejects values above 100. The click exit at 0x2ADD requires both a selected record and the primary-button latch. A click outside that proximity region therefore does not accept the message; the full panel and viewport are not implicit continue targets.

The Escape branch at 0x2AAA writes status event 12 to DS:0052, removes the transient dialogue artwork, and returns -1 without clearing the underlying dialogue state. poll_input_event at 0x7C3F converts event 12 back to Escape, and the main dispatcher at 0x8904 opens Game Options. Returning from options causes the still-active dialogue to be drawn again. The common choice selector takes the same event-12 route, preserving both the choice records and current selection. Game Options is therefore an overlay continuation, not a dialogue cancellation.

Both blocking text loops also recognize BIOS key values BBh through C5h. They subtract BBh, store the result as the status event, and return -1; poll_input_event reverses that translation before returning to the main dispatcher. Values BBh..C2h are F1..F8 and reach the status switch only when word 004C enables action selection. F9 (C3h) and F10 (C4h) reach the quick-load and quick-save branches independently of that gate. A blocking Computer Bible, map, or notice routine then returns to the still active text modal, except when loading replaces the current game state. Other keys rejected by the active text loop do not fall through to the scene action matcher. In particular, an Up or Down press cannot activate a directional exploration target behind an ordinary dialogue.

The three bytes written by opcodes 0x5C and 0x5D are logical text X, text Y, and wrap width. The BOSS.BIN values [4, 30, 150] and [162, 89, 150] align exactly with the upper-left and lower-right panels in the preserved capture. FACE.BIN supplies [80, 12, 180] and [80, 170, 180], establishing that the tuple is general panel geometry rather than a small set of speaker identifiers.

The visible type is not a BIOS font. Startup routine 0xAE42 loads frame 0 of STUFF.ART, a 257-by-14 atlas arranged as two seven-row glyph strips, and builds an offset table for bytes 21h through 7Fh. The executable’s width table at DS:3462 makes the font proportional; renderer 0xAF36 advances by width plus one, treats spaces as three pixels, and copies seven source rows. Each logical source pixel becomes a 2-by-2 block on the 640-by-400 presentation surface, with an eight-logical-pixel line pitch.

The atlas has three source values which renderer 0xBE7E maps through a text style. Character and Captain Bible messages use style 2 (1,37,4), while adversary messages use style 7 (15,86,90). The live BOSS framebuffer contains exactly indexes 1, 37, and 4 in the displayed line, independently confirming the normal-dialogue mapping. The CONTINUE caption is the complete sprite in STUFF.ART frame 29 rather than text rendered at runtime; its descriptor (-17,-3,35,7) centers and overlaps it above the panel.

Choice table

Opcode 0x45 clears the current choices and resets dialogue state. Each opcode 0x44 then appends one six-byte entry, and opcode 0x46 presents the result. The runtime structure is:

Location / offsetSizeMeaning
DS:B4282Number of active choices.
DS:B116 + 6*n + 02Absolute target offset in the current BIN resource.
DS:B116 + 6*n + 22Text pointer offset.
DS:B116 + 6*n + 42Text pointer segment.

The choice text normally points directly into the loaded BIN member. Opcode 0x13 removes the first entry whose target word matches its operand and shifts the later six-byte records down. No recovered BIN resource uses 0x13, but its implementation is unambiguous.

The full command lifecycle is:

  1. clear_dialogue_choices (0x45, handler 0x51B7) sets the count and dialogue state to zero.
  2. add_dialogue_choice (0x44, handler 0x51C6) stores the absolute target and far string pointer, then increments the count.
  3. present_dialogue_choices (0x46, handler 0x5257) enters state 1 and suspends the current scene thread.
  4. select_from_text_menu at 0x2556 draws the entries and returns the target word belonging to the chosen row.
  5. poll_input_event writes that target to DS:7CBA. The resumed handler replaces the interpreter cursor with the target, so execution continues at the selected branch.

The input loop clamps keyboard selection at row zero and count minus one. For every wrapped row, 0x25E8..0x26AC constructs a 16-byte selector record. Its first two words are the proximity anchor (text_x + 72,row_y + 2); offsets 8 and 10 hold the separate SELECT artwork anchor. Function 0x6CE5 tests all row anchors with distance helper 0x3315, retains the strictly closest result at distance 100 or less, and therefore resolves equal distances in row order. A pointer move outside all row regions clears the pointer selection rather than retaining the prior keyboard row.

Unselected choices use style 1 (1,7,3) and the selected choice uses style 2 (1,37,4). The adjacent SELECT caption is STUFF.ART frame 28 with descriptor (-12,-3,24,7), including its lettering and border. Thus neither choice label nor continuation label should be synthesized with a host font. The caption anchor is (text_x - 11,row_y + 2) for a text origin of 23 or greater and (text_x + text_width + 12,row_y + 2) otherwise. This selector record family is shared by Game Options, slot lists, and confirmation menus; those interfaces are not rectangular host controls.

The corpus contains 40 choice definitions in six resources, 11 clear commands in six resources, and 14 present commands in seven resources. Those different totals reflect conditional paths and reuse of a previously built menu; a linear listing must not assume that every definition belongs to one runtime menu.

BOSS.BIN example

The introductory conversation constructs one five-choice menu:

ChoiceSourceTargetText
00x045C0x0644So what do I do when I get inside?
10x04820x07E8Can I expect any resistance?
20x04A20x0751What about the people inside?
30x04C30x0519Should I expect any problems with my computer bible?
40x04FB0x095COK! I'd better go do it!

Each answer eventually jumps back to the menu-building region, except the final choice, which advances the introductory sequence. This is ordinary BIN branching; the menu itself does not retain a conversation graph.

The shared text-menu input loop at 0x2556 lowercases an otherwise unmatched keyboard character at 0x27AA, scans displayed strings from row zero at 0x28BA, and compares their first byte at 0x28D0. A match is converted into the same activation event as Enter at 0x28E7. Choice menus therefore support case-insensitive first-letter activation, with the first duplicate initial winning.

The branches at 0x2796..0x2825 additionally map Home and End to row zero and the final row. Up and Down walk over the disabled marker byte 0xFE, while the direct edge assignments do not perform that skip.

Study-Bible integration

Victim conversations repeatedly ask the player to select an appropriate verse. Opcode 0x7D reads a byte plus a script-variable operand and stores a prompt component at DS:0066 and the variable’s current selector value at DS:0068. The study screen’s prompt builder at 0x446F interprets the first value as follows:

Prompt valueCompanion component shown
0x00, 0x09No prompt.
0x2A (*)Victim-conversation text.
0x64P paraphrase text.
other nonzeroL cyber-lie text.

The builder looks up the expected selector directly rather than searching the acquired-verse list. It submits the resolved text to the general wrapped-text renderer with logical X 6, Y 22, width 309, and at most three lines on an eight-pixel pitch. A vertical bar forces a new line. L prompts use text style 7, P prompts use style 9, and * prompts use style 10. Because the open-book chassis begins at logical Y 51, these three possible rows occupy the black space immediately above it.

Opcode 0x49 sets the study request at DS:79F0 and suspends the current thread. The main game loop clears the request and calls handle_study_bible_request at 0x834E, which invokes the full study browser at 0x1C88. The browser clears state flags 0x14 and 0x15 before input. Its key branch at 0x1E2C recognizes ASCII 16h (Ctrl+V), finds the configured selector from DS:A0EA, and changes the current record only when the corresponding acquired-state byte is nonzero. The normal redraw then places that record on its 14-row page; the shortcut does not apply it. Selecting the expected descriptor sets flag 0x14; leaving without that match sets flag 0x15. Scripts branch on those flags around opcode 0x72, which applies a one-update scheduler yield rather than another modal suspension.

For example, NAGE.BIN sets variable 28 to a text-record selector, executes configure_study_prompt 0x2A, var[28], requests the study screen, and then branches to success, rejection, or departure paths according to flags 0x14 and 0x15. This ties the conversation graph directly to the text descriptors and companion * records documented in the text-format chapter.

QEMU validation

A visible, silent ./run.sh --trace-dos session was advanced from the title screen to the BOSS conversation. At the five-choice menu, the stable game data segment was 14E1. The one-megabyte physical-memory capture therefore placed the count at physical 0x20238 and the table at physical 0x1FF26.

The live count was five. All five records contained the exact static target words above and far pointers into BOSS.BIN at 4C13:045F, 4C13:0485, 4C13:04A5, 4C13:04C6, and 4C13:04FE. Dereferencing those pointers produced the five visible menu strings byte for byte.

Before selection, DS:8934 was 1 and DS:7CBA was zero. Selecting the final row changed DS:7CBA to 0x095C; the next visible dialogue was Before you go, I think that we should pray., exactly the opcode-0x48 string at target 0x095C. The resulting dialogue state was 2. QEMU was then stopped through its monitor.

The ignored evidence files are under build/qemu-trace/, including conversation-menu2.png, conversation-menu2-physical-1m.bin, conversation-selected.png, and conversation-selected-physical-1m.bin.

Inspection

The ordinary BIN listing now uses semantic names for the recovered dialogue, choice, study-request, and thread-suspension commands. Add --choices for a compact linear inventory of every opcode-0x44 definition:

tools/inspect_bin.py build/dd1/all/327_BOSS.BIN --choices

The summary prints source offset, target offset, and text. It explicitly warns that branches can change the menu seen at runtime.

Remaining boundaries

  • The exact artwork role of every positive dialogue state is not yet named.
  • Conversation choices and verse answers alter progression flags, but they do not expose a separate enemy-health, hit-test, or combat-entity structure.

Relevant executable routines

Load offsetCurrent name
0x1C88show_study_bible
0x2556select_from_text_menu
0x2933show_dialogue_message
0x446Frender_study_prompt
0x4672Configure-study-prompt handler.
0x51B7Clear dialogue-choice handler.
0x51C6Add dialogue-choice handler.
0x51FFRemove dialogue-choice handler.
0x5257Present dialogue-choice handler.
0x52A3Shared dialogue-message handler.
0x5351Study-Bible request handler.
0x5357Suspend-scene-thread handler.
0x7BEDpoll_input_event
0x834Ehandle_study_bible_request
0xAE42Load the STUFF.ART font and build glyph offsets.
0xAF36Draw proportional seven-row glyphs.
0xBE7EDraw a text object with a three-index style mapping.

Offsets use the unpacked load-module convention documented elsewhere in this book.

Script State and Progression

Primary-state structure

The 200-byte live block at DS:727A is the scene interpreter’s shared state. New-game initialization writes 100 zero words with rep stosw, and the save system copies exactly those 100 words to and from the checkpoint block at DS:7BF2. Values are signed 16-bit integers where arithmetic or comparison requires a sign.

BIN operands do not store variable ordinals. They store even byte offsets from DS:727A; the interpreter shifts an offset right once before indexing the word array. Thus operand 0x002A identifies variable 21:

address = DS:727A + encoded_offset
index   = encoded_offset / 2

Across all 64 recovered BIN code regions, the core variable instructions reference 39 of the 100 slots. Every one of their encoded operands is even and below 200. Many high-numbered slots are scene-local temporaries rather than persistent player attributes.

Identified variables

IndexByte offsetDS addressCurrent meaningEvidence
00x00727ADifficulty: 0 Easy, 1 Normal, 2 DifficultDifficulty input writes these values; map loading indexes END; faith damage branches on the same values.
110x167290Current map XUsed in every 3*x cell calculation and exploration-bit update.
120x187292Current map YUsed in every 48*y cell calculation and as the exploration-row index.
160x20729ACurrent map level letterload_map_resource compares and caches its level argument here.
170x22729CCurrent cell parameter Aprocess_current_map_cell copies cell byte +1 here.
180x24729ECurrent cell parameter Bprocess_current_map_cell copies cell byte +2 here.
210x2A72A4Faith in hundredths of a percentInitialized and clamped to 10,000; the F3 display divides by 100.

The first gameplay entry in FIRST.BIN demonstrates the interface directly: it stores X=0 in offset 0x16, Y=6 in offset 0x18, and faith=10,000 in offset 0x2A before processing the current map cell.

Map processing also produces several context-local words for the hall scene:

IndexDS addressMap-processing role
137294Connected-cell low kind, or zero-mask room class 04.
147296Room entrance code 02, or a neighboring-cell kind during hall processing.
2372A8Parameter B of a Trap room immediately right of the hall.
2472AAParameter B of a Trap room immediately left of the hall.
2572ACParameter B of a Trap room immediately above the hall.

These are not durable player attributes. process_current_map_cell rebuilds them from the live map, and other scenes can reuse the same general-purpose slots. The world-map chapter describes the room quotient/remainder and the adjacent Trap interaction that establish these meanings.

Variable bytecode

The core instruction family is now recovered. In this table, var operands are encoded byte offsets, value is a signed immediate where relevant, and target is an absolute BIN file offset.

OpcodeOperandsEffect
0x1Esource, destinationCopy a variable.
0x1Fvalue, destinationStore an immediate value.
0x20 / 0x21var, targetJump if zero / nonzero.
0x22 / 0x24left, right, targetJump if two variables are equal / unequal.
0x23 / 0x25var, value, targetJump if a variable equals / does not equal an immediate.
0x26 / 0x28left, right, targetSigned jump if left is greater than / less than right.
0x27 / 0x29var, value, targetSigned jump if a variable is greater than / less than an immediate.
0x2A / 0x2Bsource-or-value, destinationAdd a variable / immediate to the destination.
0x2C / 0x2Dsource-or-value, destinationSubtract a variable / immediate from the destination.
0x2E / 0x2Fsource-or-value, destinationSigned multiply the destination by a variable / immediate.
0x30 / 0x31source-or-value, destinationSigned divide the destination by a variable / immediate.
0x32 / 0x33varIncrement / decrement.
0x8F / 0x90source-or-value, destinationBitwise-AND the destination with a variable / immediate.

The disassembler annotates known operands with both forms, for example var[21:faith]@0x002a. This also prevents immediate values and jump targets from being mistaken for variable numbers.

Boolean state flags

Variables 3 through 10, at DS:7280..728F, are also treated as a 128-bit flag bank. Identifier n selects word n >> 4 and mask 1 << (n & 15). Dedicated helpers test, set, and clear one identifier using mask and inverted-mask tables in the executable.

OpcodeOperandsEffect
0x73flag, targetJump if the flag is clear.
0x74flag, targetJump if the flag is set.
0x75flagClear the flag.
0x76flagSet the flag.

The scene corpus uses 78 distinct identifiers through 0x55. They mix temporary navigation/action state with durable progression. When the current map cell is processed, the executable clears the first three flag words (0x00..0x2F) and rebuilds movement and action availability from the cell and its neighbors. Flags at 0x30 and above survive that operation.

Five durable identifiers map exactly to the F4 through F8 status icons:

FlagCapability
0x30Sword
0x31Shield
0x32No Trap
0x33Candle
0x34Flight

Flag 0x36 is the status-artwork gate. The renderer at 0x641B releases the Computer Bible, Map, Faith, and power slots and returns when it is clear; several shipped conversation scenes clear it and restore it around their authored sequences. The main loop separately gates F1 through F8 on action selection word 004C. Its pointer-status path requires flag 0x36 as well, so keyboard shortcuts can remain active when the row is hidden but clicks cannot. The branch at 0x8845 also maps ASCII b to the same Computer Bible routine as F1 whenever word 004C is nonzero. The renderer clamps Faith for frame selection and retains the empty frame at exactly zero.

The same renderer owns the dormant transient started by unused opcode 8B. While flag 0x36 and timer word 005C are nonzero, its pre- and post-VM refreshes each consume the runtime random source and use the low two bits as flip flags for zero-based STUFF.ART frame 27. The exact timer and call-order rules are recorded under Runtime random source.

Two adjacent flags control automatic combat:

FlagMeaning
0x37Automatic Combat option is enabled.
0x38An ordinary combat scene is active; lock the option against changes.

The Game Options routine displays the on/off state from 0x37. When 0x38 is set it assigns the Automatic Combat row a disabled target instead of the normal toggle target. COMBAT1 through COMBAT5 and COMBAT7 set and clear 0x38 around their shared encounter lifetime. The exceptional guard program COMBAT6 does neither.

Game-options dispatcher

The main input loop handles Escape at 0x88FE after the action-selection branches have rejoined. It calls the options routine at 0x2F36 without testing word 004C, so the menu remains available while the status-control row and ordinary scene actions are disabled. Ordinary dialogue and choice routines translate their own Escape input into status event 12, which poll_input_event at 0x7C3F returns to this dispatcher. Their modal state remains active and is redrawn after Game Options closes. Other modal interfaces may consume Escape locally.

The dialogue routine at 0x2A89 and text selector at 0x2758 similarly propagate BIOS F-key values BBh..C5h through status word 0052. poll_input_event adds BBh back. The main loop sends F1..F8 to the status switch only when word 004C is nonzero, but its F9 quick-load and F10 quick-save tests at 0x892D and 0x890A are outside that gate. Status screens entered this way are synchronous: returning from one redraws an interrupted dialogue or choice unless a load changed the session.

Faith and power descriptions take a subtly different route. Status dispatcher 0x83B6 calls wrapper 0x2AFD, which invokes the same dialogue reader but ignores its return value. A plain F1 through F10 still makes the reader return negative and closes the description, yet that key never returns to the main dispatcher and cannot open another status screen or trigger quick save/load. The next scene poll redraws any interrupted dialogue or choice.

The Computer Bible’s printable b alias is different. Its comparison at 0x884C..0x885F is an exact test against 62h and is reached only after a blocking text routine has returned the key to the main dispatcher. Ordinary dialogue does not return b, and the common choice selector consumes it as a first-letter row accelerator when one matches. Only a top-level lowercase b therefore opens the Bible; Shift+B and Caps-Lock B do not, while Shift plus Caps produces lowercase ASCII and does.

Because those routines are blocking, the key or pointer event used to leave one is consumed before the main dispatcher resumes. It cannot also activate the restored dialogue, set a scene confirmation latch, or select an underlying world target during the same poll.

The status switch at 0x83B6 has 13 entries. Entries 0 through 7 are the Computer Bible, Map, Faith, and five powers; entries 8 through 11 are no-ops; entry 12 calls the same options routine. The click dispatcher at 0x87E0 special-cases entry 12 before testing action selection or flag 0x36. This is the always-present upper-right disk indicator, so its authored bounds remain an options control even when the rest of the status row is hidden.

The options routine at 0x2F36 temporarily replaces the active text-menu table and builds its rows in this exact order:

  1. Continue
  2. Load Game
  3. Save Game
  4. New Game
  5. the translation string indexed by word 007C
  6. Music On or Music Off from word 0048
  7. Sound Effects On or Sound Effects Off from word 004A
  8. Automatic Combat On or Off from flag 37
  9. Quit

The installation no-combat word at 0058 suppresses row 8. Flag 38 replaces that row’s target with -2, the menu’s disabled value. A translation lock leaves the current translation row visible but prevents its target from cycling word 007C.

Before calling select_from_text_menu, the routine sets the text origin to logical (180,10) and the width to 130. choose_save_slot at 0x2B6F uses ten rows at (140,10) with width 170: the appropriate Cancel string followed by all nine labels. The confirmation helper at 0x2EEC builds two rows from Start New Game or Quit Game plus Cancel.

For each load row, choose_save_slot compares the mutable filename suffix at DS:0045 with that row’s digit and confirms that the state file exists. A match is copied to a temporary buffer and has literal << appended. Selecting a normal save or load leaves its digit as the active suffix; the F9/F10 path temporarily uses Q and restores suffix 0, so quick operations do not mark a numbered row.

The save-name routine at 0x2DF7 copies the selected label to a 27-byte local buffer and clears it first when it equals (EMPTY). Its editor at 0x2C8B limits input to 26 bytes and accepts ASCII ranges 20..3B, 3F..5A, and 61..7A, except for & and *. Backspace (08h) and Left (CBh) delete, Enter accepts, and Escape cancels. The local timer at 0x2CA5..0x2CD6 toggles a literal > at the current end every 02BCh (700) reference-timer units. Editing or deleting writes a new terminator and hides the cursor until the next toggle. After acceptance, initialize_empty_save_slot at 0x815A replaces an empty or (EMPTY) label with Game 1 through Game 9.

Before entering that editor, 0x2DF7 rebuilds a ten-row text table: disabled row zero points to Enter New Name, and rows one through nine retain every save label. It calls select_from_text_menu with the chosen slot plus one so the complete panel is drawn without entering the selector loop, releases the SELECT render slots, and passes the chosen row’s render slot and text coordinates to 0x2C8B. The editor therefore redraws only that row in style 2 while all other labels remain visible in style 1.

The common selector’s Escape branch at 0x278E reaches 0x28A3 and returns -1. The options caller distinguishes that value by nesting level. At 0x3267 it exits the main menu; the save/load calls at 0x3257 and 0x3276 loop back to rebuild the main menu; and the confirmation wrapper at 0x2EEC also reports a negative selection as unconfirmed. The label editor differs: 0x2ECC skips copying the edited local buffer on Escape, but the caller at 0x3261 still continues to save_selected_slot. Thus Escape there rejects the label edit without cancelling the selected save operation.

The function-key branch at 0x2758..0x2772 also makes the selector return -1, after recording the BIOS value relative to BBh in word 0052. Dialogue and choice callers expose that negative return directly to poll_input_event, but Game Options consumes it inside its own call stack. The main selector consequently closes at case -1/0x3267; a negative save/load selection returns nonzero to 0x325D or 0x327C and rebuilds the main menu; and a negative confirmation becomes false at 0x2F2B. No top-level F1..F10 action runs from that same key. The save-name editor at 0x2C8B uses a separate input loop and rejects function-key values.

F9’s state replacement is also a loop boundary, not a deferred scene request inside the controller. The branch at 0x8934 reads .SVQ, stores its return mode at 007A, and reaches the return test at 0x8966. A successful read therefore exits main_menu_and_game_loop. game_main stops boundary music, dispatches mode 2 at 0x8C4A..0x8C75, copies the retained checkpoint buffers to live state, and only then re-enters the scene loop. The old scene cannot execute another update with the restored variables.

F10 takes the neighboring but non-transitioning path. At 0x8911 it calls start_palette_blackout(1), which stores 0081 in the palette-effect word. The immediate compositor call at 0x8920 runs update_palette_effect; that clears the high bit, writes all 256 VGA entries black, decrements the effect to zero, and renders before write_save_state. The next compositor update reapplies the mapped scene palette. The save data and indexed framebuffer are not themselves blackened.

The same selector supplies first-letter accelerators. Its loop at 0x27AA..0x28E7 lowercases the key, scans menu rows from zero, compares the first displayed byte, and activates the first match. It does not skip the disabled target value -2; that value reaches the normal rebuild continuation. Options, slot selectors, and confirmation panels all inherit this ordering.

Home (C7) and End (CF) assign the first and last menu indices directly at 0x27BD and 0x27EF. The neighboring Up/Down paths inspect and skip disabled row marker FE; the edge assignments do not. Enter on such a directly selected disabled row returns target -2, which rebuilds the appropriate options level.

Selection dispatch proves the immediate settings behavior: translation is advanced modulo four and reloads the current text bank, music and effects XOR their enable bytes with one, and Automatic Combat toggles flag 37. The translation path at 0x31D0 calls load_text_bank directly. That loader rewrites descriptor offsets +0, +2, +5, +6, and +8, but deliberately leaves acquisition state byte +4 unchanged. Collected records therefore remain collected when their citation and verse strings change translation. Opcode 6B differs because its handler clears all 66 +4 bytes after the load.

The effects case at 0x31F5 consists only of xor byte [0x4A],1 followed by the common menu rebuild. It does not call release_sound_effect_buffer, digpak_stop_current_sound, or any other audio routine. An active effect therefore continues after the option changes to Off, although later opcode 58 starts are suppressed. Opcode 59 independently tests the same effects word at 0x5125; while it is zero the script takes the 100-unit silent fallback rather than polling the still-running driver.

Continue, a completed save, and a completed load close the interface. New Game and Quit only proceed after their confirmation menu.

The seven victim scenes each set a distinct rescue flag at successful progression points:

FlagScene / victim identifier
0x3AJELO
0x3BFEAR
0x3CCULT
0x3DLAW
0x3ERICH
0x3FDENY
0x40NAGE

GANTRY.BIN tests those seven flags and mirrors the set members into 0x42..0x48 before the Unibot sequence. The bytecode proves the one-to-one transition. CP1.BIN counts those later flags as the rescued crew physically present aboard the Unibot and requires all seven before departure.

One further durable flag is specific to the Unibot road network:

FlagMeaning
0x54The one-time Annoy Cyber verse-loss event has occurred.

The late-game programs also give exact meanings to variables 53 through 65:

IndexByte offsetCurrent meaning
530x6AUnibot turn/rotation offset.
540x6CCurrent Unibot node.
550x6EHeading: north 0, east 1, south 2, west 3.
56–620x70..0x7CPylons 1–7 rescued/destroyed.
630x7ENext node selected by forward movement.
640x80Active pylon number; 100 means none.
650x82Tower confrontation state: 0, 1, 2, or failure 9.

ROBOT.BIN initializes variables 53 through 55. CP2.BIN uses variables 56 through 62 as both pylon-completion state and the seven-part Tower gate. FACE.BIN and CP3.BIN alternate on variable 65 to implement the final study prompt and its success/failure branches. See the Unibot and endgame chapter for the complete graph and state machine.

Two lower flags carry the result of a conversation’s study-Bible prompt:

FlagConversation meaning
0x14The player selected the expected text descriptor.
0x15The player left the browser without that match.

The browser clears both flags before accepting input. Victim scenes branch on them after requesting the study screen, so they are transient result flags rather than durable progression markers. See the conversation-flow chapter for the complete prompt and suspension sequence.

Faith

Faith is variable 21 and uses a 0–10,000 scale, so one displayed percentage point is 100 internal units. The status renderer clamps values above 10,000 and below zero before selecting meter artwork. The F3 detail screen divides by 100 and writes the resulting two integer digits into the mutable Your faith is at 00%. template. A separate Your faith is at 100%. string handles 10,000.

The status-control dispatcher at 0x83B6 passes every Faith or power notice to the ordinary dialogue routine with logical presentation values (24,28,150). The five power messages are executable strings rather than generated labels:

  • Helps you hit harder during battle.
  • Helps protect you during battle.
  • Warns of rooms that are traps.
  • Causes dark halls to be lit.
  • Lets you fly in some places.

Opcode 0x81 passes an immediate loss to reduce_faith at 0x3979:

  • Easy divides the loss by two.
  • Normal applies it unchanged.
  • Difficult multiplies it by four.
  • No-combat mode suppresses the subtraction.

This directly supports the manual’s statement that Easy mode loses faith less readily and connects the installation no-combat option to the same damage path.

Faith exhaustion is checked centrally after input processing rather than by individual scene scripts. handle_faith_depletion at 0x7B12 clamps a negative value to zero and calls enter_game_over_scene at 0x1B86. That routine selects the initialized resource strings OVER and seg, sets the pending-scene state, and starts the accompanying palette effect. The POWER.BIN resource is a separate in-combat study interface and must not be confused with this game-over transition.

One encounter also raises faith rather than reducing it: the Zapper victory subroutine in COMBAT7.BIN alternates direct assignments of 1 and 10,000, ending at the maximum. This implements the special full-faith reward stated in the manual.

Text-record progression state

Each loaded text descriptor has a persistent byte at record offset +4. The save chapter describes its compact checkpoint copy and serialized live records. The bytecode interpreter addresses these bytes by the descriptor’s one-byte selector:

OpcodeOperandsEffect
0x36selectorSet the matching record’s state byte and select it.
0x37selectorClear the matching record’s state byte.
0x38selector, targetJump if the matching state byte is set.
0x39selector, targetJump if the matching state byte is clear.
0x88noneClear all 66 loaded state bytes.

This is the persistent bridge between dialogue/study records and scene control flow. The exact user-facing meaning varies by record: the same mechanism can represent an obtained verse, completed interaction, or another text-related condition.

Save inspection

Show named, nonzero, or checkpoint-different variables and decode the flag bank with:

tools/inspect_save.py CB/DDGAMES.SV9 --variables

The supplied saves have no active boolean flags. Both copies keep variable 16 at -1. Their live copies vary at general-purpose variable 28, and SV9 also has variable 27 set to 5; static evidence does not justify assigning gameplay meanings to those temporary slots.

Relevant executable functions

Load offsetCurrent name
0x1191initialize_script_state
0x1B86enter_game_over_scene
0x3979reduce_faith
0x43F5test_state_flag
0x4413set_state_flag
0x4433clear_state_flag
0x5B24get_text_record_state
0x5B76set_text_record_state
0x5BBFclear_text_record_state
0x7B12handle_faith_depletion

Offsets use the unpacked load-module convention documented elsewhere in this book.

World Maps

Resource set and naming

DD1.DAT contains 21 MAP members: one for every combination of level letter A through G and difficulty code E, N, or D. The codes mean Easy, Normal, and Difficult, matching the three modes described by the manual. Archive directory entries 215 through 235 contain this complete cross product, and every member expands to exactly 768 bytes.

Scene opcode 0x78 supplies a level letter. Its load_map_resource helper at load offset 0x034F first compares that letter with variable 16. A match returns without touching the mutable grid or exploration state. For a different level, it reads script variable zero as a difficulty index, selects one byte from the literal END, appends .MAP, and loads the resulting archive member into the live grid at DS:5B16. It stores the new letter in variable 16 and clears the sixteen exploration words at DS:72C4. Uses across the scene corpus supply all seven letters. For example, level C on Easy mode loads CE.MAP.

When the global no-mature flag is set, the same loader scans the new grid. A connected cell with low kind 1 through A is replaced by an empty connected hall if either parameter is at least E0: the connection nibble survives, while the low kind and both parameters become zero. Rooms and kinds B through F are not rewritten. The resource corpus contains 67 such cells across twelve Normal and Difficult maps and none in the Easy maps.

This establishes that the policy filters access to mature encounters at the map layer. It does not compact the runtime Bible descriptor table: no-mature DOS saves still contain all 46 bank-C records, including selectors E1 through E4.

Grid layout

Each resource is a headerless, row-major 16×16 grid with three bytes per cell:

cell_offset = 3 * (16 * y + x)

+0  packed connection/location byte
+1  parameter A
+2  parameter B

Coordinates range from zero through 15. Disassembly repeatedly computes 48*y + 3*x, including accesses to the four neighboring cells by adding or subtracting 3 for X and 48 for Y. The current X and Y coordinates are words at DS:7290 and DS:7292.

The first byte has two independently used nibbles:

  • The high nibble is a four-direction connection mask: 0x10 is up, 0x20 is down, 0x40 is left, and 0x80 is right. It selects one of 16 connection frames when the map screen is drawn.
  • The low nibble selects a location kind. Shipped scripts replace it while preserving the high nibble. More precisely, opcode 0x7B preserves the old high nibble and ORs it with the low byte of a script variable; the handler does not mask that variable to four bits. All shipped callers use only 0x00, 0x05, 0x0A, 0x0B, and 0x0C, so they have the intended low-nibble effect. Several numeric kinds can be correlated with map glyphs and gameplay branches, but a complete symbolic enumeration is not yet justified.

Parameters A and B are also manipulated separately by scene commands. The map screen uses them as text-record selectors for at least location kinds 0x6 and 0xA, consistent with the manual’s statement that stations and communication locations show verse references. Other meanings remain open.

Room encoding and dispatch

A zero connection mask changes the meaning of low kinds 0x1 through 0xF. They form five room classes of three entrance orientations each:

room_class     = (location_kind - 1) / 3
entrance_code  = (location_kind - 1) % 3

Both operations use integer quotient and remainder. The executable writes them to script variables 13 and 14. A static table at load offset 0xED7A contains the same orientation sequence for neighbor detection.

Low kindsClassSceneMap letter
0x10x3VictimLevel-specific JELO, FEAR, CULT, LAW, RICH, DENY, or NAGEV
0x40x6TrapROOM1T
0x70x9PrayerROOM2P
0xA0xCCommunicationsROOM3C
0xD0xFJump TunnelROOM4J

The scene-resource identities provide independent confirmation. ROOM1 loads TRAP, TRAP2, and TRAP3; ROOM2 loads PRAY; ROOM3 loads COMM, COMM2, and FACE1; and ROOM4 loads TUNNEL, TUNNEL2, and MONST1. The hall programs dispatch victim class zero to the seven named victim scenes and patch the final digit of room1 for the other four classes. These five classes are also exactly the T, P, C, J, and V rooms described by the manual and rendered by the map screen.

Kind within each classRoom position from hallEntrance side
First (1, 4, 7, A, D)Right / eastWest
Second (2, 5, 8, B, E)Left / westEast
Third (3, 6, 9, C, F)Above / northSouth

There is no encoding for a room below a hall cell. The executable supports all 15 class/orientation codes, but the 21 shipped maps use only 14: no map contains a south-entry Jump Tunnel (0xF) with a zero connection mask.

Room codes and connected-hall codes are separate contexts. For example, a zero-mask 0xA is a Communications room, while kind 0xA on a connected hall cell participates in station and post-encounter behavior. Treating the low nibble as one global enum would incorrectly merge these states.

The parameters are likewise class-specific. process_current_map_cell copies the current room’s parameter A and B to variables 17 and 18. Trap scripts use parameter A as a study-prompt selector and clear it after the interaction. When a Trap room is adjacent to a hall, its parameter B becomes one of three contextual prompt values in variables 23 through 25; a correct study result clears that byte in the adjacent cell. This identifies both bytes as mutable encounter state in that class without assuming that they have the same meaning in Prayer, Communications, or Jump Tunnel rooms.

Connected hallway features

When the connection mask is nonzero, the low nibble describes the hallway cell rather than a room. The following meanings have independent script, resource, or transition evidence:

KindHall featureEvidence
0x0Empty connected hallwayNo feature branch or parameters.
0x1Macho CyberSelects COMBAT1, whose enemy resources are BIG*.
0x2Armored CyberSelects COMBAT2, whose enemy resource is HELMET.
0x3Mantis CyberSelects COMBAT3 and MANTIS*.
0x4Snake CyberSelects COMBAT4 and SNAKE*.
0x5Spider CyberSelects COMBAT5 and its internally named CRAB art.
0x6Leech-covered Scripture stationSelects COMBAT6 and GUARD*; victory restores a station.
0x7Zapper CyberSelects COMBAT7 and ZAP*; passing beneath it damages faith.
0x9Hidden Spider triggerA successful trigger replaces it with kind 0x5.
0xAScripture stationEnables Get Verse using parameter A.
0xBCleared encounterWritten by ordinary combat victories.
0xELevel exitEvery hall program branches from this kind to its exit sequence.

The seven hall programs dynamically patch SML1 to load SML1 through SML7, and their Confront Cyber actions enter POWER. That scene patches combat1 from the current kind and returns to COMBAT1 through COMBAT7. This joins the map kinds, hallway sprites, and combat resources without depending only on visual resemblance. The manual supplies the player-facing Cyber names. CRAB is the Spider: the separate kind-0x9 ambush state conditionally turns into kind 0x5, matching the documented Spider that can drop behind Captain Bible.

Kind 0x6 has the strongest transition evidence. Its special combat victory writes kind 0xA, copies parameter B to parameter A, and clears parameter B. The result is a normal Scripture station whose verse selector is now in the field used by Get Verse. This matches the manual’s Leech Cyber, which sits on top of Scripture stations. Kind 0x7 can be walked under, but its hall branch applies base faith loss 400; defeating it restores full faith in COMBAT7. Both behaviors match the Zapper description.

Cyber parameter A selects the lie used by the confrontation. The covered station’s parameter B is preserved as the verse selector revealed after the Leech is defeated. A normal station uses parameter A, sets the corresponding text-record state, and displays Verse loaded: & when Get Verse succeeds. The dialogue formatter expands & to the selected record’s complete citation - verse text.

Connected kinds 0xC, 0xD, and 0xF control visual or environmental hall states, but their exact player-facing meanings remain unproven. Kind 0x8 does not occur on a connected cell in any shipped map. The inspector leaves all four unnamed rather than folding them into the entity table.

The hall action selectors now also have direct labels:

SelectorAction
.u, .d, .l, .rMove Up, Down, Left, or Right
.cConfront Cyber
.xUnlock
.vGet Verse

The three Unlock targets operate on a locked Trap-room door to the right, left, or above the hall. They use the adjacent room’s parameter B as the study prompt and clear it after the correct verse is applied. This separates the door lock from parameter A, which controls the encounter inside the Trap room.

Runtime state and scene commands

The loaded resource becomes mutable gameplay state. The following commands have direct support in their handlers:

OpcodeOperandsEffect
0x77noneProcess the current cell, consulting adjacent cells and current state.
0x78BIf the level changed, load its difficulty map, apply no-mature rewriting, and clear exploration rows.
0x7BHPreserve the current high nibble and OR in a script variable’s low byte. Shipped values are all valid low-nibble kinds.
0x7CHSet parameter A from a script variable.
0x7FHSet parameter B from a script variable.
0x87noneNormalize location cells after loading or state changes.
0x89noneMark the current coordinate explored.

Opcode 0x89 updates a separate 16-word row bitmap at DS:72C4:

explored_rows[y] |= 1 << x

The F2 map screen tests this bitmap while rendering its 16×16 display. This agrees with the manual: explored areas appear gold, unexplored areas gray, stations and communication points display verse references, and room types are marked with the letters P, J, T, C, and V.

The normalization routine at 0x0457 proves that resource bytes are not immutable identifiers. In one pass it changes low kind 0x6 to 0xA, moves parameter B to parameter A, and clears parameter B. In another relevant pass, location kinds 0x1 through 0x9 become 0xB. Further control-flow work is needed to name the conditions and gameplay states behind those transitions.

Combat programs perform closely matching transitions at their victory epilogues. COMBAT1 through COMBAT5 and COMBAT7 replace the current low kind with 0xB. The guard encounter in COMBAT6 instead writes kind 0xA and copies parameter B to parameter A. Every RETREAT action jumps around these writes into the shared scene-exit path, so retreat preserves the encounter cell. These scripts prove that 0xB is a completed/cleared form for ordinary combat locations and connect the special 0xA transition to the guard encounter, although the broader uses of both kinds remain more general than those labels.

Save-state correlation

Both the live grid and its checkpoint copy are serialized in every 2,752-byte state file. They occupy file offsets 0x4C0 and 0x7C0, respectively. The supplied DDGAMES.SV3 and DDGAMES.SV4 grids match CE.MAP except for four field changes:

CoordinateFieldResourceSaved
(2,0)parameter B0x380x00
(0,1)parameter A0x370x00
(1,1)packed byte0xA20xAB
(2,1)packed byte0xE50xEB

The two packed-byte mutations preserve their high nibbles and replace their low kinds with 0xB, exactly as the normalizer does. This byte-level match independently identifies the saved 16×16×3 tables as world-map state. The other supplied saves have zeroed grids and cannot be assigned to an archive map from this field alone.

Inspection tool

Inspect a resource directly from the archive:

tools/inspect_map.py CB/DD1.DAT --map CE
tools/inspect_map.py CB/DD1.DAT --map CE --cells
tools/inspect_map.py CB/DD1.DAT --map CE --rooms
tools/inspect_map.py CB/DD1.DAT --map CE --hall-features

The compact display prints the low location-kind nibble at every coordinate. --cells adds the packed byte, named connection directions, kind, and both parameters for every nonzero cell. --rooms lists decoded room class, entrance side, and parameters. --hall-features lists only connected cells with proven nonempty features; unresolved environmental kinds remain visible in --cells. Compare a resource with the live grid in a state save with:

tools/inspect_map.py \
  CB/DD1.DAT --map CE --compare-save CB/DDGAMES.SV3

The parser requires an exact 768-byte grid and a valid level/difficulty name. Tests cover all 21 archive members, row-major addressing, connection directions, the complete encoded room domain, the 14 combinations present in the corpus, room/victim scene resources, all seven script level selectors, hall features and combat resources, the hidden-Spider transition, invalid inputs, and the four saved mutations above.

Relevant executable functions and data

Load offset / DS offsetCurrent name or role
0x034Fload_map_resource
0x0457normalize_map_cells
0x075Fshow_map_screen
0x0C6Cprocess_current_map_cell
0xED7A16-byte kind-to-entrance-code lookup table
DS:5B16Live 768-byte grid
DS:7290 / DS:7292Current X / Y coordinates
DS:72C4Sixteen explored-row bitmaps
DS:76ECCheckpoint 768-byte grid

Offsets use the unpacked load-module convention documented elsewhere in this book.

Unibot and Endgame Progression

The final sequence is controlled by six scene programs: GANTRY, CP1, ROBOT, CP2, FACE, and CP3. KABLAM and WIN provide the successful ending, while OVER handles both failure paths. Static decoding is sufficient to recover the rescue gate, the complete Unibot road graph, all seven energy pylons, and the Tower confrontation state machine.

Boarding the Unibot

The seven victim scenes set rescue flags 0x3A..0x40. On the gantry, GANTRY.BIN mirrors each set flag to the corresponding crew-present flag:

Victim flagCrew flag
0x3A0x42
0x3B0x43
0x3C0x44
0x3D0x45
0x3E0x46
0x3F0x47
0x400x48

CP1.BIN counts the seven crew flags in variable 27. With none present it says the craft needs eight people in all; with one through six it reports how many more people are needed. Exactly seven advances to ROBOT.BIN. That scene clears the Sword, Shield, No Trap, Candle, and Flight flags, initializes Unibot variables 53 through 55 to zero, and enters CP2.BIN.

This proves that 0x42..0x48 are not merely generic late-game flags: they are the seven rescued crew members physically present for the Unibot mission.

CP2 data trailer

CP2.BIN is exactly 7,765 bytes (0x1E55). Commands occupy 0x0000..0x1D54; four signed-word tables fill the remainder:

OffsetWordsRecovered purpose
0x1D556416 nodes × four next-node indexes
0x1DD516Node type: road 0, pylon 1, Tower 2
0x1DF516Per-node transition/render value
0x1E153216 lower-right-map (x, y) coordinate pairs

The four exit slots are north, east, south, and west. Turning right increments the current heading and turning left decrements it, both with wraparound. Value 100 is the blocked-exit sentinel. Every nonblocked edge is reciprocal, and each coordinate changes by five units in the expected direction.

The transition values are exposed by the inspector because the script indexes them, but their exact visual interpretation remains unproven. Calling them a render or rotation field would currently go beyond the evidence.

The road network has 16 nodes. N, E, S, and W are destinations; a dash is blocked.

NodeTypeMap (x,y)TransitionNESW
0road/start(270,170)01---
1road(270,165)014402
2road(265,165)0913-
3pylon 1(265,170)8002---
4road(275,165)065-1
5pylon 2(280,165)480---4
6road(275,160)01574-
7pylon 3(280,160)480---6
8pylon 4(275,150)160--15-
9road(265,160)012-210
10pylon 5(260,160)-160-9--
11pylon 6(260,155)-160-12--
12road(265,155)0-13911
13pylon 7(270,155)480---12
14Tower(270,160)160--1-
15road(275,155)08-6-

The three player actions use selectors .r, .l, and .u: turn right, turn left, and move forward. Forward indexes the four-entry adjacency row by the current heading and disables the action when the result is 100.

On scene entry, pylon and Tower node indexes are normalized back to their adjacent road nodes: 3→2, 5→4, 7→6, 8→15, 10→9, 11→12, 13→12, and 14→1. The behavior is exact; treating it as save/resume normalization is a likely interpretation rather than a proven design name.

Script variables and road event

VariableMeaning
53Turn/rotation offset used by the Unibot animation
54Current Unibot node
55Current heading (0=N, 1=E, 2=S, 3=W)
56–62Pylons 1–7 rescued/destroyed
63Next node selected by forward movement
64Active pylon number; 100 means no unresolved pylon
65Tower confrontation state

The ordinary-road handler also contains one special event. The first eligible road visit loads ANNOY, removes the player’s verses, sets flag 0x54, and continues without combat. Later road visits skip it. This matches the manual’s Annoy Cyber description, so 0x54 is the high-confidence one-time Annoy-event flag.

Energy pylons

Entering a pylon node maps it to pylon number 1 through 7 and variable 56 through 62. A pylon whose variable is already nonzero is skipped. Otherwise, CP2.BIN loads the matching face artwork and presents one of seven study prompts, selected by 0x11..0x17.

The expected response sets that pylon variable to one, plays the destruction sequence, and shows the corresponding crew member recovering. A wrong response produces a catastrophic defense failure and enters OVER. Thus the seven variables mean both that the pylon was destroyed and that its captive crew member was recovered.

Tower gate and confrontation

Moving into node 14 tests all seven pylon variables. If any is zero, the crew reports that the defenses cannot hold and the scene enters OVER. With all seven set, the Tower trance begins in FACE.BIN.

FACE renders dialogue for the current state; CP3 performs the transition:

StateFACE / CP3 behaviorNext state or scene
0Tower threatens and cajoles; crew calls Captain Bible back.state 1, FACE
1Tower presses hopelessness and obedience; study prompt 0x20.correct: state 2; wrong: state 9
2Captain rejects control and accepts the risk.KABLAM
9Captain surrenders and shuts down the defenses.OVER

The successful resource chain is CP3 → KABLAM → WIN. The failed study response follows CP3 → FACE(state 9) → CP3 → OVER. No additional hidden endgame condition appears in these programs.

Reproducible inspection

After extracting DD1.DAT, print and validate all four embedded CP2 tables:

tools/inspect_unibot.py build/dd1/all/315_CP2.BIN

The inspector rejects the wrong file size, invalid destinations, unknown node types, nonreciprocal edges, a changed pylon set, or a Tower outside node 14. Archive-backed tests independently assert the crew gate, Unibot initialization, seven pylon variables, graph endpoints, Tower states, and ending scene chain.

All findings in this chapter come from expanded BIN bytecode and its embedded tables. Interactive confirmation of the complete final sequence remains useful, but is not required for the recovered control flow.

Audio Formats

Audio resource families

DD1.DAT contains two independent audio families:

ExtensionMembersExpanded bytesPurpose
ABT41205,513Compressed unsigned eight-bit mono sound effects.
XMI3234,618IFF/XMIDI music sequences.

The scene interpreter prepares an effect with opcode 0x57, whose operands are an effect number and playback rate. Every nonzero invocation in the recovered command regions uses rate 9,000 and constructs D001.ABT through D041.ABT. Opcode 0x58 then starts or restarts that retained effect. A 0, 0 opcode-0x57 invocation stops active playback and releases the retained sample. Music opcode 0x52 selects a numeric track and play_music_resource constructs either a MUS###.XMI or IBM###.XMI name according to a runtime mode flag.

The decoded effect buffer belongs to the current scene. The common scene-initialization path calls release_sound_effect_buffer, stopping any active instance and discarding the retained sample before interpreting the new BIN. New-session initialization reaches the same cleanup path.

ABT header

The game contains its complete ABT decoder at unpacked load offset 0x92E0. Each member starts with this nine-byte header:

OffsetSizeInterpretation
0x002Decoded sample count, little endian.
0x022Playback rate in hertz, little endian.
0x041Samples emitted by each delta block.
0x051Codec identifier.
0x062Auxiliary field not used by the decoder.
0x081Initial unsigned PCM sample.

All 41 resources use a 9,000 Hz rate, delta blocks of 32 samples, and codec identifier 2. The auxiliary word is 128 in 35 files, 32 in four, 64 in one, and 320 in one. Its purpose remains unknown; the decoder explicitly consumes and discards it.

The decoded population contains 412,282 samples, or approximately 45.809 seconds at 9,000 Hz. Samples are unsigned eight-bit mono PCM. The smallest effect is 185 samples and the largest is 32,933 samples.

ABT command stream

The initial sample is followed by variable-length commands until the declared sample count has been produced:

Control byteEncoding
Bit 7 setAbsolute sample. The output byte is the control byte shifted left once.
Bit 7 clear, bit 6 setRepeat the previous sample by the low six-bit count.
Bits 7 and 6 clearAdaptive delta block selected by the high nibble with step size (low nibble + 1).

Delta mode 1 uses one-bit codes and the signed table [-step, +step]. Mode 2 uses two-bit codes and starts at -2 * step. Modes 0 and 3 use four-bit codes and start at -8 * step. Each table advances by step, skips zero, and keeps the executable’s signed-byte wraparound. Packed codes are read most significant bits first. Each delta is added to the preceding output and clamped to the inclusive range 0 through 255.

Across the archive, exact decoding encounters:

Command typeCount
Absolute sample71,094
One-bit delta block2,125
Two-bit delta block1,185
Four-bit delta block6,533
Run-length command1,699

Every member produces its declared sample count and consumes its compressed input exactly, with no unused trailer.

QEMU validation of D003.ABT

The startup sequence plays D003.ABT during the logo. QEMU was launched with the visible Cocoa display, silent audio backend, and GDB remote debugging. A breakpoint at physical address 0xA499 stopped the process at 0627:4229, immediately before the decoded effect is submitted to interrupt 66h.

The live state structure at DS:A0DE contained buffer pointer 5A45:0000, sample count 0x2368 (9,064), callback 14E1:79EC, and rate 0x2328 (9,000). Dumped 9,064 bytes from physical address 0x5A450. The live buffer and independently decoded host output are byte-for-byte identical:

ca97ad22acf3cc39d078b619168fa026deb1606082999bfb8b9a1aac4957422b

This validates the header, every command family exercised by D003.ABT, code bit order, signed delta table, clamping behavior, and final sample count against the running DOS program.

Converting ABT to WAV

Inspect an effect or write standard unsigned eight-bit mono PCM:

tools/convert_abt.py build/dd1/all/306_D003.ABT
tools/convert_abt.py \
  build/dd1/all/306_D003.ABT \
  --output build/audio/d003.wav

The decoder rejects truncated commands, output overruns, invalid block geometry, sample-count mismatches, and unused input bytes. The WAV rate comes from the ABT header rather than being hard-coded.

XMI: IFF/XMIDI music

The archive has matching numeric families MUS001.XMI through MUS016.XMI and IBM001.XMI through IBM016.XMI. Every file has two top-level IFF containers. IFF sizes are big endian, while the two-byte sequence count in INFO is little endian:

FORM XDIR
  INFO  01 00
CAT  XMID
  FORM XMID
    TIMB
    EVNT

All files declare exactly one sequence and contain one FORM XMID. TIMB is an even-length list of two-byte patch/bank pairs. EVNT uses XMIDI’s MIDI-like event representation:

  • bytes below 0x80 before an event are additive delay values;
  • channel statuses carry fixed-size parameters;
  • note-on events add a variable-length duration after note and velocity;
  • system-exclusive and meta events use variable-length payload sizes; and
  • meta event FF 2F 00 ends the track.

Eleven EVNT chunks include one zero padding byte after end-of-track. One track also contains controller parameter 0xFF, so the validator preserves the game data instead of imposing a generic seven-bit MIDI parameter check.

The 32 sequences contain 7,087 events, including 6,608 duration-bearing note events and 173 TIMB entries. MUS001.XMI, loaded during startup, contains 12 timbres, 446 events, 432 notes, eight meta events, and total additive delay 3,016.

Inspect and validate a sequence with:

tools/inspect_xmi.py build/dd1/all/267_MUS001.XMI

The tool validates container sizes and padding, directory counts, form and chunk order, event boundaries, variable-length quantities, and end-of-track.

Executable routines

Load offsetCurrent nameEvidence
0x4091play_music_resourceBuilds MUS###.XMI or IBM###.XMI, loads it, and starts the music driver.
0x4155release_sound_effect_bufferReleases the retained decoded PCM allocation.
0x417Fplay_sound_effect_resourceStops/releases the preceding sample, builds D###.ABT, decodes it, and asks DIGPAK to preformat the retained PCM state without starting it.
0x4235start_prepared_sound_effectStops any active instance, resets completion state, and submits the retained preformatted sample for playback.
0x92D0abt_get_sample_rateReturns the little-endian word at ABT offset 2.
0x92E0decode_abtReads the header and dispatches absolute, run, and delta commands.
0x93BEabt_decode_1bit_delta_blockExpands eight one-bit delta codes per byte.
0x94CBabt_decode_2bit_delta_blockExpands four two-bit delta codes per byte.
0x956Eabt_decode_4bit_delta_blockExpands two four-bit delta codes per byte.

Offsets use the unpacked load-module convention documented elsewhere in this book.

The separate sound-driver chapter maps the DIGPAK/MIDPAK interrupt interface, the exact playback structure receiving decoded ABT data, and the installed OPL timbre library.

The Rust SDL frontend parses the same EVNT stream at XMIDI’s 120 Hz timebase and renders it on a music stream separate from digital effects. Its portable two-operator synthesizer uses the transpose, operator multiplier, total-level, envelope, waveform, and connection fields from the original SOUND.4 timbres. It intentionally approximates the installed driver’s analog OPL output rather than emulating a YM3812 chip cycle by cycle. Completed PCM is cached per music identifier and queued repeatedly until a stop or replacement request.

An exact event inventory of the 16 MUS resources found only note-on, program-change, and controller events—no pitch-bend or embedded XMIDI loop controllers. Controller 0 selects banks, controller 7 sets volume, and controller 10 supplies stereo pan which the mono OPL renderer may ignore. MUS016 additionally contains standard RPN/NRPN setup events but no pitch bends for those settings to modify. Percussion uses bank 0x7F; its directory patch selects the drum timbre and the timbre record’s signed note byte supplies the oscillator pitch.

Sound Drivers and MIDPAK Timbres

Installed driver set

SETSOUND.BAT preserves the original installer names used to create the otherwise anonymous SOUND.* files:

Installed fileBytesInstaller sourceRole
SOUND.14,824soundrv.comDIGPAK Sound Blaster 16 resident driver, version 3.40.
SOUND.216,263midpak.advMiles/Creative Sound Blaster Pro FM MIDPAK hardware driver.
SOUND.313,312tmidpak.comMIDPAK loader and resident service, version 3.0 family.
SOUND.43,622midpak.adAudio Interface Library global OPL timbre library.
SOUND.54installer outputGame configuration and Bible-translation lock words.

The identities do not depend only on the batch file. SOUND.1 contains the signatures DIGPAK, Sound Blaster 16, John W. Ratcliff’s credit, and a version 3.40 identification string. SOUND.3 contains MIDPAK, MIDI Sound Package, and the Audio Solution credits. SOUND.2 begins with a Miles Design copyright string and identifies Creative Labs Sound Blaster Pro FM internally.

Interrupt interface

Both resident packages hook software interrupt 66h. Captain Bible contains 34 literal CD 66 instructions: 31 in small API wrappers, one in the driver detection routine, and two in bootstrap paths. The request number is the complete AX value. The recovered names agree with the contemporary Ralf Brown Interrupt List’s INT 66 catalog.

DIGPAK wrappers

Load offsetAXRecovered wrapperContract
0x8F080688digpak_play_8bit_soundPlay the DS:SI sound structure.
0x8F280689digpak_report_statusReturn 0 idle or 1 playing in AX.
0x8F2E068Adigpak_preformat_soundConvert the DS:SI structure for the output device.
0x8F41068Bdigpak_play_preformatted_soundPlay the preformatted DS:SI structure.
0x8F540694digpak_play_preformatted_dataNewer preformatted-data entry point.
0x8F67068Cdigpak_report_capabilitiesReturn capability mask, fixed rate, and ID pointer.
0x8F6D068Cdigpak_get_driver_idCopy the NUL-terminated ID returned in BX:CX.
0x8F95068Ddigpak_report_current_sample_addressReport approximate playback position.
0x8F9B068Edigpak_set_callback_addressSet callback BX:DX and callback DS.
0x8FAD068Fdigpak_stop_current_soundTerminate current digital playback.
0x8FB30690digpak_set_audio_hardwareSet IRQ, base address, and device value.
0x8FC80691digpak_report_callback_addressReturn callback and original caller DS.
0x8FCE0689digpak_wait_until_idlePoll status until AX becomes zero.
0x8FD80695digpak_post_audio_pendingStart or queue a sound structure.
0x8FEB0696digpak_get_audio_pending_statusReport playing/pending combination.
0x8FF10697digpak_set_stereo_panningSet DX from 0 right through 127 left.

The documented DIGPAK sound structure is 12 bytes: a far audio-data pointer, 16-bit byte count, far playback-status pointer, and 16-bit frequency. This exactly explains the live structure at DS:A0DE used by play_sound_effect_resource. The game first invokes 068A to preformat the independently decoded ABT PCM and then 068B to play it.

MIDPAK wrappers

Load offsetAXRecovered wrapperContract
0x8CFC0701midpak_get_digitized_capabilitiesProbe the companion digital driver.
0x8C7A0702midpak_play_sequencePlay sequence BX.
0x8C890703midpak_segue_sequenceSegue to BX using activation CX.
0x8C9B0704midpak_register_xmidiRegister data at CX:BX, length DI:SI.
0x8CB70705midpak_stop_midiStop MIDI playback.
0x8CBD0706midpak_remap_channelObsolete sequence/channel remap.
0x8D020707midpak_report_trigger_countReturn trigger count and ID.
0x8D080708midpak_reset_trigger_countReset the trigger counter.
0x8D0E0709midpak_sleepObsolete MIDI sleep.
0x8D14070Amidpak_awakeObsolete MIDI wake.
0x8D1A070Bmidpak_resumeResume playback.
0x8D20070Cmidpak_get_sequence_statusReturn stopped, playing, or done.
0x8D3E070Dmidpak_register_xmidi_fileRegister the filename at CX:BX.
0x8D26070Emidpak_get_relative_volumeReturn relative volume.
0x8D2C070Fmidpak_set_relative_volumeSet BX volume over CX time.

The bootstrap call at 0xABBB uses service 0710: BX is the segment of the loaded SOUND.2 .ADV, CX is zero, and DX:SI points to SOUND.4. This matches the MIDPAK load-driver contract exactly. play_music_resource then stops playback, sets volume, brackets registration with sleep/wake, registers the XMI byte buffer with 0704, and plays sequence zero with 0702.

The options path performs a separate stop/restart lifecycle. Turning music off calls the stop wrapper; turning it back on wakes MIDPAK and starts sequence zero from the retained registration. play_music_resource returns immediately when asked for the already current identifier, and identifier zero stops without registering another resource.

The outer game loop at 0x8C35 calls play_music_resource(0) every time main_menu_and_game_loop returns. A pending scene name or state flag 40 makes the inner loop return through 0x8740, so normal scene transitions stop music and clear the current identifier before the successor is initialized. This matters even when two consecutive scenes request the same music number: the second request is no longer rejected as an already-current no-op.

The order of checks in play_music_resource gives the disabled case different semantics. It compares the requested and current identifiers first, then returns before updating the identifier when the music enable word at 0x48 is zero. A transition while muted consequently retains the preceding registration. This is consistent with the options path, which can restart that registration when music is turned on again.

QEMU call and return evidence

The QEMU plugin now recognizes both int 21h and int 66h and reads the live EAX register at entry and return. QEMU represents the first x86 register with opaque handle value zero; treating that value as a valid handle was necessary to recover result AX rather than falling back to instruction inference. Return records also include the other general and segment registers.

A visible, host-muted startup capture produced this capability exchange:

CALL ... pc=0627:ABED int=66 AX=068C ...
RET  ... from=0627:ABED int=66 AX=0FC1 BX=2E88 CX=010C ...
         result="Sound Blaster 16"

The same query through wrapper 0x8F67 returns the same mask and ID pointer. Service 0710 returns AX zero after loading the FM driver and timbres. Observed DIGPAK status calls return both 0 and 1, matching idle and playing; MIDPAK 070C returns 1 while the title sequence is active. All captured driver returns have carry clear.

The current bounded trace contains 159 DOS calls and 7,844 driver calls. Most driver traffic is deliberate polling: 7,831 calls to DIGPAK status while the slowed one-instruction translation mode runs. Its ignored artifact is build/qemu-trace/dos-calls.log, SHA-256 ae9d9b5952171f35d9dff8a75548e399a15fb01ee27d4058b2800891766404a6.

SOUND.4 global timbre library

The file consists of a six-byte directory entry repeated until FF FF, then the timbres at their absolute offsets:

Directory offsetSizeMeaning
+01MIDI patch number.
+11Bank; 0x7F denotes percussion.
+24Absolute little-endian timbre offset.

The terminator ends at 0x440, which is also the first timbre offset. There are 128 bank-zero melodic entries for patches 0 through 127 and 53 percussion entries for notes 35 through 87 in bank 0x7F.

Every timbre is exactly 14 bytes and every directory offset is contiguous:

Timbre offsetSizeMeaning
+02Record length including this word: 14.
+21Signed melodic transpose, or percussion note.
+35Modulator OPL registers: AVEKM, KSL/TL, attack/decay, sustain/release, waveform.
+81Feedback/connection register.
+95Carrier registers in the same order.

This layout agrees byte-for-byte with the AIL structures recovered from Miles source by OPL3BankEditor’s pinned AIL parser. No padding or unreferenced trailer remains: 181 * 14 bytes extend from 0x440 to the exact 3,622-byte end of file.

Validate or list the bank with:

tools/inspect_midpak_ad.py CB/SOUND.4
tools/inspect_midpak_ad.py CB/SOUND.4 --list

The parser rejects a missing directory terminator, out-of-range or duplicate patches, a gap or overlap between records, unsupported record lengths, and a truncated final record.

Text Resource Formats

Captain Bible divides its study and gameplay text between translation-specific verse indexes inside DD1.DAT and translation-independent companion files named DDLA through DDLG and DDLR. The executable joins them at runtime.

Resource families

The first character of an extensionless archive name selects a Bible translation:

PrefixTranslationCommand-line letterRuntime index
KKing James VersionK0
NNew International VersionN1
RRevised Standard VersionR2
TThe Living BibleT3

The second character is a bank letter A through G or R. It selects the companion file with the same suffix, such as resource NA plus file DDLA. There are 319 logical verse records in each translation:

BankVerse recordsCompanion bytesTerminal offset
A4710,0650x2751
B408,2530x203D
C464,0200x0FB4
D4614,9730x3A7D
E4210,4890x28F9
F4610,2570x2811
G449,9930x2709
R86960x02B8

The archive contains 33 rather than 32 extensionless members because NG appears at directory indexes 199 and 206. Their expanded bytes are identical.

Extensionless verse index

Each ordinary index record has a three-byte header followed by text:

OffsetSizeMeaning
0x001Nonzero selector byte used by scene logic.
0x012Little-endian offset into the companion DDL file.
0x03variableNUL-terminated CP437 `citation

The final record is only 00 plus a little-endian terminal offset. That offset equals the companion file’s exact byte length and lets the game derive the final ordinary record’s span. Offsets are nondecreasing rather than strictly increasing: repeated offsets represent verses with no associated companion records.

The selector is an exact lookup key, not a record type. Function find_text_record_by_selector linearly compares it against the loaded table. Selectors 0xE0 and above mark mature-topic records. They are filtered by the study exporter and by world-map content selection, not by the runtime text loader.

DDL companion stream

Every companion record is an ASCII tag byte followed immediately by a NUL-terminated CP437 string. There is no file header or terminal marker; index offsets and the file size delimit the relevant ranges.

TagCount in all eight filesGame export heading
L277CYBER LIE:
P123PARAPHRASE:
W210WRONG GUESS:
C67CORRECT GUESS:
E68EXPLANATION OF CORRECT GUESS:
*58CONVERSATION WITH VICTIM:
M255Numeric or internal metadata; not printed by the exporter.

All eight files contain 1,058 tagged records. Of these, 1,057 fall in indexed verse spans. DDLF begins with one 26-byte E preamble before its first index offset; the game does not associate it with a verse. Some verse spans contain several records with the same tag, particularly multiple wrong guesses or metadata values.

The built-in -gXX export mask connects the two formats. Bit 1 prints L, bit 2 prints P, bit 3 prints *, bit 4 prints W, C, and E, and bit 5 prints the citation and verse from the index. Bit 0 prints the numbered record heading. M is consumed by gameplay helpers but omitted from study output.

Executable implementation

load_text_bank at load offset 0x629C constructs the two-character archive name from the selected translation and requested bank. It loads the extensionless resource and turns every index record into a ten-byte runtime descriptor containing a far text pointer, selector, companion offset, and span. It does this for selectors 0xE0 and above even under the supplied no-mature installation policy, then opens the corresponding DDL file for random access. Supplied no-mature save files independently confirm the full descriptor counts and original ordinals.

Load offsetCurrent nameRole
0x5AD6find_text_record_by_selectorLinearly find the runtime descriptor with a requested selector.
0x5CE2copy_text_record_componentReturn the verse or a selected tag occurrence from its companion span.
0x5EE7write_wrapped_export_textWrap study text at 70 columns and write it to the export stream.
0x5F92export_game_textApply the -gXX mask and emit the complete study file.
0x629Cload_text_bankLoad and join one verse index and DDL bank.

The loader reads the next record’s offset to compute each span, including the terminal zero record for the last span. copy_text_record_component seeks to that offset in the open companion file, reads exactly the computed span, and walks its tagged NUL strings.

Conversation opcode 0x7D connects this lookup machinery to the interactive study browser. Its prompt byte selects companion tag * for victim dialogue, P for a paraphrase, or L for a cyber lie, while its variable operand supplies the expected text-record selector. Prompt bytes zero and nine suppress the text. The browser resolves the configured selector directly, regardless of whether that record has been obtained; acquisition state controls the list of answers, not the adversary’s statement. A matching selection returns through state flag 0x14; leaving without the match returns through flag 0x15. The conversation-flow chapter documents the surrounding BIN control flow, prompt-rendering geometry, and QEMU-validated menu system.

QEMU export validation

Ran the original executable’s built-in exporter in a visible, silent FreeDOS QEMU session with -g63 -sTSTUDY.TXT. The installed SOUND.5 configuration contains translation index 1, so it enforced NIV despite the requested T, as the manual warns an installation lock may do.

The game produced a 132,510-byte file with SHA-256 c9ebe2cc4fbd00cd709d87761b38f6a8843eae99ceaa75cef842b93364dad0bc. After normalizing the exporter’s line wrapping, all 302 emitted verses match the parsed N resources exactly. The only 17 verses absent from the export have selectors 0xE1 through 0xE4; these are precisely the records skipped by the exporter’s active mature-topic check. They remain present in the runtime descriptor table. The export also reproduces the parsed L, P, W, C, E, and * companion strings under their documented headings.

Inspection tool

tools/inspect_text_resources.py validates both formats, joins index spans to their companion records, and displays the game-facing result. For example:

tools/inspect_text_resources.py \
  CB/DD1.DAT --data-dir CB \
  --translation N --bank A --record 0

Omit --record to display the complete bank. Translation choices are K, N, R, and T; bank choices are A through G and R.

Save-Game Formats

Captain Bible keeps one nine-entry label index and up to ten independent game states per player prefix. The executable contains no file header, version number, checksum, or compression for either format. Parsing therefore depends on the exact file sizes and fixed block order recovered from the read and write routines.

Player prefixes and filenames

At startup, game_main copies DDGAMES into the active prefix. Every command line argument that does not begin with - replaces it using strcpy. Both save routines then construct a filename by copying that prefix to a local buffer and appending a mutable static suffix initially containing .SV0.

UseSuffixExample with default prefix
Slot-label index.SV0DDGAMES.SV0
Normal slots.SV1 through .SV9DDGAMES.SV4
Quick save.SVQDDGAMES.SVQ

The save-slot menu writes the selected index plus ASCII 1 into the suffix. The main event loop writes Q before an F10 quick save or F9 quick load and restores 0 afterward. Thus quick save is a tenth state without an entry in the nine-label index.

Scene opcode 0x8D reuses this exact active-prefix plus mutable-suffix construction. Its sole shipped site is TITLE.BIN:0x012C, while the suffix is .SV0, so it probes the player’s label index in rb mode. Its missing-file target 0x012F equals the next command, so the shipped title program enters INTRO whether the open succeeds or fails.

The manual requires an extensionless legal DOS name of at most eight characters, or permits a complete path such as d:\players\jimmy. That is a user-facing constraint, not executable validation: the parser copies the argument verbatim and does not check its length, characters, path, or number of non-option arguments. A player prefix separates only filenames; it is not stored inside the state.

SV0 label index

The index is exactly 243 bytes: nine consecutive 27-byte character buffers. Each buffer is interpreted as a CP437 C string.

offset = (slot - 1) * 27

+0x00  char label[27]

If the file cannot be opened, read_save_index initializes all nine buffers with strcpy("(EMPTY)"). Because strcpy stops after the first NUL and does not clear the rest of a 27-byte destination, bytes after that NUL are not part of the label and can retain old data. The supplied index demonstrates this: every visible label is EMPTY, but eight records have nonzero stale-tail bytes. The supplied spelling also lacks the parentheses in the executable’s literal, so it was likely prepared or sanitized separately.

The game writes the complete 243-byte array whenever it writes a state. It does not include slot labels in .SV1 through .SV9 themselves.

Normal-save name editing uses a 27-byte local buffer and permits at most 26 characters. The input routine at 0x2C8B accepts ASCII 20..3B, 3F..5A, and 61..7A, except for ampersand and asterisk. It recognizes Backspace, Enter, and Escape; the blocking routine has no mouse-confirmation path. A selected (EMPTY) label begins as an empty edit field; after Enter, the helper at 0x815A replaces a still-empty label with Game 1 through Game 9 before the index is written.

State-file layout

write_save_state emits 15 fwrite calls totaling exactly 2,752 bytes. read_save_state performs corresponding fread calls in the same order. All nine supplied state files have this size.

File offsetSizeDS offsetInterpretation
0x0002007BF2Checkpoint copy of 100 script-variable words
0x0C8200727ALive 100-word script state
0x190663A66Checkpoint copy of descriptor state bytes
0x1D2660B19466 live ten-byte text descriptors
0x466206EA6Checkpoint resource-name C-string buffer
0x47A20B83ELive resource-name C-string buffer
0x48E208938Checkpoint extension C-string buffer
0x4A220AEFELive extension C-string buffer
0x4B62007CBible translation index
0x4B820048Music enabled flag
0x4BA2004ASound-effects enabled flag
0x4BC29FB0Checkpoint text-bank character
0x4BE20080Live text-bank character
0x4C07685B16Live 16×16×3-byte world map
0x7C076876ECCheckpoint copy of the world map
Total2,752

“Checkpoint” is based on executable copy direction, not just similarity. At new-session initialization and a scene-bytecode checkpoint command, copy_live_state_to_save_buffers copies the live 200-byte block, each descriptor’s byte at offset 4, the two C strings, current text-bank word, and all 16×16 three-byte table cells into the checkpoint buffers. The inverse routine restores those fields and reloads the selected text bank. The normal and quick save paths serialize both versions; they do not take a fresh checkpoint immediately before writing.

The script-state chapter documents the primary block’s 100 signed words, embedded 128-bit flag bank, identified variables, powerups, victim flags, and interpreter commands. The world-map chapter documents the table’s row-major cell layout, packed connection/location byte, scene commands, exploration state, and archive-resource correlation. The detailed semantics of the primary state and several map kinds and parameters remain open.

Runtime text descriptors

The 660-byte block is 66 records of ten bytes:

Record offsetSizeMeaning
+02Far-pointer offset
+22Far-pointer segment
+41Persistent gameplay state byte
+51Text-record selector
+62Offset in the companion DDL stream
+82Span in the companion DDL stream

The preceding 66-byte block is exactly the checkpoint copy of byte +4 from each descriptor. The text-bank loader reconstructs the process-dependent far pointers and structural fields after a load while preserving the state byte. Consequently, pointer values observed on disk are not stable format identifiers.

All supplied saves select bank C. Their first 46 descriptor records match the recovered NIV bank C index exactly in selector, DDL offset, and span; the remaining 20 structural records are zero. All 66 state bytes are also zero. This independently connects the save layout to the verse-index format.

Supplied-save observations

Static comparison of DDGAMES.SV1 through DDGAMES.SV9 found:

  • SV6 and SV8 are byte-for-byte identical.
  • 2,477 of the 2,752 offsets are constant across all nine files.
  • The live and checkpoint 16×16 maps match within every file. They are all zero except in SV3 and SV4, which each have 112 nonzero bytes. Those two grids match CE.MAP except for four field mutations, including two kind changes that exactly follow the executable’s map-normalization rule.
  • The checkpoint/live resource strings decode as LOGO, LOGO, seg, and seg. Bytes after their first NUL can be stale and are not semantic.
  • Translation is 1, music and effects are enabled, and both bank values are ASCII C in every file.
  • Each primary block pair differs at byte 56; SV9 additionally differs at byte 54. This is evidence that both serialized blocks matter, not evidence that either copy is damaged.
  • Descriptor far-pointer segments differ in SV9, consistent with addresses being reconstructed runtime state rather than portable identifiers.

Pairwise comparisons alone do not yet assign gameplay meaning to the 275 offsets that vary across this small corpus. Controlled saves after specific game actions are needed for that stage.

Error behavior

The state reader returns failure if the requested state file cannot be opened. Once open succeeds, it issues all 15 reads but does not check their individual return counts before refreshing audio/text state and returning success. The format has no integrity marker, so a truncated or modified file may partially overwrite memory rather than producing a clean format error. The host-side inspector is intentionally stricter.

That open failure has a surprising F9 consequence. The main input loop stores read_save_state’s return value in the top-level mode word. A missing .SVQ returns 1; mode 1 resets the initial scene strings and calls the same new-session initializer as New Game. Thus F9 with no quick save restarts the game while preserving the current option words. Numbered loading is insulated by its selector, which does not offer a missing state as a loadable row.

Inspection tool

tools/inspect_save.py selects the index or state parser by exact size, decodes C-string buffers, reports snapshot differences and settings, and can list live text descriptors:

tools/inspect_save.py CB/DDGAMES.SV0
tools/inspect_save.py CB/DDGAMES.SV3 --descriptors
tools/inspect_save.py CB/DDGAMES.SV9 --variables

The parser rejects every size other than 243 or 2,752 bytes. Its tests cover all ten supplied files, stale label tails, exact snapshot relationships, scalar meanings, script variables and flags, descriptor state copies, and the full NIV bank C structural match.

Relevant executable functions

Load offsetCurrent nameRole
0x2B6Fchoose_save_slotBuild the nine-label menu and change .SV0 to the chosen slot suffix.
0x7D8Ecopy_live_state_to_save_buffersRefresh checkpoint fields from live state.
0x7E41copy_save_buffers_to_live_stateRestore checkpoint fields and reload text.
0x7F01write_save_indexWrite nine 27-byte label buffers.
0x7F58read_save_indexRead the index or initialize (EMPTY) labels.
0x7FD7write_save_stateWrite the index and the 15 state blocks.
0x815Ainitialize_empty_save_slotReplace an empty label with the Game 1 through Game 9 default.
0x81A5save_selected_slotInitialize the selected label, then write the state.
0x81ACread_save_stateRead the index and all 15 state blocks.

Known Gaps and Evidence Boundaries

This book deliberately leaves fields unnamed when the available evidence does not distinguish their semantics. The list below consolidates those boundaries so an unresolved detail in one chapter is not mistaken for a contradiction in another.

Dynamic-capture boundary

Focused keyboard/mouse input, quick and normal saves, representative screens, and all three COMBAT1 runtime table families now have preserved dynamic evidence. The combat capture deliberately used a saved-scene patch to enter COMBAT1, so it validates loaded runtime structures and action execution but not the natural map-to-encounter transition or an ordinary outcome. The startup trace, title/intro memory captures, live dialogue-choice table, scene display records, framebuffer comparison, and decoded D003 PCM remain independent dynamic checks.

Partially named runtime structures

  • The auxiliary word at ABT offset 0x06 is consumed but unused by the game decoder. Its values are known across all resources; its producer-side meaning is not.
  • The opcode-0x02 16-byte interactive/display records and the last two bytes of 12-byte animation slots need finer field names. Animation modes 1 through 10 now have complete start, direction, boundary, and terminal behavior.
  • Combat’s POWER re-entry exists and is decoded, but its complete caller path has not been isolated. Randomized branches are not all labeled by rendered enemy phase.
  • Supplied saves expose 275 varying byte positions. Known state, flags, descriptors, and maps are decoded; pairwise variation alone cannot assign a gameplay meaning to every remaining byte.

Conservative world-map names

All room classes, entrances, Cybers, stations, locks, Spider triggers, cleared encounters, and exits used by recovered gameplay are named. Connected hallway kinds 0xC, 0xD, and 0xF remain numeric because their exact environmental presentation has not been proven. Parameters A and B are named only in contexts where script transitions or map rendering establish their roles.

Static-analysis coverage

The checked catalog contains every project-assigned Rizin name: 140 functions, 134 distinct BIN handler addresses, and 9 data symbols. It is not a claim that the executable has only 140 functions. Rizin proposes roughly 340 candidates, including false merges across data and jump tables; unsupported candidates remain unnamed.

All 145 opcode operand layouts and dispatch effects now have inspector names. Twenty-three values do not occur in shipped scripts, including thirteen from the final unnamed-handler pass, so their names stay close to directly observed state writes and callees. The finer gameplay role of unused opcode 0x1B’s motion-transition latch and unused modal-menu opcode 0x47 remain evidence boundaries rather than reasons to leave their handlers structurally unnamed.

Resolved former gaps

Several statements in early progress entries describe questions that later work answered. Current chapters supersede those chronological notes:

  • DDLA through DDLG and DDLR are tagged companion text streams joined to extensionless verse indexes inside DD1.DAT.
  • POWER.BIN is the in-combat study interface, not a game-over resource.
  • CP2.BIN has a 256-byte 16-node Unibot graph trailer beginning at 0x1D55.
  • Flags 0x42..0x48 are rescued crew present for the Unibot mission, and variables 56 through 62 are the seven energy-pylon results.
  • All 34 game-side interrupt-66h sites are assigned DIGPAK/MIDPAK services, and SOUND.4 is a fully validated 181-entry AIL OPL timbre library.
  • Hall kinds for all seven Cybers, the hidden Spider, stations, cleared combat, and exits are correlated with their scripts and manual identities.
  • BIOS keyboard and mouse polling are captured at the named game call sites; F10 and normal menu saves are joined to their exact guest writes.
  • The live COMBAT1 action table matches all four static targets, and all 33 captured animation records match their static first-step and interval fields.
  • Representative startup, story, exploration, study, map, faith, menu, save, combat, and defeat screens were exercised in visible, silent QEMU.
  • The complete dispatch audit establishes 145 opcode values, 134 distinct handlers, and exact operand-reader paths. Pointer-capable strings are now distinguished from inline-only names.
  • Opcode 0x8D constructs the active player’s save-index filename. Its only shipped use tests .SV0, and its branch target equals its fallthrough.

The append-only progress log is intentionally historical and can contain an earlier hypothesis followed by its correction. Format and system chapters are the current reference.

Progress Log

This is an append-only narrative of project actions. Commands are shown as run from /Users/peter/ai/captain-bible-re unless another directory is specified. Routine documentation edits are described by filename rather than reproducing the patch payload.

2026-07-15

Initial inventory

Reported to the user that the first pass would be limited to a reproducible FreeDOS/QEMU setup, a playable run.sh, documentation scaffolding, and a boot and launch check before pausing for their test.

Ran:

pwd
rg --files -g 'AGENTS.md' -g '!CB/**' -g '!docs/**' | sort
find CB -maxdepth 2 -type f -print | sort | sed -n '1,240p'
command -v qemu-system-i386
qemu-system-i386 --version
command -v qemu-img
qemu-img --version
command -v rizin
rizin -v
command -v mdbook
mdbook --version

Results:

  • Confirmed the workspace path and the repository-level AGENTS.md.
  • Found CB.EXE, CB.PIF, CB.ICO, DD1.DAT, eight extensionless DDL* files, ten initial DDGAMES.SV* save files, SOUND.1 through SOUND.5, SETSOUND.BAT, MANUAL.TXT, and a large collection of sound drivers.
  • Found QEMU 11.0.2, Rizin 0.9.1, and mdBook 0.5.3.

Ran:

git status --short --branch
du -ah CB | sort -h | tail -40
file CB/CB.EXE CB/CB.PIF CB/CB.ICO CB/DD1.DAT CB/DDLA CB/SOUND.1
sed -n '1,240p' CB/MANUAL.TXT
sed -n '1,160p' CB/SETSOUND.BAT

Results:

  • The working tree was initially clean on main.
  • CB/ occupies approximately 2.7 MB. DD1.DAT is approximately 1.8 MB, the sound-driver directory is approximately 672 KB, and CB.EXE is approximately 64 KB.
  • The host file utility recognizes CB.EXE as an MS-DOS executable and CB.PIF as a Windows Program Information File.
  • The manual says the normal DOS launch is CB from the installed directory and claims a conventional-memory requirement of approximately 500 KiB.
  • SETSOUND.BAT runs separate digital-audio and MIDI configuration programs, then copies the selected driver artifacts to SOUND.1 through SOUND.4.

FreeDOS source selection

Looked up the official FreeDOS download and installation documentation. The official site identifies FreeDOS 1.4 as the stable release and recommends the LiveCD when installing in a virtual machine. It publishes a 20 MB minimum disk requirement. The official download page links to:

https://download.freedos.org/1.4/FD14-LiveCD.zip

The first session was interrupted after this read-only research. No VM or repository files had been created at that point.

After the user requested that work continue, reported that the completed actions would be recorded before creating and driving the VM installation.

Ran:

ls -la
curl -fsSL https://www.freedos.org/download/ \
  | rg -o 'href="[^"]*(FD14|LiveCD|live|verify)[^"]*"' \
  | sed -n '1,120p'

Results:

  • Confirmed the repository initially tracks .gitignore, AGENTS.md, and url.txt; the ignored CB/ directory contains the supplied game.
  • Extracted the official FreeDOS 1.4 LiveCD ZIP URL shown above.

Ran:

sed -n '1,120p' .gitignore
sed -n '1,120p' url.txt
git log -5 --oneline --decorate
git ls-files

Results:

  • .gitignore already excludes /CB/ and /build/, which is suitable for keeping original game files, downloaded media, VM disks, and book output out of version control.
  • url.txt points to an archived copy of the game.
  • The repository contains one initial commit on main.

Documentation scaffolding

Created PLAN.md, README.md, docs/book.toml, and the initial sources under docs/src/. The plan separates environment setup, static analysis, dynamic analysis, game-system research, and final consolidation. Configured mdBook to write generated HTML to the ignored build/docs-book/ directory.

Superseded installer attempt

Downloaded the official FreeDOS 1.4 LiveCD ZIP to build/downloads/FD14-LiveCD.zip and verified it against the official SHA-256 value:

2020ff6bb681967fd6eff8f51ad2e5cd5ab4421165948cef4246e4f7fcaf6339

Extracted FD14LIVE.iso and observed its SHA-256 as c48a9dcf4b8e22f44e268a9879745f0bd88c061195ac584e6ef2deb0477f81fb. Created a sparse 512 MiB QCOW2 disk with:

mkdir -p build/downloads build/vm build/screenshots
curl -fL --progress-bar \
  https://download.freedos.org/1.4/FD14-LiveCD.zip \
  -o build/downloads/FD14-LiveCD.zip
printf '%s  %s\n' \
  '2020ff6bb681967fd6eff8f51ad2e5cd5ab4421165948cef4246e4f7fcaf6339' \
  'build/downloads/FD14-LiveCD.zip' | shasum -a 256 -c -
unzip -l build/downloads/FD14-LiveCD.zip
unzip -jo build/downloads/FD14-LiveCD.zip FD14LIVE.iso \
  -d build/downloads
shasum -a 256 build/downloads/FD14LIVE.iso
qemu-img create -f qcow2 build/vm/freedos.qcow2 512M
qemu-img info build/vm/freedos.qcow2

Started QEMU headlessly with its monitor on standard input, captured screen dumps, and injected keys through the monitor. Selected the hard-disk installer, English, partitioned the disk, rebooted, selected the installer again, formatted the new FAT16 partition, chose a US keyboard and the plain DOS package set, and began installation. This approach was not completed.

The user then supplied FREEDOS_SETUP_INSTRUCTIONS.md, which explicitly requires a simpler, noninteractive construction method and prohibits driving the installer. Read that file in full, then read all 321 lines of its reference implementation at:

/Users/peter/ai/agi/reverse/tools/setup_freedos_image.py

Confirmed that mformat, mcopy, mmd, mdir, and mtype are installed. Stopped the installer VM through its QEMU monitor with quit. Removed only the superseded generated VM artifacts and installer download:

rm -rf build/vm build/screenshots
rm -f build/downloads/FD14-LiveCD.zip build/downloads/FD14LIVE.iso

Noninteractive image builder

Created tools/setup_freedos_image.py, adapted from the supplied reference while removing its project-specific imports, game-copy functionality, and custom VGA BIOS support. The builder:

  • accepts every required command-line option;
  • downloads through a .download path and publishes only a verified archive;
  • extracts the largest source image and validates its MBR and boot sector;
  • preserves the source MBR boot code while defining the requested partition;
  • creates and formats the target atomically through a temporary image;
  • copies the complete source filesystem; and
  • replaces both boot scripts with the required direct-to-prompt commands.

Created tests/test_setup_freedos_image.py with focused checks for the MBR signature, preserved boot code, active/type/start/count partition fields, CHS encoding and saturation, and invalid-MBR rejection.

Ran the test and static validation commands:

chmod +x tools/setup_freedos_image.py
python3 -m unittest discover -s tests -v
python3 -m py_compile \
  tools/setup_freedos_image.py tests/test_setup_freedos_image.py
tools/setup_freedos_image.py --help
git diff --check

All nine unit tests passed. The script compiled, exposed all required CLI options, and the Git whitespace check passed.

Build and verify the base image

Ran:

tools/setup_freedos_image.py --print-mtools-image

The builder downloaded the prescribed archive, selected FD14LITE.img, and created build/freedos/freedos.img. It printed the partition-aware path build/freedos/freedos.img@@1048576.

Ran:

shasum -a 256 build/downloads/FD14-LiteUSB.zip
stat -f 'logical_size=%z bytes' build/freedos/freedos.img
du -h build/freedos/freedos.img
tools/setup_freedos_image.py --print-mtools-image
mdir -i 'build/freedos/freedos.img@@1048576' ::/
mtype -i 'build/freedos/freedos.img@@1048576' ::/AUTOEXEC.BAT
mtype -i 'build/freedos/freedos.img@@1048576' ::/FDAUTO.BAT

Results:

  • The archive SHA-256 was exactly 857dcd2ebf9d3d094320154db5fb5b830acba6fb98f981a95a0ca7ab3350338b.
  • The raw image’s logical size is 1,073,741,824 bytes and its initial physical allocation was approximately 37 MiB.
  • The root directory contains KERNEL.SYS, COMMAND.COM, both boot scripts, FDCONFIG.SYS, and the FreeDOS and package trees.
  • Both boot scripts contain the required five lines with DOS CRLF endings.

Used a short standard-library Python inspection to decode the first partition entry. It found MBR signature 55aa, active status 80, type 0e, first LBA 2048, and 2,095,104 sectors.

Screenshot-free QEMU boot smoke test

Made a temporary copy-on-write clone of the base disk and replaced both boot scripts on that clone with the required script plus:

ECHO FREEDOS_READY>C:\BOOT.OK

Confirmed BOOT.OK did not exist before boot. Started the clone with:

qemu-system-i386 \
  -name 'FreeDOS smoke test' \
  -machine pc,accel=tcg \
  -cpu pentium \
  -m 16 \
  -boot c \
  -drive file=build/freedos/freedos-smoke.img,format=raw,if=ide,index=0,media=disk \
  -display none \
  -monitor stdio

Allowed five seconds for boot, stopped QEMU through its monitor with quit, then ran:

mdir -i 'build/freedos/freedos-smoke.img@@1048576' ::/BOOT.OK
mtype -i 'build/freedos/freedos-smoke.img@@1048576' ::/BOOT.OK

The marker existed and contained FREEDOS_READY. Removed the smoke-test clone and temporary script afterward. The deliverable base image was never mounted with mtools while QEMU was running.

Play-image and QEMU configuration

Inspected QEMU’s available display, audio, Sound Blaster 16, AdLib, and machine options. The Homebrew build supports Cocoa display and CoreAudio, plus ISA Sound Blaster 16 and AdLib devices. Inspected the manual’s controls section; the game supports mouse input as well as cursor keys, Space, Enter, function keys, and letter shortcuts.

Inspected the base FreeDOS filesystem and found that CuteMouse was not installed, but PACKAGES/BASE/CTMOUSE.ZIP was present. Copied that archive to a temporary host directory, listed its members, confirmed BIN/CTMOUSE.EXE, and removed the inspection copy. Ran strings against the supplied sound files and found that SOUND.1 is configured for Sound Blaster 16 and SOUND.2 for Sound Blaster Pro FM sound.

Created tools/captain-bible-autoexec.bat and run.sh. The run script keeps the verified base disk unchanged and atomically prepares a persistent derived disk at build/captain-bible/captain-bible.img. It copies the game, extracts and installs CuteMouse, patches both boot paths to run the game, and starts QEMU with suitable emulated hardware. Added --setup-only, --rebuild, and --help options.

Ran:

chmod +x run.sh
bash -n run.sh
./run.sh --help
./run.sh --rebuild --setup-only

The first setup attempt failed safely while copying CuteMouse because the actual LiteUSB system directory is C:\FREEDOS, not C:\FDOS. The temporary image cleanup trap ran, so no partial play image was published. Corrected the derived disk’s destination and PATH to C:\FREEDOS, leaving the base image’s required boot-script text unchanged, then reran the setup successfully.

Verified the resulting play disk with stat, du, mdir, and mtype. CB.EXE, CTMOUSE.EXE, AUTOEXEC.BAT, and FDAUTO.BAT were all present; the clone remained sparse at approximately 42 MiB physical allocation.

Game launch check

Booted the play image for a bounded launch check with:

qemu-system-i386 \
  -name 'Captain Bible launch check' \
  -machine pc,accel=tcg \
  -cpu pentium \
  -m 16 \
  -boot c \
  -drive file=build/captain-bible/captain-bible.img,format=raw,if=ide,index=0,media=disk \
  -vga std \
  -audiodev coreaudio,id=audio0 \
  -device sb16,audiodev=audio0 \
  -device adlib,audiodev=audio0 \
  -display none \
  -monitor stdio

No device conflicts or QEMU errors occurred. After five seconds, captured one screen dump solely as visual launch evidence, stopped QEMU with quit, and converted the dump to PNG. It showed the Captain Bible title screen rendered at 640×400. This visual check was not used to automate FreeDOS setup; the base boot proof remained the marker-file test described above.

The user then requested that game runs always be visible with -display cocoa,zoom-to-fit=on. Updated run.sh to use that exact display option for macOS game launches and recorded the behavior in the user and book documentation. Headless QEMU remains limited to the automated base-image boot smoke test, which does not run the game.

Final Phase 1 validation

Added Python bytecode/cache patterns to .gitignore, because running the unit tests and compiler creates __pycache__ directories that are not research artifacts.

Ran:

python3 -m unittest discover -s tests -v
python3 -m py_compile \
  tools/setup_freedos_image.py tests/test_setup_freedos_image.py
bash -n run.sh
./run.sh --setup-only
tools/setup_freedos_image.py --print-mtools-image
if command -v shellcheck >/dev/null 2>&1; then
  shellcheck run.sh
else
  echo 'shellcheck: not installed (skipped)'
fi
mdbook build docs
test -f build/docs-book/index.html
git diff --check
git status --short --branch
git diff --stat
stat -f '%Sp %N' run.sh tools/setup_freedos_image.py

Results:

  • All nine unit tests passed again.
  • Both Python files compiled.
  • Bash parsed run.sh, and setup-only mode found the prepared images.
  • The builder reported the expected partition-aware image path.
  • shellcheck is not installed, so that optional check was skipped.
  • mdBook generated build/docs-book/index.html successfully.
  • The Git whitespace check passed.
  • Both executable scripts have mode 755.
  • The working tree contains the intended new research/setup files and the user-provided, untouched FREEDOS_SETUP_INSTRUCTIONS.md; no commit was made.

Handoff

Reported that Phase 1 is ready for the user’s interactive test. The handoff directs them to run ./run.sh, notes the visible Cocoa zoom-to-fit display and persistent play image, summarizes the successful base boot marker and title screen checks, and identifies interactive input, audio, and clean exit as the remaining user confirmations. Reverse-engineering analysis is paused until the user says to proceed.

Phase 1 commit

At the user’s request, reviewed the final working-tree status, the ignore-file change, every new project file, and the complete user-provided FREEDOS_SETUP_INSTRUCTIONS.md. Prepared to stage the full Phase 1 source and documentation set while leaving all ignored downloads, raw disks, screenshots, book output, game files, and Python caches out of version control. The commit uses the repository’s required imperative subject and a detailed, wrapped body that explains the reproducibility and documentation rationale.

Committed Phase 1 as 30b34c8 with subject chore: Add FreeDOS game environment. Verified afterward that the working tree was clean and every stored commit-message line was at most 72 characters.

Copy game into the base disk

The user requested that the game be copied into the disk image. First confirmed that no QEMU process was running, because mtools must not access an image while the VM is using it. Inspected both 1 GiB images. The persistent play image already contained CB.EXE, DD1.DAT, and the sound-driver tree at C:\CB, but the base build/freedos/freedos.img did not.

Copied the complete non-hidden host game tree into the base image and performed a content-level verification:

mmd -i 'build/freedos/freedos.img@@1048576' ::/CB
mcopy -s -o -i 'build/freedos/freedos.img@@1048576' CB/* ::/CB/
rm -rf build/copy-verification
mkdir -p build/copy-verification
mcopy -s -i 'build/freedos/freedos.img@@1048576' \
  ::/CB build/copy-verification/
find CB -type f ! -name '.DS_Store' -exec shasum -a 256 {} + \
  | awk '{print $1}' | sort > build/copy-verification/host.sha256
find build/copy-verification/CB -type f -exec shasum -a 256 {} + \
  | awk '{print $1}' | sort > build/copy-verification/image.sha256
cmp build/copy-verification/host.sha256 \
  build/copy-verification/image.sha256

Both manifests contained 96 files, and cmp found no content differences. Used mdir to confirm C:\CB\CB.EXE, C:\CB\DD1.DAT, and C:\CB\DRIVERS\SBLASTER.COM, then removed the temporary extracted tree and manifests. The user can run the base image copy with CD \CB and CB; the normal ./run.sh path still starts the already-populated play image automatically.

The user then reported that after running run.sh they could not see the game on the hard disk. Confirmed QEMU was stopped and inspected the exact play image selected by run.sh. Its root directory contained CB, the directory listing contained all expected top-level game files, and an extracted CB.EXE matched the host executable’s SHA-256 2b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0.

Identified a likely path-expectation mismatch: the game manual names the normal installation directory C:\CBDOME, while the initial play image used C:\CB. Updated run.sh and its auto-start batch file to use the canonical C:\CBDOME path. Also made image preparation tolerate an already-present directory and print both the host disk path and guest executable path before QEMU opens. Planned to rebuild the play disk, copy the canonical directory into the current base disk, remove the old ambiguous C:\CB copies, and verify both images before the user retries.

Confirmed the host mdeltree tool was available and no QEMU process was running. Created C:\CBDOME in the base image, copied the game tree into it, removed the previous C:\CB directory, and rebuilt the play image with:

mmd -i 'build/freedos/freedos.img@@1048576' ::/CBDOME
mcopy -s -o -i 'build/freedos/freedos.img@@1048576' \
  CB/* ::/CBDOME/
mdeltree -i 'build/freedos/freedos.img@@1048576' ::/CB
./run.sh --rebuild --setup-only

Extracted C:\CBDOME from each stopped image into temporary host directories. Compared the sorted SHA-256 content hashes from each image with the host CB/ tree using cmp and process substitution. All three trees contained 96 files, and both comparisons succeeded with no differences. mdir showed CBDOME in the root and CBDOME\CB.EXE in both images. Explicit checks confirmed the old C:\CB directory was absent from both. mtype confirmed that the play image’s FDAUTO.BAT changes to C:\CBDOME and invokes CB.

Performed a bounded, visible launch check using the required Cocoa display:

qemu-system-i386 \
  -name 'Captain Bible corrected path check' \
  -machine pc,accel=tcg \
  -cpu pentium \
  -m 16 \
  -boot c \
  -drive file=build/captain-bible/captain-bible.img,format=raw,if=ide,index=0,media=disk \
  -vga std \
  -audiodev coreaudio,id=audio0 \
  -device sb16,audiodev=audio0 \
  -device adlib,audiodev=audio0 \
  -display cocoa,zoom-to-fit=on \
  -monitor stdio

The visible QEMU window reached the Captain Bible title screen from the new canonical path. Captured one screen dump as evidence, then stopped QEMU cleanly through its monitor with quit. Updated the user documentation to say that run.sh prints the host image and C:\CBDOME\CB.EXE guest path before opening QEMU.

Canonical game-path commit

At the user’s request, reviewed the six modified source and documentation files and confirmed the changes contain the canonical directory fix, reproducible image behavior, verification evidence, and updated plan and usage guidance. Prepared to commit these tracked changes while leaving the corrected generated disk images and launch-check screenshot ignored.

Static-analysis scope and initial fingerprint

After the user asked for static disassembly, reported that the pass would fingerprint the executable, identify packing or overlays before trusting an entry-point disassembly, and keep generated binaries under ignored build/. The user then explicitly asked that QEMU be used for memory dumps and other debugging facilities. Adopted that request as permission to use runtime state to recover and verify the executable, while continuing to derive game logic from static analysis.

Checked repository state, tracked history, current plans and documentation, and the installed analysis toolchain with commands including:

git status --short
git log -3 --oneline
find . -maxdepth 3 -type f | sort
command -v cargo
command -v rizin
command -v qemu-system-i386
sed -n '1,260p' PLAN.md
sed -n '1,260p' docs/src/progress-log.md

The tree was clean before this pass. The current history showed commits 833a81d, 9ade46a, and 3a1d2ec. The earlier log’s Phase 1 commit ID was a transient prior ID; no history-changing command was run during this pass.

Inspected the packed file with stat, file, shasum, xxd, strings, rz-bin, Rizin, and short read-only standard-library Python decoders. Important results were:

  • CB.EXE is 64,299 bytes with SHA-256 2b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0.
  • Its timestamp is 1996-12-24 23:32 in the host’s +0700 zone.
  • The file is a 16-bit MZ executable with a 512-byte header, no outer relocations, and entry 0F79:0010 at file offset 0xF9A0.
  • The packed load module has approximately 7.004 bits/byte of entropy.
  • Strings include the 1988 Microsoft run-time banner, game resources and UI text, R6000 run-time errors, and Packed file is corrupt.

Decoded the 16-byte structure at file offset 0xF990 and disassembled the following stub. The RB signature, reverse B0 fill/B2 copy loop, relocation restoration, and error string identify Microsoft EXEPACK. Recovered:

real_ip       cb5c
real_cs       0000
exepack_size  019b
real_sp       1388
real_ss       1a40
dest_len      1260 paragraphs
signature     4252 ("RB")

Parsed the packed relocation table after the 277-byte stub. It occupies the final 118 file bytes and contains 43 relocations in the standard 16 grouped segments. The full offset list is preserved in executable.md.

Consulted Microsoft’s historical DOS documentation, served by PCjs, for the documented purpose of LINK /EXEPACK, and David Fifield’s EXEPACK page and source for a current independent specification and implementation. Also read the relevant exepack.rs and exe.rs source in full around header parsing, backward decompression, relocation decoding, minimum-allocation adjustment, MZ serialization, and checksum handling.

Visible QEMU post-unpack capture

Started the prepared game disk with the user-required visible Cocoa display, plus a monitor and GDB endpoint:

qemu-system-i386 \
  -name 'Captain Bible memory capture' \
  -machine pc,accel=tcg \
  -cpu pentium \
  -m 16 \
  -boot c \
  -drive file=build/captain-bible/captain-bible.img,format=raw,if=ide,index=0,media=disk \
  -vga std \
  -audiodev coreaudio,id=audio0 \
  -device sb16,audiodev=audio0 \
  -device adlib,audiodev=audio0 \
  -display cocoa,zoom-to-fit=on \
  -gdb tcp:127.0.0.1:1234 \
  -monitor stdio

After the title screen appeared, used the QEMU monitor:

stop
info registers
pmemsave 0 0x100000 build/dumps/title-physical-1m.bin
screendump build/dumps/title-screen.ppm
quit

The dump SHA-256 is aca64f0013d052a2cd8b8ecb5869b1d71df7cd30f704361f57bfebacfa1d67d5. The title-screen state had PSP 0617, CS=0627, IP=C614, and DS=ES=SS=14E1. Thus the load module begins at physical 0x6270, its relative data segment begins at load offset 0xEBA0 / physical 0x14E10, and the current instruction was at physical 0x12884.

Searched the dump for known strings. The Microsoft run-time banner occurs at physical 0x14E18, faith/status strings at 0x15068, DD1.DAT at 0x15220, the game title at 0x15456, and the R6000 strings near 0x18796. The packed stub’s corruption message is absent from the reconstructed module, as expected. Stopped QEMU before performing any disk or host-side file work.

Reported to the user that this capture provides a bridge between the packed DOS file and static analysis and that the rebuilt module would be checked against the captured process rather than trusting packed-entry disassembly.

Independent EXEPACK reconstruction

Built an external implementation under ignored build/tools/ for an initial independent result:

mkdir -p build/tools build/analysis
git clone https://www.bamsoftware.com/git/exepack.git \
  build/tools/exepack-src
git -C build/tools/exepack-src rev-parse HEAD
cargo build --release \
  --manifest-path build/tools/exepack-src/Cargo.toml
build/tools/exepack-src/target/release/exepack -d \
  CB/CB.EXE build/analysis/CB_UNPACKED.EXE

The source revision was f715ed19285565d636e78182fc19df62c0fa64b9. The output is a 75,776-byte MZ executable with 75,264 load bytes, 43 ordinary MZ relocations, entry 0000:CB5C, and SHA-256 4875f83d6d2ba9c1cc4f058e351e453010c6a5976e1b15976b676689f9747643.

Applied the relocations with load segment 0x0627 in a read-only Python comparison and compared all 75,264 bytes with the QEMU dump. The first 0x905A bytes are identical. There are 5,612 differing bytes overall, grouped primarily in initialized state and BSS; inspection showed strings and tables loaded by startup. Reported the real entry and this verification result to the user.

Created tools/analyze_cb_exe.py using apply_patch. It is a dependency-free MZ/EXEPACK parser, decompressor, relocation decoder, MZ serializer, checksum writer, and optional QEMU-memory comparator. Created tests/test_analyze_cb_exe.py with size-field, known-output, and memory-dump regressions.

The first test run exposed a Python 3.14 dataclasses import issue because the dynamically loaded test module had not been registered in sys.modules:

AttributeError: 'NoneType' object has no attribute '__dict__'

Registered the test module before executing it, then ran:

chmod +x tools/analyze_cb_exe.py
python3 -m unittest discover -s tests -v
python3 -m py_compile \
  tools/analyze_cb_exe.py tests/test_analyze_cb_exe.py
tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED_PY.EXE \
  --memory-dump build/dumps/title-physical-1m.bin \
  --load-segment 0x627
cmp build/analysis/CB_UNPACKED.EXE \
  build/analysis/CB_UNPACKED_PY.EXE

All 12 repository tests passed. cmp proved that the local Python implementation emits exactly the same bytes as the independent Rust tool.

Rizin first pass and segmented-address correction

Ran Rizin recursively over the unpacked file. An initial attempt to set asm.bits=16 through an evaluation variable failed with:

ERROR: use -b argument for setting the arch bits

Reran with -b 16, saved exploratory output under ignored build/analysis/, and searched for DOS, BIOS video, BIOS keyboard, and mouse interrupt instructions. Rizin reported approximately 340 candidate functions, 79 int 21h sites, 12 int 10h sites, three int 16h sites, and six int 33h sites. Large false function merges remain around tables and unusual control flow.

The Microsoft startup at 0xCB5C pushes three values and calls 0x8A82 before passing the return value to its exit path. The values are envp, argv, and argc in right-to-left order, identifying 0x8A82 as main.

Corrected an important addressing issue during inspection: Rizin displays ordinary DS immediates as low linear addresses. Startup loads DS with load-segment plus 0x0EBA; therefore DS:08DA is load offset 0xF47A, not code address 0x08DA. Used this translation when extracting and correlating strings.

Inspected main, its startup callees, the manual’s command-line section, the string run-time functions, DOS file routines, VGA detection, mouse routines, the event combiner, text export, and save functions. Commands included Rizin pdf, pd, axt, afl, and /ad queries, ndisasm around instructions that Rizin initially rendered as invalid, xxd, and read-only Python string and structure decoders.

Confirmed from implementations that 0xE22C, 0xE26C, 0xE29E, 0xE302, 0xE31A, 0xE772, and 0xE993 are strcat, strcpy, strcmp, tolower, toupper, puts, and chdir; and that 0xD0C2, 0xD1AE, and 0xD1D6 are fclose, fopen, and fread.

Created analysis/cb.rz with the high-confidence names. Its first form used unsupported evaluation-variable names and incorrect address syntax; the second form collided with Rizin’s existing section flags; and another run revealed that the two tiny mouse cursor wrappers had not been recognized as functions. Removed the unsupported settings, used @ address syntax, stopped redefining section flags, and explicitly analyzed the two wrappers before renaming them. Verified the final script with:

rizin -q -b 16 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~game; afl~save; afl~libc; fl~str_; q' \
  build/analysis/CB_UNPACKED.EXE

The final run completed without an error and listed all intended game, save, library, and string names.

Static game-system findings

Reported during the pass that the startup analysis had identified these paths, then documented the evidence in dedicated book chapters:

  • main directly implements -t, -bX, -c, -idirectory, -sXfilename, -gXX, and the non-option per-player save prefix.
  • 0x3363 checks VGA, reads SOUND.5, conditionally loads SOUND.1 through SOUND.4, opens DD1.DAT, and initializes hardware/data subsystems.
  • 0x5F92 is the ASCII export routine. Its bit tests match the six documented -gXX categories.
  • 0x7BED merges keyboard, mouse motion, mouse-button, and UI-hit events.
  • 0x8E0A detects the mouse through int 33h; 0x8D79 updates buttons and clamps coordinates to 320×200.
  • 0x7F58 reads a 243-byte .SV0 index consisting of nine 27-byte labels.
  • 0x7FD7 and 0x81AC write and read a fixed 2,752-byte state in 15 blocks. Every supplied .SV1 through .SV9 file has that exact size.
  • The quick-save path changes the mutable suffix from 0 to Q, identifying .SVQ as the quick-save state file.

Ran stat, SHA-256, xxd, and a nine-record decoder on the supplied save files. The supplied DDGAMES.SV0 is 243 bytes, each state file is 2,752 bytes, and the index’s visible labels begin with EMPTY. The missing-index code uses the initialized string (EMPTY) instead.

Updated PLAN.md, README.md, the mdBook summary and introduction, and added executable.md and static-analysis.md. The new chapters preserve the exact EXEPACK and QEMU evidence, address convention, command parser, export masks, save block layout, input path, function map, confidence limits, and commands for reproducing the analysis.

Static-pass verification

Reported to the user that the repository now contains the reproducible EXEPACK/QEMU verifier, regression tests, Rizin symbol map, and mdBook findings, and that a final consistency pass was in progress.

Ran:

python3 -m unittest discover -s tests -v
python3 -m py_compile \
  tools/analyze_cb_exe.py tests/test_analyze_cb_exe.py
bash -n run.sh
tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE \
  --memory-dump build/dumps/title-physical-1m.bin \
  --load-segment 0x627 \
  > build/analysis/analyzer-report.txt
rizin -q -b 16 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~game; afl~save; afl~libc; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/analysis/symbol-check.txt
mdbook build docs
test -f build/docs-book/executable.html
test -f build/docs-book/static-analysis.html
git diff --check
git status --short
git diff --stat

Results:

  • All 12 tests passed.
  • Both analysis Python files compiled and run.sh still parsed as Bash.
  • The analyzer reproduced the expected executable and QEMU comparison report.
  • The Rizin script loaded and printed the intended renamed function groups.
  • mdBook 0.5.3 built both new chapters successfully.
  • The Git whitespace check passed.
  • Generated executable, dump, reports, external source, and book output remain ignored under build/; only analysis source, tests, and documentation are pending in the working tree.

After adding the source link and making a formatting-only Python change, repeated the tests, compiler check, analyzer comparison, Rizin symbol query, mdBook build, whitespace check, executable-mode check, status, and source line counts. All checks passed again; tools/analyze_cb_exe.py is mode 755, and the five new analysis/source files total 805 lines.

Static-analysis commit preparation

At the user’s request, reviewed the complete static-analysis change set before committing it. Confirmed that the new analyzer, regression tests, Rizin symbols, executable notes, static-analysis report, and project status updates belong to this phase of the investigation. The verification results above establish the commit as a reproducible analysis baseline.

2026-07-15: Dynamic DOS-call tracing

The user asked to continue after commit 86bb979. Reported that the next step would build on the verified EXEPACK/QEMU baseline by capturing a more useful runtime snapshot and correlating it with the static function map.

Inspected AGENTS.md, PLAN.md, README.md, run.sh, the executable and static-analysis chapters, and the existing files under build/analysis/ and build/dumps/. Confirmed that the worktree was clean, the current play path still uses the required visible -display cocoa,zoom-to-fit=on, and the title snapshot is present.

Checked the installed dynamic-analysis interfaces with:

command -v qemu-system-i386 rizin rz-bin gdb lldb nc socat \
  mtools mcopy mdir
qemu-system-i386 --version
qemu-system-i386 -plugin help
find /opt/homebrew /usr/local -path '*qemu-plugin.h' \
  -o -path '*libexeclog*'
qemu-system-i386 -d help
rg -n 'read_register|read_memory|vcpu_insn_exec' \
  /opt/homebrew/include/qemu-plugin.h
sed -n '1,1260p' /opt/homebrew/include/qemu-plugin.h

QEMU 11.0.2 includes plugin API version 6, an installed qemu-plugin.h, instruction callbacks, register access, and physical-memory reads. The host has LLDB but not GDB. Chose a TCG plugin as the next tracing method because it can observe each int 21h at its exact game address, read the DOS call registers and pathname from guest memory, and leave CB.EXE unchanged. Added the plugin and trace-capture steps to PLAN.md.

The user asked that QEMU remain visible but stop playing audio. Changed the macOS QEMU audio backend in run.sh from coreaudio to none, while retaining the emulated Sound Blaster 16 and AdLib devices. This keeps the guest hardware paths available for analysis without producing host sound. Documented the behavior in README.md; it applies to normal and traced launches.

Building the TCG tracer

Added tools/qemu_dos_trace.c, tools/build_qemu_dos_trace.sh, and the --trace-dos option to run.sh. The plugin recognizes executed CD 21 instructions, reads the i386 register descriptors and guest physical memory, escapes DOS pathname strings, pairs calls with their returns, and writes an ignored log under build/qemu-trace/. Trace mode also creates a QEMU monitor socket so screen, memory, and register evidence can be captured while the Cocoa window remains visible.

Ran:

chmod +x tools/build_qemu_dos_trace.sh
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/build_qemu_dos_trace.sh
file build/qemu-trace/qemu_dos_trace.so
git diff --check

The plugin compiled without warnings as a native arm64 Mach-O bundle. Both shell scripts parsed successfully and the Git whitespace check passed.

Launched the first visible trace with ./run.sh --trace-dos. After allowing FreeDOS and the game to start, inspected dos-calls.log, connected to the monitor socket with nc -U, stopped the guest, recorded info registers, used screendump, saved the first MiB with pmemsave, and quit QEMU. The first screen capture showed the opening Captain Bible conversation. QEMU had captured hundreds of paired interrupt sites, but every apparent AH value was zero.

Compared those sites with Rizin disassembly. For example, load offset 0x9908 is an int 21h immediately after mov ah,49h, proving that the initial zero was a tracing artifact rather than the executed function. Normal instruction callbacks exposed register state from the translation-block entry. Added -accel tcg,one-insn-per-tb=on for traced runs and moved register sampling to translation-block entry callbacks. Normal ./run.sh execution remains unrestricted.

The one-instruction mode fixed PC and the other argument registers, but QEMU 11.0.2’s plugin accessor continued to report only EAX as zero. Tested both the synchronous-exception callback and retaining the register-descriptor array for the entire VM lifetime; neither changed EAX. Removed the misleading AX values from output. The final implementation reads up to 48 preceding code bytes and derives the DOS function from the nearest MOV AH,imm8 or MOV AX,imm16. It preserves the directly observed BX, CX, DX, SI, DI, DS, ES, and returned carry flag, and states the AX limitation in the trace header and dynamic-analysis chapter.

An early trace also contained calls from a FreeDOS program that temporarily used segment 0627 before CB.EXE loaded. Added the start=0xCB5C plugin option and configured run.sh to keep tracing dormant until physical address 0627:CB5C executes. The log now begins with the game’s real startup calls: DOS version 30h, memory resize 4Ah, interrupt-vector setup, and handle IOCTL calls.

Startup trace and memory capture

Repeated the visible, silent QEMU run after each timing/filter correction. For the final run, polled the log until call 195, then issued these monitor operations:

stop
info registers
screendump build/qemu-trace/startup-screen.ppm
pmemsave 0 1048576 build/qemu-trace/startup-physical-1m.bin
quit

Converted the PPM to PNG with sips and visually inspected it. The screen is the first story narration, beginning “There once was a city far from us in place and time.” The stopped registers were CS:IP=0627:C668, DS=ES=SS=14E1, FS=0617, and GS=04C7. These segment values reproduce the earlier title-screen capture.

The final trace has exactly 195 CALL/RET pairs, no unresolved fn=FF records, and no CF=1 returns. Function counts include 78 reads, 27 seeks, 16 allocations, 15 IOCTL calls, 11 opens, 10 attribute queries, nine closes, six resizes, and six frees. Its SHA-256 is f8013fb529444c409a6309a5bbc57336d674382f4e20dcde9185a4d67658e3c9. The memory dump SHA-256 is 7fee3fdda30db225711d0db84d1f292efb9b087c4a91deb2e035025cd31bf71e; the PNG SHA-256 is 85a46bbf6345d5cd88393596706ded3daadbbe0ecb9853cdd0bcecf610077c79.

Ran the existing EXEPACK analyzer against the new memory dump:

tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE \
  --memory-dump build/qemu-trace/startup-physical-1m.bin \
  --load-segment 0x627 \
  > build/qemu-trace/memory-comparison.txt

It again found a 0x905A-byte identical prefix and 5,612 differing runtime bytes across the full 75,264-byte load module. This exactly matches the first title-screen comparison and independently validates the new capture’s process location.

Runtime file findings

Extracted the path timeline with rg '^CALL.*arg='. The trace changes to C:\CBDOME, reads SOUND.5, loads SOUND.1 through SOUND.4, probes and reopens DD1.DAT, reads DDGAMES.SV0, loads DDLC twice, and reads the save index again before the story introduction. Every recorded path operation has carry clear.

Correlated the sound calls with stat, xxd, file, strings, SETSOUND.BAT, MANUAL.TXT, and the disassembly of initialize_hardware_and_data. SOUND.1 is the configured DIGPAK Sound Blaster 16 soundrv.com; SOUND.2 is Miles Design midpak.adv; SOUND.3 is tmidpak.com; and SOUND.4 is midpak.ad timbre data. The trace shows that the generic loader at 0xACDA measures each file, requests its size rounded up to a DOS paragraph, reads it, and closes it. The observed allocations are 012Eh, 03F9h, 0340h, and 00E3h, exactly matching the four rounded file sizes.

Disassembly shows that four-byte SOUND.5 is the installation-lock record, not a driver selector. Its word at offset 0 controls the Bible translation; byte 2 forces no-mature mode unless it is DBh; byte 3 is ORed into the no-combat flag. The supplied 01 00 00 00 selects translation value 1, forces no-mature mode, and leaves combat unrestricted. This behavior also explains how the installation locks combine with -b, -t, and -c.

The trace opens DD1.DAT as persistent DOS handle 5, then seeks and reads resources through that handle. It never opens the static RUN.ART name as a DOS path, establishing that names used by load_art_resource are members of the DD1.DAT container. DDLC is different: it is opened directly twice.

Inventory and documentation

Ran stat, shasum -a 256, and file over every supplied file except host .DS_Store. All distributed files share timestamp 1996-12-24 23:32:00 +0700. Recorded every size and SHA-256 in the new file inventory chapter. Noted that file’s “Arhangel archive” guess for DDLE is only a heuristic and that DDGAMES.SV6 and DDGAMES.SV8 are byte-identical.

Added dynamic-analysis.md and file-inventory.md, updated the mdBook summary and introduction, expanded the static-analysis sound findings, documented --trace-dos in README.md, and marked the inventory and startup trace tasks complete in PLAN.md. Added high-confidence Rizin names for the far-memory file loader and Microsoft C low-level open, read, write, seek, and close routines.

Dynamic-pass verification

Ran:

python3 -m unittest discover -s tests -v
python3 -m py_compile \
  tools/analyze_cb_exe.py tests/test_analyze_cb_exe.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/build_qemu_dos_trace.sh
./run.sh --help
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~lowio; afl~far_memory; q' \
  build/analysis/CB_UNPACKED.EXE
mdbook build docs
test -f build/docs-book/dynamic-analysis.html
test -f build/docs-book/file-inventory.html
git diff --check
git status --short
git diff --stat

All 12 Python tests passed, both Python analysis files compiled, both shell scripts parsed, and the C plugin rebuilt without compiler warnings. The launcher help exposes --trace-dos; Rizin loaded all five low-level I/O names and load_file_into_far_memory; mdBook 0.5.3 built both new chapters; and the Git whitespace check passed. Generated plugins, traces, screenshots, memory dumps, reports, and book output remain ignored under build/.

2026-07-15: DD1.DAT resource format

After verifying the dynamic pass, reported that work would continue into the main resource container rather than stop at the trace. Chose DD1.DAT because QEMU established it as the persistent handle used for named resource seeks, while static analysis identified load_art_resource and the member name RUN.ART. Added explicit directory-recovery and extractor tasks to PLAN.md.

Directory reconstruction

Inspected the beginning and end of the archive, searched its strings, compared QEMU’s handle-5 seeks with archive offsets, and disassembled the code around the resource loader. The investigation used xxd, strings, rg, Rizin, and small read-only Python parsers. Representative commands were:

stat -f 'size=%z bytes' CB/DD1.DAT
shasum -a 256 CB/DD1.DAT
xxd -g 1 -l 512 CB/DD1.DAT
xxd -g 1 -s -256 CB/DD1.DAT
strings -a -t x CB/DD1.DAT | head
rg 'RUN\.ART|LOGO\.BIN' build/qemu-trace/dos-calls.log
rizin -q -b 16 -a x86 -e scr.color=false \
  build/analysis/CB_UNPACKED.EXE

The first word is 0x0171, or 369. Parsing the following bytes as 24-byte records produced printable names, markers 0 or 1, monotonic offsets, expanded sizes, and stored sizes. The implied directory length, 2 + 369 * 24 = 0x229A, is exactly the first payload offset. A Python invariant pass confirmed that every payload begins at the preceding payload’s end and that the last ends at file size 0x1C7954. All 369 payloads begin with GC.

Correlated individual records with runtime and static evidence. Directory index 1 is LOGO.BIN, offset 0x24DA, stored size 431, and expanded size 640. Those are the same seek and size values in the QEMU startup trace. Index 82 is RUN.ART, offset 0x6F69C, stored size 16,138, and expanded size 53,213. This establishes the static RUN.ART string as a container member, consistent with the absence of a DOS pathname open.

Counted 38 marker-0 records and 331 marker-1 records. Marker 0 always has stored_size = expanded_size + 2, identifying it as an uncompressed body after GC. The extension counts are 143 ART, 62 BIN, 41 ABT, 37 PAL, 33 without extensions, 32 XMI, and 21 MAP. The three duplicate names are GANTRY.PAL, HOLEA.ART, and NG.

Loader and compression disassembly

Followed calls from the art loader and saved a focused disassembly with:

rizin -q -b 16 -a x86 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz \
  -c 's 0x97d0; pdf; s 0x99ab; pdf; s 0x9bef; pdf; \
s 0x9ca4; pdf; s 0x9d98; pdf; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/analysis/dd1-functions.txt

The routine at 0x99AB mutates the requested name to uppercase, splits it at the dot, scans the memory-resident directory in 0x18-byte increments, seeks to the record’s 32-bit offset, reads two bytes, and compares them with little- endian word 0x4347 (GC). The caller at 0x97D0 branches on the record marker: 0x9BEF copies raw data and 0x9CA4 expands compressed data. 0x9C5F refills the input buffer, while 0x9D98 recursively walks the decoder’s prefix table and writes suffix bytes.

Reconstructed the compressed code stream in a temporary Python decoder. The format uses literal roots 0 through 255 and grows through code 0x1000. For each group of eight codes, it puts one to four bytes containing the high-bit planes before the eight low bytes. Each plane byte is consumed least- significant bit first across the group. When the counter reaches 0x1001, the decoder begins a new dictionary pass with a literal; there is no clear code in the stream.

The first temporary implementation expanded all 331 compressed members to their declared lengths and consumed every stored byte. That established the bit-plane boundaries but was not yet sufficient evidence that every output byte matched the executable’s prefix/suffix update order.

Reproducible extractor

Added executable tools/extract_dd1.py and tests/test_extract_dd1.py. The tool validates the complete directory and payload layout, lists members, extracts by unique name or numeric index, and extracts every member with an index prefix so duplicates cannot overwrite one another. It checks compressed stream bounds and exact consumption in addition to expanded size.

The first tool check ran:

git status --short
chmod +x tools/extract_dd1.py
python3 -m py_compile tools/extract_dd1.py tests/test_extract_dd1.py
python3 -m unittest -v tests.test_extract_dd1

Three structural tests and the all-members length test passed. The initial RUN.ART checksum fixture failed because it had not been independently tied to the newly written decoder. Extracted RUN.ART and LOGO.BIN, inspected their first 64 bytes, then exercised the all-members mode and computed archive statistics:

mkdir -p build/dd1
tools/extract_dd1.py --extract RUN.ART \
  --output build/dd1/RUN.ART CB/DD1.DAT
tools/extract_dd1.py --extract LOGO.BIN \
  --output build/dd1/LOGO.BIN CB/DD1.DAT
shasum -a 256 build/dd1/RUN.ART build/dd1/LOGO.BIN
wc -c build/dd1/RUN.ART build/dd1/LOGO.BIN
xxd -l 64 build/dd1/RUN.ART
xxd -l 64 build/dd1/LOGO.BIN
rm -rf build/dd1/all
tools/extract_dd1.py --extract-all build/dd1/all CB/DD1.DAT
find build/dd1/all -type f | wc -l
du -sh build/dd1/all

All 369 files were present. Raw entries contain 203,303 expanded bytes and 203,379 stored bytes; compressed entries contain 5,340,520 expanded bytes and 1,653,831 stored bytes. Total expanded content is 5,543,823 bytes. A separate checksum and cmp pass established that both instances of each of the three duplicate names have identical content.

Re-read the decoder instructions one by one before accepting those bytes. At 0x9CF8, the first literal becomes prefix entry 0x100. Before each later expansion, 0x9D87 saves the current code at prefix index BP, while the leaf case at 0x9DDE writes the phrase’s first byte at suffix index BP - 1. This offset is essential to the usual LZW case where a code refers to the entry currently being completed. The temporary/tool implementation had put that suffix at BP; output lengths and stream consumption could not reveal the error.

Reported this correction immediately, wrote a second inline decoder matching the assembly order, and compared it byte-for-byte with the first output. The first difference was byte 10 of LOGO.BIN and byte 24 of RUN.ART. The exact decoder still expanded all 369 records to the declared sizes and consumed all stored input. Corrected decode_gc_dictionary, added fixed checksums for both resources, and reran:

python3 -m py_compile tools/extract_dd1.py tests/test_extract_dd1.py
python3 -m unittest -v tests.test_extract_dd1
rm -rf build/dd1/all
tools/extract_dd1.py --extract-all build/dd1/all CB/DD1.DAT
tools/extract_dd1.py --extract RUN.ART \
  --output build/dd1/RUN.ART CB/DD1.DAT
tools/extract_dd1.py --extract LOGO.BIN \
  --output build/dd1/LOGO.BIN CB/DD1.DAT
shasum -a 256 build/dd1/RUN.ART build/dd1/LOGO.BIN
wc -c build/dd1/RUN.ART build/dd1/LOGO.BIN
xxd -l 64 build/dd1/RUN.ART
xxd -l 64 build/dd1/LOGO.BIN
find build/dd1/all -type f | wc -l

All five focused tests passed. The final RUN.ART output is 53,213 bytes with SHA-256 c4b00d2e31e9dec81cc419dc577086b143a546a4a0b618dbe5600df4e5fd4ac0; LOGO.BIN is 640 bytes with SHA-256 8580d3ff93c6e775aa71334c50762ffde2b1f42a320ee362f5608bd8cbc51424.

Added the dedicated DD1.DAT book chapter, extractor usage to README.md, the six archive functions to analysis/cb.rz and the static function map, and marked directory recovery and extractor implementation complete in PLAN.md. Reported that the extractor had passed the full container and that the recovered format was being made reproducible in the documentation.

DD1 and dynamic-pass verification

Ran the complete repository checks after the extractor and documentation changes:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/build_qemu_dos_trace.sh
./run.sh --help
tools/extract_dd1.py --list CB/DD1.DAT > build/dd1/list.txt
test "$(wc -l < build/dd1/list.txt | tr -d ' ')" = 370
tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE \
  --memory-dump build/qemu-trace/startup-physical-1m.bin \
  --load-segment 0x627 \
  > build/dd1/analyzer-check.txt
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~archive_; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/dd1/symbol-check.txt
mdbook build docs
test -f build/docs-book/dd1-container.html
git diff --check
git status --short
git diff --stat

All 17 tests passed, all Python sources compiled, both shell scripts parsed, and the QEMU plugin rebuilt without warnings. The list has one heading plus 369 records. The EXEPACK analyzer again reported the verified load module, 0x905A-byte identical memory prefix, and 5,612 dynamic differences. Rizin loaded all six archive names at their intended offsets. mdBook generated the new chapter, and the Git whitespace check passed. All generated extraction, analysis, trace, and book artifacts remain under ignored build/; no commit was requested or made.

After adding that verification record, rebuilt the book once more, checked tracked and untracked source files for whitespace errors, and confirmed that run.sh, tools/build_qemu_dos_trace.sh, and tools/extract_dd1.py all retain executable mode. The final checks passed.

2026-07-15: Dynamic-analysis commit preparation

The user requested a commit. Reviewed the complete tracked and untracked change set to ensure it contains only the visible/silent QEMU launcher, DOS tracer, startup evidence and inventory, DD1.DAT extractor and tests, symbol map additions, book chapters, and associated plan, README, and progress-log updates. Generated traces, dumps, extracted resources, plugin binaries, and rendered documentation remain ignored under build/.

Repeated the pre-commit verification with:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/build_qemu_dos_trace.sh
./run.sh --help
rm -rf build/dd1/commit-check
tools/extract_dd1.py --extract-all build/dd1/commit-check CB/DD1.DAT
test "$(find build/dd1/commit-check -type f | wc -l | tr -d ' ')" = 369
tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE \
  --memory-dump build/qemu-trace/startup-physical-1m.bin \
  --load-segment 0x627 \
  > build/dd1/commit-analyzer-check.txt
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~archive_; afl~lowio_; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/dd1/commit-symbol-check.txt
mdbook build docs
test -f build/docs-book/dynamic-analysis.html
test -f build/docs-book/file-inventory.html
test -f build/docs-book/dd1-container.html
git diff --check

All 17 unit tests passed, all Python files compiled, both shell scripts parsed, the QEMU plugin rebuilt without warnings, and the launcher exposed trace mode. The extractor produced exactly 369 outputs. The analyzer reproduced the verified executable and memory comparison, Rizin loaded all archive and low-level I/O names, mdBook rendered the three new chapters, and the Git whitespace check passed. Prepared one commit with an imperative subject and a detailed, 72-column-wrapped explanation of the tracing and extraction work.

2026-07-15: Palette and artwork format analysis

After commit 7354291, the user asked to continue. Reported that the next Phase 4 milestone would recover the palette and artwork formats, correlate their extracted bytes with rendering code and the captured QEMU screen, and add a reproducible decoder when supported by the evidence. Added explicit palette/artwork recovery and rendering-tool tasks to PLAN.md.

Confirmed the worktree was clean, reviewed the current plan and latest log, and verified that the ignored build/dd1/all/ extraction still contains all 369 members. No source files were changed before the two new plan tasks and this log entry.

Resource population and descriptor inference

Used a read-only Python inventory over the extracted resources to group files by extension, count sizes, print leading bytes, and interpret initial words. All 37 PAL members are exactly 768 bytes. All their values are at most 63, which is the six-bit VGA DAC component range. ART sizes range from 201 to 64,041 bytes and their leading words form plausible positions and dimensions.

The first ART hypothesis came from BOSS2.ART: its first six values are X 180, Y 44, width 31, height 4, and 32-bit offset 60. The next pixel offset is 184, exactly 60 + 31 * 4. LOGO.ART begins with offset 72, indicating six 12-byte records. Wrote a temporary parser treating each record as signed X/Y, unsigned width/height, and a 32-bit offset, then checked all 143 members:

python3 - <<'PY'
# Parse every *_*.ART as repeated struct '<hhHHI'.
# Require first_offset % 12 == 0, contiguous width*height pixel bodies,
# and final offset == file size.
PY

Every resource passed with no exceptions. The population contains 1,178 frames and 4,850,699 pixel bytes. Frame counts range from one to 63; the 63-frame file is MAP.ART. Eleven files contain a full (0, 0, 320, 200) frame. Negative signed origins occur frequently, including RUN.ART frame 0 at (-28, -5) with dimensions 46×61, showing that they are relative sprite anchors rather than always screen coordinates.

Counted content identities with SHA-256. The 37 palettes represent 35 unique payloads: the two GANTRY.PAL entries match, and RICH.PAL matches 1.PAL. The 143 artwork members represent 142 unique payloads because the two HOLEA.ART entries match.

Inspected LOGO.BIN, INTRO.BIN, TITLE.BIN, MENU.BIN, and DOME.BIN with strings -a -t x to see how scripts refer to artwork and other assets. The first guessed numeric paths for MENU.BIN and DOME.BIN did not exist; used find to obtain their actual indices 326 and 330, then repeated the inspection. Strings such as MTITLE are command plus music names, not an embedded palette declaration, so palette pairing required program and runtime evidence.

Graphics disassembly

Saved focused Rizin listings for the art loader, descriptor users, low-level blitters, palette functions, and palette-effect loop:

rizin -q -b 16 -a x86 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz \
  -c 's 0xb818; pdf; axt; s 0xb7ea; pdf; \
s 0xb8f0; pd 260; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/analysis/art-functions.txt

rizin -q -b 16 -a x86 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz \
  -c 's 0xa0c9; pdf; s 0xb99c; af; pdf; /ad/ out; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/analysis/graphics-functions.txt

rizin -q -b 16 -a x86 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz \
  -c 's 0x9f80; pd 240; q' \
  build/analysis/CB_UNPACKED.EXE

rizin -q -b 16 -a x86 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz \
  -c 's 0xc000; pdf; s 0xbf88; pdf; s 0xb620; pdf; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/analysis/art-rendering.txt

At 0xB99C, the requested frame index is multiplied by 12. The routine reads width from descriptor offset 4, height from 6, and the 32-bit pixel offset from 8 before copying rows to VGA memory. 0xA0C9 uses destination stride 320 and segment A000h. 0xA106 explicitly skips source byte 0, while 0xA136 copies every pixel, proving that transparency is a draw-call choice rather than a descriptor bit.

Palette code provides a similarly direct match. 0xA017 invokes BIOS video function 1012h for all 256 entries. 0xA032 synchronizes through status port 03DAh, writes the starting index to 03C8h, and sends raw RGB triplets to 03C9h. 0xB620 computes bounded component changes and submits a palette range, accounting for fades and palette cycling.

Prototype rendering and captured-screen checks

Confirmed Pillow 12.3.0 is installed. Wrote an ignored, inline prototype to parse the descriptors, expand six-bit components, and render LOGO, INTRO, TITLE, DOME, BOSS, and MENU under build/graphics/. The first version created a mask with P-mode Image.point; Pillow remapped palette indices while producing that mask, so its colors were visibly wrong. Checked source and saved pixels, found that source index 33 had become index 21, and replaced the mask with an explicit L image containing byte 255 for nonzero pixels. The corrected renders show the Bridgestone logo, opening story, title screen, dome landscape, boss screen, and menu artwork with the expected geometry and colors.

Used the QEMU startup screenshot and INTRO.ART pixels to derive the dominant runtime RGB value for each of the 39 indices used on that screen. Static entries match TITLE.PAL closely; indices 243 through 254 differ because the text range is being color-cycled. This also explained why a base-palette preview need not match the exact text shade at the capture instant.

Performed a stronger comparison that does not depend on RGB conversion:

python3 - <<'PY'
# Compare INTRO.ART's 64,000-byte frame with dump[0xA0000:0xAFA00].
PY
xxd -l 64 -s 0xa0000 build/qemu-trace/startup-physical-1m.bin
xxd -l 64 -s 12 build/dd1/all/006_INTRO.ART

The frame and live VGA memory share 63,648 of 64,000 bytes. All 352 differences are explained by two visible runtime overlays: 333 pixels in the floppy/save icon bounding box X 297–316, Y 1–17, and 19 pixels in the centered mouse cursor bounding box X 154–166, Y 94–106. Outside those rectangles the resource and framebuffer are byte-for-byte identical. The extracted frame pixel SHA-256 is 9f0926921d2a5ca01586a1f644d1eee24e734b4f6cbc3b0399e9883f97d2a014.

Also checked the older and current one-MiB dumps. They are different overall, but their VGA ranges contain the same opening scene; the older filename refers to the executable-analysis milestone rather than a distinct title-frame VGA capture. Did not use it as independent title-art evidence.

Reproducible artwork tool

Added executable tools/render_art.py and tests/test_render_art.py. The tool validates descriptor alignment, positive dimensions, contiguous pixel blocks, exact resource length, 768-byte palette size, and six-bit palette components. It can list descriptors, render a single transparent frame, render every frame with index-preserving filenames, or composite signed origins on a configurable canvas. It implements clipping itself so index values remain stable and uses nearest-neighbor integer scaling.

The first focused run was:

chmod +x tools/render_art.py
python3 -m py_compile tools/render_art.py tests/test_render_art.py
python3 -m unittest -v tests.test_render_art

Seven structural tests passed; one guessed LOGO canvas checksum failed. As reported to the user, computed a second composite independently with a simple pixel loop. It exactly matched the tool output and established SHA-256 e3234c620a873a2f91bb68e8e631d0a645b7958a24b3b07e5890f9bc7b5d62bc. Corrected the stale fixture and all eight focused tests passed.

Changed the tests to extract their ART/PAL inputs directly from CB/DD1.DAT through DD1Archive, rather than depend on ignored pre-extracted files. This makes the test suite reproducible from the supplied game directory alone. A fresh --extract-all run separately produced all 369 files.

Exercised each command-line rendering path with:

tools/render_art.py build/dd1/all/003_LOGO.ART --list \
  > build/graphics/tool-check/logo-frames.txt
tools/render_art.py build/dd1/all/003_LOGO.ART \
  --palette build/dd1/all/002_LOGO.PAL \
  --canvas --scale 2 \
  --output build/graphics/tool-check/logo.png
tools/render_art.py build/dd1/all/006_INTRO.ART \
  --palette build/dd1/all/025_TITLE.PAL \
  --canvas --scale 2 \
  --output build/graphics/tool-check/intro.png
tools/render_art.py build/dd1/all/082_RUN.ART \
  --palette build/dd1/all/025_TITLE.PAL \
  --all-frames build/graphics/tool-check/run-frames \
  --scale 2

The list reports all six LOGO descriptors, the two composites are valid 640×400 indexed PNGs, and all 21 RUN frames were written. Visually inspected the final logo, opening scene, and first running frame; their pixels, transparency, orientation, and palette are correct.

Added the palette/artwork book chapter, renderer instructions and Pillow requirement to README.md, format findings to the static-analysis chapter, and eight high-confidence graphics names to analysis/cb.rz. Marked the two graphics tasks complete in PLAN.md.

Graphics-pass verification

Ran:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/render_art.py --help > build/graphics/tool-check/help.txt
tools/render_art.py build/dd1/all/003_LOGO.ART \
  --palette build/dd1/all/002_LOGO.PAL \
  --canvas --scale 2 \
  --output build/graphics/tool-check/final-logo.png
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~vga_; afl~blit_; afl~palette_effect; afl~draw_art; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/graphics/tool-check/symbols.txt
mdbook build docs
test -f build/docs-book/graphics-formats.html
git diff --check
git status --short
git diff --stat

All 25 tests passed, every Python source compiled, both existing shell scripts still parsed, and the renderer reproduced its checked 640×400 logo PNG. Rizin loaded all eight graphics names at their intended offsets. mdBook rendered the new graphics chapter, and the tracked-source whitespace check passed. Source changes remain uncommitted pending the next requested checkpoint.

Beginning scene-command analysis

Continued directly into the BIN resources because they contain artwork, music, text, timing, and effect references that connect the recovered graphics to startup behavior. Added explicit BIN command-stream recovery to PLAN.md.

Correlated the archive seeks in the existing QEMU DOS-call trace with the decoded DD1.DAT directory. The startup path reads these resources in order:

LOGO.BIN -> LOGO.PAL -> LOGO.ART -> D003.ABT
TITLE.BIN -> TITLE.PAL -> TITLE.ART -> TITLE2.ART -> MUS001.XMI
INTRO.BIN -> INTRO.ART

The trace’s archive offsets, entry numbers, stored sizes, and expanded sizes all agree with the extractor: LOGO.BIN is entry 1 at 0x24da, TITLE.BIN is entry 332 at 0x1b8665, and INTRO.BIN is entry 5 at 0xaad0. The later scene reuses TITLE.PAL; no second palette read occurs before INTRO.ART. The destination segments in the DOS reads also show a reusable resource buffer: both TITLE.BIN and INTRO.BIN occupy segment 4c13 at their respective points in startup.

Live BIN resource and interpreter state

Reported that work would continue by finishing the BIN bytecode chapter and guides, recording both the evidence trail and corrections, and running the complete verification suite.

Searched the startup physical-memory dump for the fully expanded INTRO.BIN, its far pointer, and nearby interpreter state. Repeated the check in a self-contained form with:

python3 - <<'PY'
from pathlib import Path
import sys
sys.path.insert(0, 'tools')
from extract_dd1 import DD1Archive

archive = DD1Archive.from_path(Path('CB/DD1.DAT'))
intro = archive.extract(archive.matching('INTRO.BIN')[0])
dump = Path('build/qemu-trace/startup-physical-1m.bin').read_bytes()
pointer = bytes.fromhex('00 00 13 4c')
print([hex(i) for i in range(len(dump)) if dump.startswith(intro, i)])
print([hex(i) for i in range(0x14000, 0x16000)
       if dump.startswith(pointer, i)])
print(dump[0x14f06:0x14f0e].hex(' '))
print(hex(intro[0x4b]))
PY

Results:

  • The only complete live copy of the 184-byte INTRO.BIN begins at physical address 0x4C130, the linear address of 4C13:0000.
  • The base pointer bytes 00 00 13 4C occur at physical 0x14F0A, which is the data-segment state at DS:00FA.
  • Bytes at DS:00F6..00FD are 4B 00 13 4C 00 00 13 4C. They encode current cursor 4C13:004B and resource base 4C13:0000.
  • INTRO.BIN[0x4B] is opcode 0x42, at a valid command boundary immediately after the return_minus_one command ending at file offset 0x4B.

This established that the interpreter stores a file-relative cursor in DS:00F6, its segment in DS:00F8, and the resource-base far pointer in DS:00FA..00FD. It also tied a statically decoded command boundary to QEMU runtime state rather than relying only on whole-file plausibility.

Interpreter and operand readers

Followed references to DS:00FA and inspected the parser region and its callers in Rizin. Saved the focused output as ignored research artifacts:

build/analysis/bin-parser-region.txt
build/analysis/bin-core-functions.txt
build/analysis/bin-callers.txt

The static pass identified:

  • 0x3A1E, which reads a byte from the far cursor and increments its offset.
  • 0x3A30, which reads two bytes and constructs a little-endian word.
  • 0x3A64, which normally advances through a NUL-terminated string and returns its resource-base-relative starting offset. An initial 0xFF instead introduces an explicit 16-bit offset.
  • 0x451B, the main interpreter. It installs base + argument as the cursor, reads an opcode, subtracts one, checks the range through 0x91, and uses a 145-entry near-pointer table at 0x59AB.
  • 0x6631, which appends .BIN, loads a scene resource, installs the base and cursor, initializes thread/object state, and executes file offset zero.
  • 0x7997, which resumes active scene threads by passing their saved offsets to 0x451B.

Out-of-range bytes are consumed by the game’s fallback path. The host decoder rejects them instead because treating arbitrary embedded data as executable commands would conceal region boundaries.

Recovered the operand layout of each handler by following calls to the three readers and direct cursor adjustments. The compact schema is:

B  unsigned byte
H  little-endian 16-bit word
z  NUL-terminated CP437 string
9  opaque nine-byte animation record
s  optional extra word when the preceding H is signed-negative

During the iterative decoder work, corrected several prototype mistakes:

  • An early schema string used uppercase Z, which the prototype did not recognize as a string marker. Standardized strings on lowercase z.
  • The conditional instruction layout was temporarily written as Bhs; the lowercase h was not a valid word marker. Corrected opcodes 0x11, 0x12, and 0x17 through 0x1A to BHs.
  • The initial resource count was reported internally as 60 because a quick scan omitted two members. The archive-backed test enumerated 62, and all documentation and totals now use that value.
  • The first ROOM3.BIN region test assumed its second code block continued to EOF. It stopped at another invalid zero block at 0x1754; inspection found a 20-byte reserved region followed by a third command entry point at 0x1768.

Reported the resulting structural milestone: all 145 dispatched opcodes have operand layouts, and 62 archived BIN resources cross-check against them, with two deliberate mixed code/data cases exposed explicitly.

Opcode meanings and corrected resource interpretation

Cross-referenced handler callees, suffix-building functions, and script control flow. High-confidence meanings include art and palette loading, music selection, scene changes, timing, variable assignment/increment/ decrement, jumps, calls, returns, state snapshots, and art unloading.

The most important correction concerned strings such as MTITLE seen during the earlier graphics pass. They were initially described as a command plus a music name. Static analysis of 0x4001 showed it appends the suffix at DS:0434, which is .PAL, while 0x4091 separately constructs MUS### or IBM### XMI names. Checked the suffixes directly in the QEMU memory dump:

DS:0434  .PAL
DS:0490  .ART
DS:0721  .BIN

Thus bytes 4D 54 49 54 4C 45 00 are opcode 0x4D followed by string TITLE, not an embedded MTITLE music identifier. Both opcodes 0x4D and 0x6D invoke palette loading; opcode 0x52 selects music by numeric index.

Decoded the startup streams completely. Their sizes and command counts are:

LOGO.BIN    640 bytes    114 commands
TITLE.BIN   436 bytes     80 commands
INTRO.BIN   184 bytes     39 commands
MENU.BIN  2,004 bytes     99 commands

INTRO.BIN begins with opcodes 6B 43, 4C 0F, palette command 4D "TITLE", and art command 01 "INTRO". It later issues music command 52 01 and, at file offset 0x009A, scene-change command 0D "dome" "seg".

Reproducible BIN inspection tool

Added executable tools/inspect_bin.py. It contains the recovered schema for every opcode 0x01..0x91, typed command/operand records, bounds checks, CP437-string decoding, conditional signed-word handling, and a command-line listing with file offsets. Known operations receive semantic names; other handlers deliberately remain opcode_XX.

Added tests/test_inspect_bin.py. The tests read resources directly from CB/DD1.DAT and cover:

  • exactly 145 contiguous schema entries;
  • complete decoding and command counts for four startup programs;
  • the INTRO.BIN palette, art, and scene-change commands;
  • every known command region in all 62 BIN resources;
  • the signed-negative conditional extra word;
  • both zero-filled gaps and all three ROOM3.BIN code regions;
  • rejection of opcode-zero padding and unterminated strings.

The first focused run was:

chmod +x tools/inspect_bin.py
python3 -m py_compile tools/inspect_bin.py tests/test_inspect_bin.py
python3 -m unittest -v tests.test_inspect_bin

All eight BIN tests passed. Whole-archive statistics from the validated regions are:

62 BIN members
179,200 expanded bytes
64 command regions
25,837 decoded commands
122 distinct opcodes used

Sixty resources linearly decode through EOF. CP2.BIN has a command region through 0x1D5A and a 251-byte structured trailer. ROOM3.BIN has command regions 0x0000..0x0336, 0x0C96..0x1754, and 0x1768..0x19DB, separated by zero blocks of 2,400 and 20 bytes. Saved listings of the latter two regions as build/analysis/room3-region2-bin.txt and build/analysis/room3-region3-bin.txt.

Exercised the user-facing decoder paths with:

tools/inspect_bin.py build/dd1/all/005_INTRO.BIN
tools/inspect_bin.py \
  build/dd1/all/334_ROOM3.BIN --start 0xc96 --limit 0x1754
tools/inspect_bin.py \
  build/dd1/all/334_ROOM3.BIN --start 0x1768

The first command reports all 39 commands and 184 bytes. The bounded ROOM3 listings report 180 commands over 2,750 bytes and 122 commands over 627 bytes. Adjusted the summary line after this check so a nonzero --start reports the decoded byte count, plus its absolute range, rather than mislabeling the final file offset as a byte count.

Recomputed the archive totals and repeated the QEMU pointer check with the self-contained Python snippets above. Then verified the new Rizin symbols:

rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~bin_; afl~palette_resource; afl~music_resource; afl~scene; q' \
  build/analysis/CB_UNPACKED.EXE

Rizin listed all eight intended names at 0x3A1E, 0x3A30, 0x3A64, 0x4001, 0x4091, 0x451B, 0x6631, and 0x7997.

During final review, noticed that the documented 0xFF string-offset escape in 0x3A64 was not yet implemented in the host decoder because none of the linearly decoded regions exercised it. Added string_offset operand handling, displayed such references as @0xNNNN, and added a focused regression using 01 FF 34 12. Updated the chapter’s z schema to describe both encodings.

Scene-bytecode documentation

Added docs/src/scene-bytecode.md and linked it from SUMMARY.md. The chapter documents the interpreter model, live data-segment state, operand notation, identified commands, startup sequence, mixed-content resources, inspection tool, and executable routines. Added a concise interpreter section and the new names to static-analysis.md.

Updated README.md with decoder examples and the two resources that require explicit command-region bounds. Marked BIN scene-command recovery complete in PLAN.md. Also corrected segment:offset notation in the chapter to show the resource base as 4C13:0000 and live cursor as 4C13:004B; the memory words are stored offset first because x86 is little-endian.

BIN-pass verification

Ran the complete repository verification rather than only the focused BIN tests:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/inspect_bin.py --help > build/analysis/inspect-bin-help.txt
tools/inspect_bin.py build/dd1/all/005_INTRO.BIN \
  > build/analysis/intro-bin.txt
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~bin_; afl~palette_resource; afl~music_resource; afl~scene; q' \
  build/analysis/CB_UNPACKED.EXE > build/analysis/bin-symbols.txt
mdbook build docs
test -f build/docs-book/scene-bytecode.html
git diff --check
git status --short
git diff --stat

All 33 unit tests passed in 1.261 seconds before the explicit string-offset regression was added. Every Python source compiled and both shell scripts parsed. The decoder’s help and full INTRO listing were written successfully, Rizin loaded all requested symbols, and mdBook produced build/docs-book/scene-bytecode.html. The tracked-source whitespace check passed. The graphics and BIN source changes remain uncommitted at this checkpoint.

After adding the string-offset case, repeated the full test, compile, shell, decoder, Rizin, mdBook, generated-page, and whitespace checks. All 34 tests passed in 1.267 seconds. The direct probe decoded 01 FF 34 12 as a load_art operand of kind string_offset, value 0x1234, and displayed it as @0x1234. Confirmed both new command-line tools retain executable file modes.

2026-07-15: Audio resource formats

Remaining-format inventory

After the user asked work to continue, reported that the next pass would classify the unresolved audio and text-bearing resources, correlate sound loads with the executable, and turn stable findings into tools, tests, and book documentation.

Confirmed the working tree was clean after commit b6c50df, reread PLAN.md, the file inventory, the DD1.DAT chapter, and the archive listing. Used DD1Archive to group every expanded member by extension and print the names, sizes, and first 24 bytes of the unclassified groups.

Results:

  • The 33 extensionless members are translation-specific Bible text. Names begin with T, R, N, or K, and their payloads visibly start with verse references followed by |-delimited prose.
  • All 21 MAP members are 768 bytes.
  • The 32 XMI members start with FORM, XDIR, and INFO IFF identifiers. There are 16 MUS### and 16 IBM### resources.
  • The 41 ABT members share a compact binary header and contain 205,513 expanded bytes.

Ran file and ffprobe on representative MUS001.XMI and D003.ABT files. file recognized XMI only as generic IFF and misidentified ABT as GeoSwath RDF. ffprobe rejected both. These host heuristics were recorded but not used as format evidence.

Searched existing documentation and executable strings for ABT, XMI, MUS, IBM, DIGPAK, and MIDPAK references. The executable templates are MUS000.XMI at load offset 0xF1DA and D000.ABT at 0xF1E6. An initial Rizin command used / ABT, which is not valid in the installed Rizin version and printed search-command help. Switched to data-offset and function inspection rather than treating that failed search as a result.

Digital-effect loader and decoder

Saved focused disassembly in ignored files:

build/analysis/audio-xrefs.txt
build/analysis/audio-functions.txt
build/analysis/digital-audio-loader.txt
build/analysis/abt-decoder.txt
build/analysis/audio-opcode-handlers.txt

The critical path is:

  • Scene opcode 0x57 reads a byte and word and calls 0x417F.
  • 0x417F writes the effect number into D000.ABT, stops and releases the previous effect, loads the archive member, reads its first word as the decoded allocation size, calls far routine 0x92E0, constructs the DIGPAK playback state, and preformats it through the interrupt 66h wrapper at 0x8F2E without starting playback.
  • 0x4235 stops an active instance, resets completion state, and starts the retained preformatted effect.
  • 0x92D0 returns the word at resource offset 2.
  • 0x92E0 is the complete ABT decoder, with packed-delta helpers at 0x93BE, 0x94CB, and 0x956E.

Translated the decoder into a temporary Python probe. The nine-byte header is decoded sample count, sample rate, delta-block output count, codec byte, auxiliary word, and initial sample. The main stream has absolute-sample, run-length, and adaptive packed-delta commands. The three helpers expand one-, two-, and four-bit codes most-significant bits first, add signed table values, and clamp each new sample to 0 through 255.

Ran the probe against every ABT member. All 41 files decode to their declared length and consume their compressed bytes exactly. Header values are 9,000 Hz, 32 samples per delta block, and codec identifier 2 in every member. The decoder ignores the auxiliary word; its population is 128 in 35 files, 32 in four, 64 in one, and 320 in one.

The complete output is 412,282 unsigned eight-bit samples, approximately 45.809 seconds. The stream uses 71,094 absolute commands, 1,699 runs, 2,125 one-bit blocks, 1,185 two-bit blocks, and 6,533 four-bit blocks.

Enumerated all scene opcode 0x57 operands with inspect_bin. Nonzero effects range from 1 through 41, and every nonzero invocation supplies rate 9,000. Several scenes use operands 0, 0 to stop playback and release the retained sample. Opcode 0x58 starts or restarts the prepared sample. This connects the script schema directly to D001.ABT through D041.ABT.

XMI container and event structure

Inspected MUS001.XMI with xxd and a temporary recursive IFF walker. Its complete structure is:

FORM XDIR
  INFO
CAT  XMID
  FORM XMID
    TIMB
    EVNT

Repeated the walker across all 32 files. IFF lengths are big endian, INFO contains little-endian sequence count 1, every catalog contains exactly one FORM XMID, and each form contains TIMB followed by EVNT. TIMB is an even-length series of patch/bank byte pairs.

Implemented a temporary XMIDI event parser. It sums sub-0x80 delay bytes, handles fixed-size channel events, reads the duration after each note-on, handles variable-length system-exclusive and meta payloads, and stops on FF 2F 00. Eleven EVNT chunks retain one zero byte after end-of-track. Across the archive, the streams contain 7,087 events, including 6,608 notes, and the TIMB chunks contain 173 pairs.

QEMU decoded-PCM capture

Reported that QEMU would be used for an independent runtime codec check with its visible Cocoa display and silent audio backend. Launched a snapshot-backed copy of the persistent disk with the debugger paused:

qemu-system-i386 \
  -name 'Captain Bible ABT capture' \
  -machine pc -accel tcg -cpu pentium -m 16 -boot c \
  -drive file=build/captain-bible/captain-bible.img,format=raw,\
if=ide,index=0,media=disk,snapshot=on \
  -vga std \
  -audiodev none,id=audio0 \
  -device sb16,audiodev=audio0 \
  -device adlib,audiodev=audio0 \
  -display cocoa,zoom-to-fit=on \
  -monitor unix:build/qemu-trace/audio-monitor.sock,server=on,wait=off \
  -gdb tcp:127.0.0.1:1234 -S

The first LLDB batch attempt set a breakpoint at physical 0xA499 and hit 0627:4229, but the batch client disconnected after continue and allowed the guest to resume before the buffer could be dumped. Stopped that QEMU instance, relaunched it, and kept LLDB connected interactively.

At the same breakpoint, immediately before the interrupt 66h playback call, the registers included CS=0627, EIP=4229, DS=14E1, and EAX=A0DE. Reading physical 0x1EEEE, which corresponds to DS:A0DE, returned:

0000 5A45 2368 79EC 14E1 2328 FFFF 0000

This state contains decoded buffer 5A45:0000, sample count 0x2368 (9,064), callback 14E1:79EC, and rate 0x2328 (9,000). Used LLDB to write 9,064 bytes from physical 0x5A450 to ignored file build/qemu-trace/d003-live.pcm, then detached and terminated QEMU cleanly.

Compared the live bytes with the temporary host decoder. Both are 9,064 bytes, are byte-for-byte equal, and have SHA-256:

ca97ad22acf3cc39d078b619168fa026deb1606082999bfb8b9a1aac4957422b

Reported this result to the user. It independently validates every ABT command family used by D003.ABT, including packed bit order, signed delta tables, clamping, and output length.

Reproducible audio tools

Added executable tools/convert_abt.py. It translates the executable’s codec, checks all bounds and exact input consumption, prints header and command statistics, and optionally writes standard unsigned eight-bit mono WAV.

Added executable tools/inspect_xmi.py. It recursively validates IFF sizes, padding, XDIR counts, XMID form and chunk order, TIMB pairs, event boundaries, durations, variable-length quantities, end-of-track, and zero padding.

Added tests/test_audio_formats.py, backed directly by CB/DD1.DAT. Tests cover all 41 ABT resources, the QEMU-validated D003 PCM hash, WAV properties, ABT truncation and trailing data, all 32 XMI resources, MUS001 event counts, and damaged XMI structures.

The first focused test run had six passes and one error. The whole-XMI test rejected a high-bit channel parameter in MUS016.XMI. Inspection found the deliberate event sequence B1 00 FF, alongside similar controller setup for other channels. The temporary parser had already accepted it because fixed event sizes make the boundary unambiguous. Removed the generic MIDI seven-bit parameter restriction from the repository validator, preserving the supplied XMIDI bytes, and repeated the run. All seven audio tests passed.

Exercised both command-line paths:

tools/convert_abt.py \
  build/dd1/all/306_D003.ABT --output build/audio/d003.wav
tools/inspect_xmi.py build/dd1/all/267_MUS001.XMI
file build/audio/d003.wav
ffprobe -v error \
  -show_entries stream=codec_name,sample_rate,channels,duration \
  -of default=noprint_wrappers=1 build/audio/d003.wav

The ABT tool reports 9,064 samples, rate 9,000, duration 1.007 seconds, and all five command families. file and ffprobe independently recognize the output as mono unsigned eight-bit PCM at 9,000 Hz. The XMI tool reports one sequence, 12 timbres, 446 events, 432 notes, eight meta events, and additive delay 3,016 for MUS001.XMI.

Audio documentation and symbols

Added docs/src/audio-formats.md and linked it from SUMMARY.md. Updated the README with ABT-to-WAV and XMI inspection examples, the static chapter with the loader and decoder path, the dynamic chapter with the live QEMU buffer, and the scene-bytecode chapter with opcodes 0x57 and 0x58.

Named the effect loader, prepare/start paths, ABT header helper, main decoder, and three packed-delta helpers in analysis/cb.rz. Added corresponding names for opcodes 0x57 and 0x58 in tools/inspect_bin.py. Marked ABT/XMI format recovery and reproducible audio tooling complete in PLAN.md; the broader format task remains open because text and map families still need dedicated passes.

The first full verification run passed all source and documentation checks but Rizin reported Failed to run script 'analysis/cb.rz'. The initial aaa pass did not define every ABT helper as a function, so afn could not rename one of the new offsets. Added explicit af commands for 0x4155, 0x417F, 0x4235, 0x92D0, 0x92E0, 0x93BE, 0x94CB, and 0x956E before assigning names.

Audio-pass verification

Repeated the complete verification after the symbol-script correction:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/convert_abt.py \
  build/dd1/all/306_D003.ABT \
  --output build/audio/final-d003.wav \
  > build/audio/d003-summary.txt
tools/inspect_xmi.py build/dd1/all/267_MUS001.XMI \
  > build/audio/mus001-summary.txt
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~sound_effect; afl~abt_; afl~decode_abt; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/audio/audio-symbols.txt
mdbook build docs
test -f build/docs-book/audio-formats.html
git diff --check
rg -n '[[:blank:]]+$' \
  docs/src/audio-formats.md tests/test_audio_formats.py \
  tools/convert_abt.py tools/inspect_xmi.py
git status --short

All 41 tests passed in 1.298 seconds. Every Python source compiled, both shell scripts parsed, both audio CLIs completed, and Rizin listed all eight new effect/ABT symbols at their intended offsets. mdBook generated the audio chapter. Tracked and new files have no whitespace errors. The audio-pass changes remain uncommitted pending a requested checkpoint.

Full-screen frame and palette inventory

Started from a clean worktree and searched the archive for ART descriptors whose origin is (0, 0) and whose dimensions are exactly 320×200. The first short Python probe attempted to import a nonexistent parse_art helper from tools/render_art.py and failed with ImportError. Inspected that module, used its actual ArtResource.from_bytes interface, and repeated the probe.

The corrected enumeration found exactly 11 full-screen frames, all at frame index 0: archive entries 006 INTRO.ART, 063 PRAY.ART, 073 OVER.ART, 090 LAW1.ART, 093 KABLAM1.ART, 097 SPEAKER.ART, 100 HOLE.ART, 122 DOME.ART, 130 DENY1.ART, 133 CULTA.ART, and 165 BOSS.ART.

Decoded the resource-loading command context in every BIN scene program. Associated the active palette selected by opcode 0x4D or 0x6D with the subsequent ART load at opcode 0x01. This produced one unambiguous palette for every full-screen resource:

INTRO.ART   TITLE.PAL
PRAY.ART    PRAY.PAL
OVER.ART    1.PAL
LAW1.ART    LAW.PAL
KABLAM1.ART KABLAM.PAL
SPEAKER.ART 1.PAL
HOLE.ART    HOLE.PAL
DOME.ART    DOME.PAL
DENY1.ART   DENY.PAL
CULTA.ART   1.PAL
BOSS.ART    BOSS.PAL

Added executable tools/render_fullscreen_gallery.py. It reads DD1.DAT directly, validates and discovers all full-screen ART frames, decodes the known command regions of the scene programs to infer their palettes, and renders an RGB contact sheet in archive order. Labels sit outside the game frames and give the archive identifier, ART name, frame number, and palette. The optional --scale argument enlarges pixels with nearest-neighbor sampling.

Added tests/test_fullscreen_gallery.py. Its tests lock down the exact set and order of 11 identifiers, the inferred ART-to-PAL mapping, and the dimensions and mode of the native four-column sheet. The first focused run passed the two inventory tests but failed the layout assertion because the expected height was miscalculated as 828 rather than 816 pixels. Corrected the expected value and repeated the focused suite; all three tests passed.

Generated both native and two-times artifacts and inspected each rendered PNG:

chmod +x tools/render_fullscreen_gallery.py
python3 -m py_compile \
  tools/render_fullscreen_gallery.py tests/test_fullscreen_gallery.py
python3 -m unittest -v tests.test_fullscreen_gallery
tools/render_fullscreen_gallery.py \
  CB/DD1.DAT \
  --output build/graphics/full-screen-gallery.png
tools/render_fullscreen_gallery.py \
  CB/DD1.DAT --scale 2 \
  --output build/graphics/full-screen-gallery-2x.png
file \
  build/graphics/full-screen-gallery.png \
  build/graphics/full-screen-gallery-2x.png
shasum -a 256 \
  build/graphics/full-screen-gallery.png \
  build/graphics/full-screen-gallery-2x.png

The native sheet is a 1348×816 RGB PNG with SHA-256 f9d5e6330041ad736f072ae9a90fc7328355857a8adc8f26bfc70e7dbf41dfcb. The enlarged sheet is a 2696×1632 RGB PNG with SHA-256 d91fcb4254eddee5458713f661f08269d138410d1ef224662fc13ab8904d7ef1. Visual inspection confirmed all 11 labels, frame boundaries, palette colors, and nearest-neighbor scaling.

KABLAM1.ART appeared almost entirely black, so counted its raw pixel values to distinguish a rendering error from source content. It has 16 distinct indices, dominated by indices 100 through 107; KABLAM.PAL maps those entries to very dark colors. Retained it because it is a genuine full-screen base frame used with subsequent KABLAM overlay artwork. Updated README.md, PLAN.md, and this book’s graphics chapter with the reproducible command, inventory, and palette evidence. Reported the completed visual check and this reason for retaining the dark frame to the user.

The user briefly expanded the requested scope to all ART resources, then restored it to full-screen images only before implementation changed. During that check, enumerated the broader population as 143 ART resources containing 1,178 frames. The scene-command association pass gives one palette to 113 resources, multiple possible palettes to 17, and no direct association to 13. Kept the completed 11-frame gallery design and artifacts unchanged, as requested.

Ran the complete repository verification after the scope confirmation:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/render_fullscreen_gallery.py \
  CB/DD1.DAT \
  --output build/graphics/full-screen-gallery.png
tools/render_fullscreen_gallery.py \
  CB/DD1.DAT --scale 2 \
  --output build/graphics/full-screen-gallery-2x.png
file \
  build/graphics/full-screen-gallery.png \
  build/graphics/full-screen-gallery-2x.png
shasum -a 256 \
  build/graphics/full-screen-gallery.png \
  build/graphics/full-screen-gallery-2x.png
mdbook build docs
test -f build/docs-book/graphics-formats.html
git diff --check
rg -n '[[:blank:]]+$' \
  PLAN.md README.md docs/src/graphics-formats.md \
  docs/src/progress-log.md tests/test_fullscreen_gallery.py \
  tools/render_fullscreen_gallery.py
git status --short

All 44 tests passed in 1.930 seconds. Every Python source compiled, both shell scripts parsed, both gallery variants regenerated with the documented sizes and hashes, and mdBook rebuilt the graphics chapter. The generated sources have no whitespace errors. The gallery implementation and documentation are uncommitted pending a requested checkpoint. Reported both image paths, the reproduction command, test result, and the intentional inclusion of the dark KABLAM1.ART frame to the user.

The user requested a commit checkpoint. Inspected the complete diff, including the two new source files, and confirmed it contains only the full-screen gallery generator, its regression tests, and the corresponding plan, README, graphics chapter, and progress-log updates. Prepared these six files as one cohesive gallery commit after the successful 44-test verification above. Staged the six files, ran git diff --cached --check, and created the commit with subject docs: Add full-screen artwork gallery. Verified that every commit-message line is at most 72 characters and that the worktree is clean.

2026-07-15: Verse indexes and companion text

Remaining-format inventory

After the user asked reverse-engineering work to continue, reported that the next pass would recover the text-bearing resources and their runtime consumers. Confirmed the worktree was clean at commit e546d98, reread the open plan items and mdBook summary, and grouped all 369 DD1.DAT members by extension and expanded size:

ART       143 resources  4,864,835 bytes
BIN        62 resources    179,200 bytes
ABT        41 resources    205,513 bytes
PAL        37 resources     28,416 bytes
no suffix  33 resources    215,113 bytes
XMI        32 resources     34,618 bytes
MAP        21 resources     16,128 bytes

Selected the 33 extensionless resources because the executable’s built-in study export provides an independent validation path. The first inventory probe incorrectly referenced DD1Entry.packed_size; the actual field is stored_size, so Python raised AttributeError. A follow-up sample query also tried nonexistent long names such as GEN and MAT before the complete listing showed that the real names are two-character translation/bank pairs. Corrected both assumptions and recorded all archive indexes, sizes, headers, and tails.

The members are T, R, N, or K plus bank A through G or R. These prefixes correspond to The Living Bible, Revised Standard Version, New International Version, and King James Version. NG occurs at indexes 199 and 206 and the two expanded payloads are byte-identical.

Verse-index structure

Dumped representative resources with xxd, counted control bytes and NUL positions, and printed the text around candidate boundaries. Each ordinary record begins with a nonzero selector byte, a little-endian word, and a NUL-terminated CP437 string containing one |. The text on the left of the pipe is a citation and the text on the right is a translated verse.

The initial parser treated every three-byte header as an ordinary record and failed at the end of TR with ValueError: ('string', 1287). Tail inspection showed that the final three bytes are instead a zero selector and terminal offset with no string. Corrected the parser and validated every extensionless member to its exact end.

Compared the selector/offset pairs across translations. All four translations have identical structural headers for each corresponding bank. Each bank’s terminal offset exactly equals its companion file size:

A 0x2751  B 0x203d  C 0x0fb4  D 0x3a7d
E 0x28f9  F 0x2811  G 0x2709  R 0x02b8

Offsets can repeat. Those zero-length ranges identify verses that have no companion text, rather than malformed records. There are 319 logical verse records in every translation.

Executable text path

Used Rizin with analysis/cb.rz to disassemble the existing export_game_text function and its helpers. Saved ignored working output in build/analysis/export-game-text.txt, text-loader-629c.txt, text-helper-functions.txt, text-selector-5ad6.txt, and focused caller dumps.

Function 0x629C constructs the extensionless name from a translation table and the requested bank letter, loads it from DD1.DAT, and parses exactly the record layout above. It builds ten-byte runtime entries containing the far verse pointer, selector, companion offset, and the span obtained by subtracting the next offset. It then opens the matching DDL file.

Function 0x5AD6 linearly finds a record by selector. Function 0x5CE2 copies either the citation/verse or a requested tagged string from the companion range. Function 0x5EE7 wraps export text at 70 columns. Named these functions load_text_bank, find_text_record_by_selector, copy_text_record_component, and write_wrapped_export_text in analysis/cb.rz.

The selector is an exact lookup key. The export routine separately checks for selectors at or above 0xE0 when mature topics are disabled, establishing the meaning of that range without assigning semantics to the remaining key bits.

DDL tagged strings

Listed and hashed DDLA through DDLR, then parsed each entire file as an ASCII tag followed by a NUL-terminated CP437 string. All 68,746 bytes consume exactly as 1,058 records with tags L, P, W, C, E, *, or M. Correlated the tags with literal export headings and mask checks in the executable:

L  CYBER LIE                         277 records
P  PARAPHRASE                        123 records
W  WRONG GUESS                       210 records
C  CORRECT GUESS                      67 records
E  EXPLANATION OF CORRECT GUESS       68 records
*  CONVERSATION WITH VICTIM           58 records
M  numeric/internal metadata         255 records

The index spans cover 1,057 of those records. DDLF has one 26-byte E preamble before its first indexed offset, so retained it as validated but unassociated data. The earlier count of 1,130 companion records was an arithmetic mistake; summing the per-tag counts gives 1,058.

A manual search command initially passed a pattern beginning with - to rg without its -- option, producing rg: unrecognized flag -|. Repeated the search as rg -n -- ... and found the advanced -sXfilename and -gXX options in MANUAL.TXT, including the installation-lock warning and all six export-mask values.

Visible QEMU export validation

Created the ignored build/text/export-autoexec.bat and a copy-on-write clone of the play image. Replaced both FreeDOS startup scripts in that clone with a batch file that ran the game exporter, wrote an EXPORT.OK marker, and called FDAPM POWEROFF. Verified the scripts with mtype, then launched QEMU visibly and without audio:

qemu-system-i386 \
  -name 'Captain Bible text export' \
  -machine pc -accel tcg -cpu pentium -m 16 -boot c \
  -drive file=build/text/export.img,format=raw,if=ide,index=0,media=disk \
  -vga std \
  -audiodev none,id=audio0 \
  -device sb16,audiodev=audio0 \
  -device adlib,audiodev=audio0 \
  -display cocoa,zoom-to-fit=on \
  -no-reboot

Inside DOS, the batch invoked CB -g63 -sTSTUDY.TXT. QEMU powered off with exit status zero after about 13 seconds. Extracted the generated study file with mcopy; it is 132,510 bytes, 3,854 lines, and has SHA-256 c9ebe2cc4fbd00cd709d87761b38f6a8843eae99ceaa75cef842b93364dad0bc.

The output used NIV wording rather than the requested Living Bible wording. Inspected SOUND.5 as 01 00 00 00; runtime translation index 1 is NIV, so the installed lock overrode -sT exactly as the manual describes. Normalized the exporter’s line wrapping and compared every parsed NIV verse. All 302 emitted verses match. The 17 missing verses are exactly all records whose selectors are 0xE1 through 0xE4, independently validating the active mature-topic filter. The game-authored headings and contents also match the parsed companion tags.

Reproducible inspector and focused tests

Added executable tools/inspect_text_resources.py. It validates the terminal record, pipe delimiter, CP437 text, nondecreasing offsets, companion size, known tags, exact NUL termination, duplicate-resource identity, and requested translation/bank. It joins each verse to its tagged DDL span and can display one record or a whole bank.

Added tests/test_text_resources.py with six tests covering all 32 logical translation/bank pairs, header equality across translations, the duplicate NG resources, the QEMU-validated first NIV record, valid zero-length spans, and damaged index/companion rejection.

The first focused run produced four errors and one failure because the parser compared an integer tag byte with string keys in TAG_LABELS. Converted the byte to a character before validation. The next run passed five tests but the aggregate count expected 4 * 1130; corrected the arithmetic and explicitly validated all companion bytes, including the single unindexed preamble. The third focused run passed all six tests in 0.185 seconds.

Exercised the CLI against NIV bank A record zero and confirmed it prints the archive index, selector, companion range, citation and verse, followed by the expected L, P, four W, C, and E records. Added docs/src/text-formats.md, linked it from the mdBook summary, updated the file inventory and static function map, documented CLI usage in README.md, and marked text recovery, tooling, and format documentation complete in PLAN.md. Reported the corpus counts, exact QEMU validation, mature filtering, and documentation consolidation to the user.

Text-pass verification

Ran the complete repository verification after documentation and symbol updates:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/inspect_text_resources.py \
  CB/DD1.DAT --data-dir CB \
  --translation N --bank A --record 0 \
  > build/text/inspect-niv-a0.txt
rizin -q -b 16 -a x86 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~text_; afl~export_game_text; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/text/text-symbols.txt
mdbook build docs
test -f build/docs-book/text-formats.html
git diff --check
rg -n '[[:blank:]]+$' \
  PLAN.md README.md analysis/cb.rz \
  docs/src/file-inventory.md docs/src/static-analysis.md \
  docs/src/SUMMARY.md docs/src/text-formats.md \
  docs/src/progress-log.md tests/test_text_resources.py \
  tools/inspect_text_resources.py
git status --short

All 50 tests passed in 2.185 seconds. Every Python source compiled, both shell scripts parsed, the text inspector produced the expected joined NIV record, and Rizin loaded the new text symbols at their intended offsets. mdBook built the new text-format chapter successfully. Tracked and new files have no diff or whitespace errors. The text-format changes remain uncommitted pending a requested checkpoint.

The user requested a commit checkpoint for the text-format pass. Inspected the final diff and confirmed it contains only the verse-index and DDL recovery, inspector and tests, Rizin symbols, mdBook chapter, inventory, README, plan, static-analysis, and progress-log updates. Prepared these ten files as one cohesive commit after the successful 50-test verification above. Staged all ten files, ran git diff --cached --check, and created the commit with subject text: Recover study resource formats. Verified that every commit-message line is at most 72 characters and that the worktree is clean.

2026-07-15: save-game format and player prefixes

The user asked to continue reverse engineering. Selected the next open file- format milestone: fully recover the supplied save formats and player-name behavior, turn the result into a strict inspector, and document it. Reported that the work would begin from the ten supplied save files and static comparisons, then trace filenames and scalar fields.

Corpus inventory and comparisons

Ran git status --short, inspected the recent log, searched the plan, README, book, and ignored analysis outputs with rg, and used stat, SHA-256, and short Python scripts on CB/DDGAMES.SV0 through SV9. The current starting commit was 3c42783 text: Recover study resource formats, and the tracked worktree was clean. Confirmed again that SV0 is 243 bytes and all nine state files are 2,752 bytes.

Decoded SV0 as nine 27-byte buffers. Every visible C string is EMPTY, but bytes after the first NUL include fragments such as id II and rney SL. These are stale buffer tails, not label text: the executable uses strcpy rather than clearing all 27 bytes. Noted that the executable’s missing-index initializer is (EMPTY), whereas the supplied file contains EMPTY without parentheses.

Compared every state pair and each byte position across the corpus. Found that SV6 and SV8 are identical, 2,477 of 2,752 offsets are constant, and 275 vary. The group containing SV1, SV2, SV5, SV6, SV7, and SV8 often differs by only one byte; SV3 and SV4 also differ by one byte, while SV9 differs more substantially.

Dumped every known block and compared the paired regions. Each 200-byte primary pair differs at byte 56; SV9 additionally differs at byte 54. The two 768-byte blocks are identical inside each save. They are zero in most files, while SV3 and SV4 each contain 112 nonzero bytes. The four 20-byte buffers decode as LOGO, LOGO, seg, and seg; some files retain stale bytes after those C strings’ NUL terminators.

All supplied states contain translation index 1, music flag 1, effects flag 1, checkpoint bank word 67, and live bank word 67. The latter two are ASCII C. All compact descriptor-state bytes are zero. Descriptor pointers in SV9 use a different segment from the other supplied files, an early sign that those pointer words are process-dependent.

Static save-path tracing

Generated these ignored evidence files with scripted Rizin sessions over build/analysis/CB_UNPACKED.EXE:

  • build/analysis/save-pass-functions.txt
  • build/analysis/game-main-save-pass.txt
  • build/analysis/save-field-xrefs.txt
  • build/analysis/save-name-control-flow.txt
  • build/analysis/save-menu-flow.txt
  • build/analysis/save-main-loop.txt
  • build/analysis/save-snapshot-callers.txt
  • build/analysis/save-snapshot-exact.txt

The sessions ran aaa and analysis/cb.rz, then used pdf, pd, axt, px, and ps around the save functions, game_main, the options menu, the main event loop, and DS references 0042, 0045, 0048, 004A, 007C, 0080, and 4A02. One command requested pdf @ main, which failed because the configured name is game_main; reran it with the correct symbol. Another attempt used the unavailable pdc command; retained ordinary function and recursive disassembly instead.

Recovered the exact checkpoint-copy behavior at load offsets 0x7D8E and 0x7E41. The forward routine copies 200 bytes from DS 727A to 7BF2, byte +4 from each of 66 ten-byte descriptors at B194 into 3A66, all 16×16×3 bytes from 5B16 to 76EC, the C strings at B83E/AEFE to 6EA6/8938, and word 0080 to 9FB0. The inverse routine reverses those copies and reloads the selected text bank. Forward-copy callers are new- session initialization and a scene command, not the state writer itself. This explains the manual’s warning that saves in conversations can resume at the beginning of a scene: the file contains both checkpoint and live state.

Recovered the ten-byte runtime descriptor layout as far pointer offset, far pointer segment, persistent state byte, selector, companion offset, and companion span. The loader reconstructs all structural and pointer fields while preserving byte +4; the separate 66-byte block is its checkpoint copy. Loaded NIV bank C through tools/inspect_text_resources.py and compared it with SV1. All 46 active descriptors match exactly in selector, companion offset, and span, and the remaining 20 structural records are zero.

The first ad-hoc comparison script failed with ModuleNotFoundError because inspect_text_resources.py imports extract_dd1 as a tool-directory module. Added tools to that script’s temporary sys.path and reran successfully. This was an inspection-only correction; no source was changed to accommodate the one-off command.

Traced the scalar cross-references. DS 0048 is the music-enabled flag: music playback tests it and the options menu toggles it. DS 004A is the sound-effects flag and is likewise tested and toggled. DS 007C is the translation index established in the text pass. DS 0080 is the current text-bank character, copied to checkpoint word 9FB0.

Traced game_main and the suffix data. Startup copies the literal DDGAMES to DS 4A02. A non-option command-line argument replaces it verbatim via unbounded strcpy; the executable does not enforce the manual’s DOS filename, path, or eight-character rules. The static suffix at DS 0042 is .SV0. Both index and state routines create names by copying the player prefix and appending this mutable suffix.

Disassembled slot selection at 0x2B6F and the main loop around 0x891B. The menu changes the suffix’s last byte to ASCII 1 through 9. F10 changes it to Q, updates live rendering/state, calls write_save_state, and restores 0. F9 makes the same temporary change around read_save_state. Reported to the user that this proves .SVQ is the independent tenth state. The empty-slot helper uses the selected digit to address one 27-byte label and, when empty or equal to (EMPTY), copies a generated Game 1 through Game 9 default into it.

Confirmed the write routine emits the exact 15 blocks in the documented order and rewrites SV0 first. The read routine mirrors the layout. It returns failure if fopen fails, but does not test the 15 individual fread counts before refreshing sound/text state and reporting success. This is why the host inspector rejects damaged sizes even though the original game does not reliably do so.

Reproducible save inspector

Added executable tools/inspect_save.py. It detects an index or state by the two exact sizes, decodes CP437 C-string buffers, preserves and reports stale label tails, parses all 66 text descriptors, names the recovered settings, separates live/checkpoint regions, counts snapshot differences, and optionally lists nonempty descriptors.

Added tests/test_inspect_save.py with seven tests. They cover all ten supplied files, nine visible EMPTY labels and stale tails, exact parser size selection, damaged-size rejection, scalar and C-string regressions, compact state bytes versus descriptor byte +4, live/checkpoint table equality, the known SV6/SV8 duplicate, and the complete NIV bank C descriptor match.

Ran:

chmod +x tools/inspect_save.py
python3 -m unittest tests.test_inspect_save -v
tools/inspect_save.py CB/DDGAMES.SV0
tools/inspect_save.py CB/DDGAMES.SV3 --descriptors

All seven focused tests passed. The index CLI displayed nine labels and stale- tail counts; the state CLI reported translation 1, both audio flags enabled, bank C, four expected resource strings, 46 active descriptors, no active state bytes, one primary checkpoint difference, and no three-byte-table difference. Renamed an initially proposed world_table field to the conservative three_byte_table because gameplay meaning is not yet proven.

Documentation and symbols

Added docs/src/save-formats.md and linked it from SUMMARY.md. The chapter documents player-prefix handling, all ten filenames, fixed label records, stale bytes, every state block, checkpoint direction, descriptor structure, supplied-file statistics, original error handling, inspector usage, open questions, and relevant functions. Replaced the older preliminary save table in static-analysis.md with a concise verified summary and chapter link; updated the save inventory link and added inspector usage to README.md.

Marked save-format/player-prefix recovery and the inspector complete in PLAN.md. Added choose_save_slot and save_selected_slot to analysis/cb.rz. Reported during the pass that the major save blocks were pinned down, that the snapshot relationship was established by copy direction, and that filename and scalar tracing was in progress; later reported the exact .SV0/slot/.SVQ mutation and the 46-record NIV bank C match.

Save-pass verification

Reported that the inspector and focused tests were complete, that the book now distinguishes checkpoint and live state from executable evidence, and that a full repository verification was starting. Ran:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/inspect_save.py CB/DDGAMES.SV0 \
  > build/analysis/save-index-inspection.txt
tools/inspect_save.py CB/DDGAMES.SV3 --descriptors \
  > build/analysis/save-state-inspection.txt
rizin -q -b 16 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~save_; afl~choose_save_slot; q' \
  build/analysis/CB_UNPACKED.EXE \
  > build/analysis/save-symbols.txt
mdbook build docs
test -f build/docs-book/save-formats.html
git diff --check
rg -n '[[:blank:]]+$' \
  PLAN.md README.md analysis/cb.rz \
  docs/src/SUMMARY.md docs/src/file-inventory.md \
  docs/src/static-analysis.md docs/src/save-formats.md \
  docs/src/progress-log.md tests/test_inspect_save.py \
  tools/inspect_save.py
git status --short

All 57 tests passed in 2.114 seconds. Every Python source compiled and both shell scripts parsed. Both save-inspector modes ran successfully, Rizin loaded the expanded symbol file, and mdBook built the new chapter at build/docs-book/save-formats.html. The diff and whitespace checks were clean. The save-format work remains uncommitted pending an explicit checkpoint request.

Performed a final status/diff review and displayed the inspector’s --help output. An initial chained command stopped after the whitespace rg returned status 1 for the expected “no matches” result, so it did not reach the status commands that followed. Reran git diff --check, git diff --stat, git diff --numstat, git status --short, and the help command independently. The diff check remained clean, the CLI showed its positional save path and optional --descriptors switch, and only the ten intended save-pass paths are modified or new.

Save-format commit preparation

At the user’s request, prepared the completed save-format pass for a commit. Ran git status --short, git diff --check, git diff --stat, and reviewed the tracked plan, README, Rizin, summary, inventory, and static-analysis diff. Confirmed that the ten modified or new paths form one cohesive change: save format recovery, its reproducible inspector and tests, the dedicated mdBook chapter, symbol additions, and project/log updates. The whitespace check was clean, and the full 57-test verification immediately above remains current; the only subsequent content change is this commit-preparation log entry.

Staged the ten intended paths and ran git diff --cached --check. Git found one extra blank line at the end of docs/src/save-formats.md, so the chained status/stat commands correctly stopped before commit. Inspected the final bytes with tail and xxd, removed the redundant newline with apply_patch, and prepared to restage both that correction and this log entry before running the staged checks again.

Restaged the correction and log. The second staged whitespace check passed; the staged summary contained exactly ten paths, with 801 insertions and 36 deletions. Created the requested checkpoint with subject save: Recover game-state formats and a detailed five-paragraph body covering the filename logic, state layout, text-resource correlation, inspector, tests, and rationale. Verified the initial commit message with awk; every line is at most 72 characters, and git status --short was empty. Added this final commit action to the activity log and folded it into the same checkpoint.

World-map resource and runtime-state recovery

After the user’s next continue, checked the worktree, the three most recent commits, the live plan, the scene-bytecode and save chapters, the BIN inspector, and existing analysis notes. The tree was clean at commit 3afe96f (save: Recover game-state formats). Selected the unknown saved 16×16×3-byte table as the next bounded target and reported that the pass would correlate BIN handlers, archive resources, and saved state, then add a tool where the evidence supported one.

Used Rizin cross-references for the live table at DS:5B16, its checkpoint at DS:76EC, script variables at DS:727A, and text descriptors at DS:3A66:

axt @ 0x5b16
axt @ 0x76ec
axt @ 0x727a
axt @ 0x3a66

Saved the inspection output under the ignored build/analysis/ directory as world-table-xrefs.txt, world-table-functions.txt, world-grid-control.txt, world-map-ui-and-opcodes.txt, and map-screen.txt. Several Rizin pdf requests warned that the linear size differed too much from the basic-block sum. Used pdr and direct bounded pd listings instead. Rizin’s inferred split around the map display is imperfect, but direct callers and control flow establish 0x075F as the actual show_map_screen entry.

The references repeatedly calculate 48*y + 3*x and access neighbors at offsets ±3 and ±48. This proves a row-major 16×16 grid of three-byte cells. Identified current coordinate words at DS:7290 and DS:7292. Reported that the saved table is live world-grid state, with cell[y][x] addressing, and that the next step was tracing the opcode handlers and map UI.

Disassembled the handlers around load offsets 0x034F, 0x0457, 0x075F, and 0x0C6C, plus the opcode dispatch region. The loader takes its level letter from opcode 0x78, indexes the executable literal END with script variable zero, appends the literal .MAP, and loads 768 raw bytes into DS:5B16. Checked the archive and found the complete 21-member product of levels A through G and suffixes E, N, and D, all exactly 768 expanded bytes. The manual names the corresponding modes Easy, Normal, and Difficult. Reported this resource-name construction and the 21-member result to the user.

An initial ad-hoc archive query failed with AttributeError because it used a nonexistent DD1Entry.full_name property. Repeated it with the actual filename property. Enumerated opcode 0x78 in every recovered BIN code region and found all level letters: C in FIRST and OUTC, F in OUTF, E in OUTE, D in OUTD, B in OUTB, A in OUTA, and G in OUTGL. Also found broad use of current-cell processing and mutation commands in power, room, combat, and hall programs.

Recovered these map-related opcode effects from direct handler behavior:

  • 0x77 processes the current map cell and consults adjacent cells.
  • 0x78 B loads the selected level and current difficulty map.
  • 0x7B H replaces the current cell’s low kind nibble while preserving its high nibble.
  • 0x7C H and 0x7F H write the cell’s second and third bytes.
  • 0x87 normalizes loaded or changed map cells.
  • 0x88 clears persistent byte +4 in all 66 text descriptors.
  • 0x89 sets explored_rows[y] |= 1 << x in the 16-word array at DS:72C4.

The F2 map display uses the exploration words to distinguish cells and splits the first cell byte into high and low nibbles. The high nibble selects one of 16 map connection/shape frames, while the low nibble selects a location kind. The screen uses the remaining bytes as text selectors for at least kinds 0x6 and 0xA. The manual independently says explored cells are gold, unexplored cells gray, stations and communication locations show verse references, and room markers use P, J, T, C, and V. Kept the field names conservative because individual connection-bit directions, the complete kind enumeration, and the general meanings of parameters A and B remain open.

Queried MAP.ART with the artwork renderer. It is archive index 12, expands to 38,490 bytes, and contains 63 frames: a 189×167 background, small map glyphs, and later UI assets. An attempted one-off import of parse_art from render_art failed because that is not the module’s public parser name; the supported command below succeeded and supplied the needed frame inventory:

tools/render_art.py build/dd1/all/012_MAP.ART --list

Compared each supplied save’s live grid at file offsets 0x4C0..0x7C0 with all 21 archive maps. SV3 and SV4 are closest to—and exactly identify as— CE.MAP, with only four changed fields: (2,0) parameter B changes 38->00, (0,1) parameter A changes 37->00, (1,1) packed byte changes A2->AB, and (2,1) packed byte changes E5->EB. The last two retain their connection nibbles and change their location kind to B. The normalizer at 0x0457 performs those kind changes; it also includes a transition from kind 6 to A that moves parameter B to A and clears B. Other supplied saves have zeroed grids and cannot be identified from this field. Reported this closed loop between the archive map and saved mutable state.

World-map inspector and documentation

Added executable tools/inspect_map.py. It validates a level/difficulty identifier, extracts the selected member directly from DD1.DAT, requires the exact 768-byte size, exposes row-major coordinates and the packed nibbles, prints a compact kind grid, optionally lists nonzero cells, and compares a resource against the live world map in a state save.

Added tests/test_inspect_map.py with five tests. They prove the complete 21-map level/difficulty cross product and exact sizes, a row-major CE.MAP coordinate regression, the four SV3 field mutations, all seven level letters supplied to opcode 0x78, and rejection of bad sizes, names, and coordinates. Added semantic names for opcodes 0x77, 0x78, 0x7B, 0x7C, 0x7F, 0x87, 0x88, and 0x89 to inspect_bin.py, and added the four recovered map-function names to analysis/cb.rz.

The first focused run was:

git status --short
python3 -m unittest tests.test_inspect_map tests.test_inspect_bin -v
tools/inspect_map.py \
  CB/DD1.DAT --map CE --compare-save CB/DDGAMES.SV3

All 14 focused tests passed, but the inspector invocation failed with Permission denied because its executable bit had not yet been set. Reported that the format tests passed and that only the missing executable bit failed, then ran:

chmod +x tools/inspect_map.py
tools/inspect_map.py \
  CB/DD1.DAT --map CE --compare-save CB/DDGAMES.SV3

The command printed the 16×16 low-nibble grid and the four expected save differences.

Added docs/src/world-maps.md and linked it from the book summary. The chapter documents resource naming, cell addressing, known fields, mutable runtime state, exploration bits, map-screen behavior, save correlation, tool usage, relevant functions, and explicitly open semantics. Updated the scene-bytecode command table, save-format chapter, static-analysis summary and function map, README inspector instructions, and PLAN.md. Marked world-map recovery and its inspector complete while leaving entities, conversations, combat, and progression open. Reported that documentation would distinguish proved layout and lifecycle from still-unknown parameter meanings and bit directions.

Ran the full verification:

git diff --check
git diff --stat
python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
mdbook build docs
test -f build/docs-book/world-maps.html
rizin -q -b 16 -e scr.color=false \
  -i analysis/cb.rz \
  -c 'afl~map_; afl~show_map_screen; q' \
  build/analysis/CB_UNPACKED.EXE

All 62 tests passed in 2.226 seconds, all Python sources compiled, mdBook built the new world-map page, the diff check was clean, and Rizin loaded and listed all four added symbols at their expected offsets. This world-map pass is not committed because the user has not requested a checkpoint yet.

Performed a final review that included untracked files, which are omitted by ordinary git diff --stat output. Rebuilt the book, reran all 62 tests (all passed in 2.201 seconds), checked every changed path for trailing whitespace, listed line counts for the three new files, and displayed the inspector’s --help output. The final status contains nine modified tracked files and the three intended new files: the world-map chapter, inspector, and focused test module. No whitespace errors were found.

World-map commit preparation

At the user’s request, prepared the completed world-map pass for a commit. Ran git status --short, git diff --check, and git diff --stat, then reviewed the tracked plan, README, Rizin symbols, book links and chapters, opcode-name additions, and all three new files. Confirmed that the 12 paths form one cohesive change covering the recovered map format, runtime behavior, save correlation, reproducible inspector, tests, symbols, and documentation. The whitespace check was clean, and the most recent full verification remains 62 passing tests plus a successful mdBook build.

Staged the 12 intended paths and ran git diff --cached --check; it passed. The staged summary contained 716 insertions and 8 deletions. Created the requested checkpoint with subject map: Recover world-state format and a detailed five-paragraph body covering the resource set, runtime evidence, conservative inspector model, save correlation, tests, and documentation. Verified the initial message with awk; every line is at most 72 characters. Added this final commit action to the log and amended it into the same checkpoint.

2026-07-15: script state and progression

Initial state-block inventory

Continued with the next open game-system slice: player state, progression, and the script commands which operate on them. Began by checking the worktree, recent commits, plan, README, existing save documentation, scene-bytecode notes, executable symbols, and the two primary 200-byte blocks already identified in each save. Used commands including:

git status --short
git log --oneline -5
rg -n "primary|727a|7bf2|variable|faith|flag" \
  PLAN.md README.md analysis docs/src tools tests
xxd -g 2 -l 200 CB/DDGAMES.SV0
xxd -g 2 -s 200 -l 200 CB/DDGAMES.SV0

Examined initialization and references to DS:727A and DS:7BF2 with Rizin, saving larger listings under the ignored build/analysis/ directory:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'aaa; axt @ 0x727a; axt @ 0x7bf2; q' \
  build/analysis/CB_UNPACKED.EXE
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'aaa; pdr @ 0x1191; q' build/analysis/CB_UNPACKED.EXE

Function 0x1191 sets CX=0x64, DI=0x727A, clears AX, and executes rep stosw. This proves that the primary live block is 100 words rather than an opaque 200-byte structure. The save snapshot at file offset 0x000 maps to DS:7BF2, while the live copy at file offset 0x0C8 maps to DS:727A.

Initially described opcode 0x1F too broadly and assigned the next operation to 0x20. That interpretation treated Rizin’s decimal switch labels as hexadecimal. Corrected the mistake before changing the parser: 0x1E copies a variable, 0x1F assigns an immediate, 0x20 branches on zero, and 0x21 branches on nonzero. Reported both the false start and the correction.

Variable opcode family

Dumped the interpreter’s switch handlers and direct byte ranges, then traced the shift-and-index sequence used for variable operands:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'aaa; pdr @ 0x451b; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/variable-opcode-handlers.txt
rizin -q -b 16 -e scr.color=false \
  -c 'pD 8192 @ 0x451b; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/variable-opcode-linear.txt

Some Rizin listings crossed analyzed data or incorrectly inferred function boundaries and displayed invalid instructions. Direct bytes, handler control flow, and the complete BIN corpus were therefore compared before assigning semantics. Recovered this core family:

  • 0x1E..0x21: copy, assign, branch on zero, branch on nonzero.
  • 0x22..0x29: equality, inequality, signed greater-than, and signed less-than branches using variable or immediate right operands.
  • 0x2A..0x31: addition, subtraction, signed multiplication, and signed division using variable or immediate right operands.
  • 0x32..0x33: increment and decrement.
  • 0x8F..0x90: bitwise AND with a variable or immediate.

The encoded references are even byte offsets within the 200-byte block. The interpreter shifts each reference right once before indexing. A Python corpus scan over all known executable regions in all 62 BIN resources found 39 of the 100 slots in use and no odd or out-of-range core-variable operands. The scan was saved as build/analysis/script-state-usage.txt.

An initial ad hoc scan passed a string to DD1Archive.from_path and failed with AttributeError: 'str' object has no attribute 'read_bytes'. Re-ran it with a Path. Attempts to render 247_GANTRY, 242_FIRST, and 296_ROBOT also failed because those archive indices were guessed incorrectly. Listing the archive found the correct member paths: 317_GANTRY, 325_FIRST, and 313_ROBOT.

FIRST.BIN establishes initial gameplay values: X is zero, Y is six, one unidentified variable is eight, state flag 0x36 is set, faith is 0x2710 (10,000), and the current map cell is processed. Cross-references identify the following live fields with high confidence:

  • variable 0: difficulty (0 easy, 1 normal, 2 difficult);
  • variables 11 and 12: current map X and Y;
  • variable 16: current map level letter;
  • variables 17 and 18: current cell parameters A and B;
  • variable 21: faith in hundredths of one percent.

Flags, faith, and text state

Traced helpers at 0x43F5, 0x4413, and 0x4433. Words 3 through 10 of the primary state also form a 128-bit flag bank. The test helper selects word identifier >> 4 and mask 1 << (identifier & 15); the other helpers set or clear the same bit. Scene opcodes 0x73..0x76 branch on clear/set and clear/set a flag. Corpus analysis found 78 identifiers through 0x55.

process_current_map_cell clears the first three flag words (0x00..0x2F) and rebuilds them from the current cell and its neighbors. Flags 0x30 and above survive this transient rebuild. UI checks establish these powerups:

  • 0x30 sword, 0x31 shield, 0x32 no-trap protection;
  • 0x33 candle and 0x34 flight.

Seven victim scene scripts set distinct rescue flags: JELO 0x3A, FEAR 0x3B, CULT 0x3C, LAW 0x3D, RICH 0x3E, DENY 0x3F, and NAGE 0x40. GANTRY.BIN tests all seven and mirrors set members to 0x42..0x48 before the Unibot sequence. The exact later meaning of that mirror bank remains open and was not overnamed.

Traced reduce_faith at 0x3979, called by scene opcode 0x81. It skips the loss while a no-combat state is active, halves the operand on Easy, preserves it on Normal, and multiplies it by four on Difficult. The status renderer clamps faith to 0..10000, divides by 100, and presents a percentage.

The helpers at 0x5B24, 0x5B76, and 0x5BBF get, set, and clear one of 66 text-descriptor state bytes. Opcodes 0x36..0x39 update them or branch on their state; 0x88 clears all 66. This connects dialogue and study records to scene progression, although the exact user-visible meaning varies by record and remains documented conservatively.

Compared both primary blocks in every supplied save with od, xxd, and a small Python decoder. All snapshots and live blocks have map-level variable 16 equal to -1. Live variable 28 contains 191, 189, 192, 195, 193, 195, 192, 195, 204; SV9 also has variable 27 equal to five. None of the supplied blocks has an active state flag. Variables 27 and 28 were left unnamed because the evidence only supports their being general scene temporaries.

Inspectors, tests, and documentation

Extended tools/inspect_save.py with exact-size decoding of the 100 signed words, the embedded flag bank, named fields, powerups, and victim flags. The new --variables view prints named, nonzero, or changed variables without misrepresenting the flag-bank words as independent values. Extended tools/inspect_bin.py with the recovered opcode names and renders recognized operands as, for example, var[21:faith]@0x002a.

Added focused tests for signed values, exact sizes, supplied-save regressions, the complete-corpus even-offset invariant, semantic opcode names, and the seven exact rescue flags. The first test edit accidentally placed helper functions after unittest.main() and referenced self.decoded_regions from another test module. The focused run produced three errors. Corrected the imports and iterated archive BIN members and their known executable regions directly; the next focused run passed all 22 tests.

Named the recovered executable functions in analysis/cb.rz. Added the Script State and Progression mdBook chapter and linked it from the summary. Updated the save-format, scene-bytecode, and static-analysis chapters, plus the README and living plan. Reported that the recovered system consists of three coordinated mechanisms: 100 signed script words, a 128-bit flag bank, and 66 text-state bytes, with static proof for powerups, victim flags, and faith handling.

Script-state commit preparation

At the user’s request, inspected the unstaged scope with:

git status --short
git diff --stat
git diff --check
git diff -- PLAN.md README.md docs/src/SUMMARY.md docs/src/game-state.md
git diff -- tools/inspect_bin.py tools/inspect_save.py \
  tests/test_inspect_bin.py tests/test_inspect_save.py

The diff check was clean. Found that the new state-flag helper row followed the later bytecode-interpreter row in the high-confidence function table and reordered those two rows before final validation. Reported that the set is a cohesive, still-unstaged script-state change and that the chronological log would include the failed experiments and the corrected opcode interpretation.

Ran the complete verification set:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
mdbook build docs
test -f build/docs-book/game-state.html
tools/inspect_save.py CB/DDGAMES.SV9 --variables \
  > build/analysis/inspect-save-variables.txt
tools/inspect_bin.py build/dd1/all/325_FIRST.BIN \
  > build/analysis/inspect-first-bin.txt
rg -n 'faith|set_variable|state_flag' \
  build/analysis/inspect-first-bin.txt
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'afl~initialize_script_state; afl~reduce_faith; afl~state_flag; \
afl~text_record_state; q' build/analysis/CB_UNPACKED.EXE
git diff --check

All 68 tests passed in 2.228 seconds, every Python source compiled, mdBook built game-state.html, and the whitespace check passed. The FIRST listing showed the expected annotated map assignments, flag 0x36, and faith assignment 0x2710 to var[21:faith]@0x002a. Rizin loaded all eight added symbols: script-state initialization, faith reduction, three flag helpers, and three text-record-state helpers. Reported these results before staging.

Staged the 13 intended paths and ran git diff --cached --check; it passed. The staged change contained 719 insertions and 15 deletions. Created the requested checkpoint with subject state: Recover script progression model and a four-paragraph body explaining the primary-state model, recovered opcode families, named progression fields, conservative inspectors, tests, and documentation. Added this final commit action to the progress log and amended it into the same checkpoint.

2026-07-15: scene display objects

Selecting and inventorying the subsystem

Continued with the open entity, conversation, and combat work. Chose to start with scene objects and encounter presentation because the complete BIN operand schema was already available. Checked the clean worktree, current plan, recent commits, existing symbols, scene-bytecode notes, and every prior mention of combat, encounters, entities, dialogue, victims, and powerups:

git status --short
sed -n '45,80p' PLAN.md
rg -n -i \
  "combat|encounter|entity|enemy|robot|conversation|dialog|victim|attack" \
  docs/src analysis/cb.rz README.md tools tests
git log -4 --oneline

Reported that the intended path was to inventory scene opcodes and runtime structures, correlate them across the executable and BIN corpus, and use visible, silent QEMU debugging where static evidence needed confirmation.

Dumped the 145-word dispatch table at 0x59AB and the interpreter body:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'px 320 @ 0x59ab; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/bin-dispatch-table.txt
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'pdr @ execute_bin_commands; q' \
  build/analysis/CB_UNPACKED.EXE

Parsed the little-endian handler words into an opcode-to-handler map. Then scanned all 64 known executable regions in the 62 BIN resources to count each still-unnamed opcode and collect resource, offset, and operand examples. The first Python attempt had a mismatched bracket in a formatted expression and failed with SyntaxError. The corrected attempt used the nonexistent DD1Archive.members attribute and failed with AttributeError; changed it to the documented entries collection and completed the scan.

The corpus separated into dialogue-heavy and combat-heavy command clusters. Reported that dialogue was concentrated around 0x44..0x4F, 0x59..0x5B, and 0x85/0x86, while the COMBAT*.BIN programs use another cluster. Began with the former because its strings and seven victim scripts supply stronger independent labels.

From dialogue handlers to the display table

Saved direct disassemblies of the dialogue and record handlers:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'pD 1000 @ 0x50a2; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/dialogue-handler-disassembly.txt
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'pD 850 @ 0x54dc; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/dialogue-record-handlers.txt

Rendered complete listings for NAGE.BIN and the other victim programs. An early attempt guessed archive prefixes 004_TITLE.BIN and 331_LOGO.BIN; both failed with FileNotFoundError. Used find to obtain the actual extracted paths 332_TITLE.BIN and 001_LOGO.BIN, then repeated the listings successfully.

Handlers 0x85 and 0x86 multiply their operand by ten and set or clear bit 7 at DS:A2B2 + index*10. Neighboring commands use the same stride and change adjacent bytes. Initially described this as a likely entity/object table but explicitly withheld names until its renderer was traced; the high bit could have represented flipping rather than visibility.

Searched the whole load-module disassembly for every indexed reference and disassembled the creation and update paths around 0x3AFF, 0x4A5C, and 0x55EC. This established a record base of DS:A2AC, not A2B2: A2B2 is record offset +6. The record count is at DS:00E2, the stride is ten bytes, and the updater rejects counts above 100.

The creation handlers append these type values:

  • opcode 0x02 creates a command-thread record of type 0x02;
  • opcode 0x03 creates a direct record of type 0x03 with scale 0x0100;
  • opcodes 0x04 and 0x43 create direct records of type 0x43 with an explicit scale;
  • opcode 0x06 starts animation state and creates a type-0x06 record.

Disassembled the direct renderer at 0xBCAC. Its arguments prove that the three words are signed X, signed Y, and 8.8 scale; the four bytes are render flags, one-based frame, ART slot, and record type. Frame zero suppresses the render slot. Flags bit 7 suppresses drawing for values 0x80..0xEF, while bits 1 and 2 flip the axes. This resolved 0x85 as hide and 0x86 as show. Opcodes 0x65 and 0x66 respectively clear consecutive frame bytes and increment them with an inclusive minimum/maximum range.

Reported the key semantic boundary: the table is a scene display list shared by characters, props, animation groups, and command threads. It is not yet evidence for a distinct gameplay enemy/health model.

Visible QEMU memory validation

Launched the existing trace mode:

./run.sh --trace-dos

This retained the Cocoa window requested by the user and used the existing -audiodev none configuration, so the diagnostic run was visible and silent. After the Bridgestone logo scene appeared, sent these commands to the monitor socket:

info registers
pmemsave 0 1048576 build/qemu-trace/entity-title-physical-1m.bin
screendump build/qemu-trace/entity-title-screen.ppm

The filename says title, but the screenshot confirmed that the capture was the earlier Bridgestone LOGO.BIN scene. QEMU reported DS=14E1, placing DS:00E2 at physical 0x14EF2 and DS:A2AC at physical 0x1F0BC. Decoded the live table with xxd and Python. The count was 13. Records 7–9 were:

7: x=303 y=0 scale=0x0100 flags=1 frame=4 art=1 type=0x43
8: x=3   y=0 scale=0x0100 flags=1 frame=4 art=0 type=0x43
9: x=3   y=0 scale=0x0100 flags=1 frame=1 art=0 type=0x43

These are byte-for-byte matches for the three LOGO.BIN opcode-0x43 definitions at 0x0122, 0x012C, and 0x0136. The live type order also matches four animation records, three thread records, the three direct objects, another animation record, and two final thread records. Reported that all 13 definitions align, then stopped QEMU cleanly with quit and waited for the process to exit.

Inspector, tests, and documentation

Extended tools/inspect_bin.py with conservative semantic names for the definition, frame-control, hide, and show opcodes. Added a typed static display-definition model and --objects, which appends source offsets, types, and known definition fields in linear order. The output warns that branches can skip or repeat definitions, so the summary is not presented as a general control-flow simulation.

Added tests for the recovered opcode names and a QEMU-correlated LOGO regression. The regression requires exactly 13 definitions, checks the full type order, and checks all direct fields in record 7. Ran:

python3 -m unittest tests.test_inspect_bin -v
tools/inspect_bin.py build/dd1/all/001_LOGO.BIN --objects

All 14 focused tests passed. The inspector printed the 13 expected records, including display[07] with the exact captured fields.

Named the reset, update, render-slot release, and direct-object renderer in analysis/cb.rz. Added the Scene Display Objects chapter and linked it in the book. Updated the bytecode and static-analysis chapters, README, and living plan. Split the broad remaining task so the recovered display system is complete while gameplay entities, conversation flow, combat, and other progression remain open.

Ran the complete verification set:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh
mdbook build docs
test -f build/docs-book/scene-objects.html
tools/inspect_bin.py build/dd1/all/001_LOGO.BIN --objects \
  > build/analysis/logo-display-records.txt
rg '^display\[' build/analysis/logo-display-records.txt
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'afl~scene_display; afl~render_slot; q' \
  build/analysis/CB_UNPACKED.EXE
git diff --check

All 70 tests passed in 2.259 seconds, every Python source compiled, run.sh passed shell syntax checking, and mdBook built scene-objects.html. The inspector emitted all 13 expected LOGO definitions. Rizin loaded the reset, display update, direct-object render, and render-slot release symbols at 0x3AD2, 0x3AFF, 0xBCAC, and 0xB948. The whitespace check passed. Reported these results and that the completed slice remains uncommitted until the user requests a checkpoint.

Final display-field correction

During final diff review, rechecked the renderer’s stack arguments against the three distinct bytes in LOGO records 7–9. The first live record had value one in both byte +6 and byte +8, which had hidden an ordering error in the initial interpretation. The renderer indexes the loaded-ART table with byte +6, reads the frame from byte +7, and derives flip flags from byte +8. Thus byte +6 is the ART slot plus high hidden marker, while byte +8 holds the separate render flags. Opcodes 0x85/0x86 still mean hide/show, but they set/clear the ART-slot byte’s high bit rather than a flags-byte bit.

Reported the correction before handoff. Corrected the inspector operand mapping, tests, scene-object chapter, bytecode table, and static summary. Added an assertion for LOGO record 8, whose distinct values (art=1, flags=0) prevent the two fields from being swapped again. The corrected QEMU interpretation is:

7: x=303 y=0 scale=0x0100 flags=1 frame=4 art=1 type=0x43
8: x=3   y=0 scale=0x0100 flags=0 frame=4 art=1 type=0x43
9: x=3   y=0 scale=0x0100 flags=0 frame=1 art=1 type=0x43

An attempted consistency search put Markdown backticks inside a double-quoted shell pattern. Bash treated `+8` as command substitution and printed /bin/bash: +8: command not found. The search had || true, did not mutate files, and the validation block that followed still ran. Reported the harmless command error and repeated the search with a single-quoted pattern.

Reran all 70 tests after the field correction; they passed in 2.290 seconds. Python compilation, mdBook, the corrected inspector output, all four Rizin symbols, and git diff --check also passed. In particular, inspector records 8 and 9 now show art_slot=0x0001 and flags=0x0000, matching the raw QEMU bytes and their BIN operands.

Preparing the display-object checkpoint

The user requested a commit. Inspected the worktree with git status --short, checked whitespace with git diff --check, reviewed the diff statistics and path list, and inspected the four most recent commits. The whitespace check passed. The intended checkpoint contains ten paths: the plan, README, Rizin symbol script, book summary, progress log, scene-bytecode and static-analysis chapters, new scene-object chapter, inspector, and its tests. The previously recorded verification remains current: all 70 tests pass, Python sources compile, mdBook builds, the four Rizin symbols load, and the corrected 13-record LOGO interpretation matches the QEMU dump.

Staged those ten paths explicitly, then ran git diff --cached --check, git status --short, git diff --cached --stat, and git diff --cached --name-only. The staged check passed and confirmed that no unrelated path was included. Created the checkpoint with subject scene: Recover display-object runtime; its detailed message records the runtime model, inspector support, QEMU correlation, field-order regression coverage, and documentation rationale. Amended this final log entry into the same commit so the repository records the commit operation as part of the append-only investigation history.

The first post-commit message audit used git log -1 --pretty=%B with an awk line-length check. It found seven body lines between 73 and 76 characters, exceeding the repository’s 72-character rule. The audit stopped the chained status and summary checks before they ran. Rewrapped the message without changing its meaning, staged this note, and amended the checkpoint again before repeating the complete verification.

2026-07-15: Resuming gameplay-system analysis

The user asked to continue after the display-object checkpoint. Confirmed that the worktree was clean, then read the living plan, README, tail of this log, scene-bytecode chapter, static-analysis chapter, and complete Rizin symbol script. The next open bounded area is the relationship among gameplay entities, conversation, combat, and the remaining unidentified BIN handlers. The investigation will begin with static handler and corpus correlation, then use visible, silent QEMU evidence where live state is needed.

Static dialogue-handler and corpus analysis

Parsed all 145 entries in the BIN dispatch table and compared the handlers around the dialogue-heavy opcode cluster. Saved focused Rizin output under the ignored build/analysis/ tree, including bin-dispatch-hex.txt, conversation-handlers.txt, conversation-state-xrefs.txt, choice-ui-functions.txt, dialogue-ui-disassembly.txt, and study-selection-flow.txt. Representative commands were:

rizin -q -b 16 -e scr.color=false \
  build/analysis/CB_UNPACKED.EXE \
  -c 's 0x51b7; pd 220; q' \
  > build/analysis/conversation-handlers.txt
rizin -q -b 16 -e scr.color=false \
  build/analysis/CB_UNPACKED.EXE \
  -c 'axt @ 0xb428; axt @ 0xb116; axt @ 0x8934; axt @ 0x7cba; q' \
  > build/analysis/conversation-state-xrefs.txt
rizin -q -b 16 -e scr.color=false \
  build/analysis/CB_UNPACKED.EXE \
  -c 's 0x2556; pd 300; s 0x2933; pd 300; q' \
  > build/analysis/dialogue-ui-disassembly.txt

The handler at 0x51B7 clears the word at DS:B428 and dialogue state at DS:8934. Handler 0x51C6 appends a record at DS:B116 + 6*count containing an absolute BIN target word and a far pointer to its inline text. Handler 0x5257 enters dialogue state 1 and suspends the current scene thread. Function 0x2556 is the generic text-menu renderer and selection loop; poll_input_event ultimately writes the selected record’s target to DS:7CBA. Handler 0x51FF removes the first record with a matching target and shifts all later six-byte records, although no recovered BIN uses that opcode.

Wrote short Python analyses against the existing decoded command model to count opcodes across all 64 known code regions and list their resource, source offset, target, and string operands. The results were:

0x44 add choices:        40 uses in 6 resources
0x45 clear choices:      11 uses in 6 resources
0x46 present choices:    14 uses in 7 resources
0x14 adversary dialogue: 10 uses, all in FACE.BIN
0x48 character dialogue: 306 uses in 12 resources
0x4E Captain channel:    281 uses in 25 resources

The differing choice counts show that scripts conditionally add entries and reuse menus; a linear command inventory is useful evidence but not a full control-flow reconstruction. The three string commands share handler 0x52A3. Their dominant uses establish separate adversary, character, and Captain Bible presentation channels, but 0x4E is also reused for captions and system text, so the documentation does not claim an enforced speaker type.

Decoded the study-request path separately. Opcode 0x7D stores its byte at DS:0066 and the value of its variable operand at DS:0068. The prompt renderer at 0x446F maps 0x2A to the * victim-conversation component, 0x64 to the P paraphrase, and other nonzero values to the L lie. Opcode 0x49 sets DS:79F0 and suspends the thread. The main loop calls the study browser through 0x834E; it clears flags 0x14 and 0x15, sets 0x14 after the expected descriptor is selected, and sets 0x15 on the nonmatching departure path. Saved focused NAGE.BIN and JELO.BIN listings as build/analysis/nage-listing.txt and jelo-listing.txt. NAGE.BIN demonstrates the complete configure, request, suspend, and result-branch sequence.

The initial attempt to add all new names to analysis/cb.rz in one patch did not apply because the expected poll_input_event context appeared in a different order. Inspected the file with numbered lines and applied smaller patches. The script now names show_study_bible, select_from_text_menu, show_dialogue_message, render_study_prompt, and handle_study_bible_request, plus the identified dialogue and suspension handlers.

Live BOSS choice-table correlation

Started the normal diagnostic VM with:

./run.sh --trace-dos

This retained the requested Cocoa window and the existing silent audio backend. Used QEMU monitor sendkey ret commands to advance through the title sequence into the BOSS conversation. Captured screenshots and physical memory with commands of this form:

screendump build/qemu-trace/conversation-menu2.ppm
pmemsave 0 1048576 build/qemu-trace/conversation-menu2-physical-1m.bin
info registers

The raw PPM was not accepted by the local image-viewing path, so converted it to PNG with macOS sips and inspected the result. This was only a format conversion of ignored evidence, not an edit to game data. The visible screen contained the five expected BOSS questions.

At the stable menu, the game data segment was 14E1. This placed the choice count at physical 0x20238 and the record table at physical 0x1FF26. The count word was five. A Python decoder over the memory capture printed:

0: target=0644 text=4C13:045F
1: target=07E8 text=4C13:0485
2: target=0751 text=4C13:04A5
3: target=0519 text=4C13:04C6
4: target=095C text=4C13:04FE

Dereferencing each far pointer in the same physical dump produced, byte for byte, the five inline strings statically decoded from BOSS.BIN:

So what do I do when I get inside?
Can I expect any resistance?
What about the people inside?
Should I expect any problems with my computer bible?
OK!  I'd better go do it!

Before selection, DS:8934 was 1 and DS:7CBA was zero. Highlighted the last choice and selected it, then captured conversation-selected.png and another one-megabyte memory image. The selected-target word became 0x095C, dialogue state became 2, and the next visible line was Before you go, I think that we should pray. That string is the opcode-0x48 message at the exact static target 0x095C. Stopped QEMU cleanly with the monitor quit command and waited for the process to exit.

Choice inspector and documentation

Extended tools/inspect_bin.py with conservative semantic names for opcodes 0x13, 0x14, 0x44 through 0x46, 0x48, 0x49, 0x4E, 0x72, and 0x7D. Added a typed DialogueChoiceDefinition model and --choices output that lists the source, absolute target, and decoded text for every linear opcode-0x44 definition. The output warns that branches can alter the menu presented at runtime.

Added three focused regression tests: semantic opcode names, the exact five BOSS target/text pairs correlated in QEMU, and a complete-corpus total of 40 choice definitions while respecting mixed code regions. Ran:

python3 -m unittest tests.test_inspect_bin -v
tools/inspect_bin.py build/dd1/all/327_BOSS.BIN --choices

All 17 focused tests passed in 0.116 seconds, and the inspector printed the five expected BOSS records.

Added the Conversation Flow chapter and linked it from the book summary. Updated the scene-bytecode, static-analysis, script-state, and text-format chapters; README now demonstrates --choices; and the plan now marks choice flow, dialogue channels, and study integration complete while leaving combat, gameplay entities, and other progression open. The first combined documentation patch failed because its expected context split one sentence differently in text-formats.md; reapplied the changes against the exact numbered context. This failure changed no file contents.

Validation and symbol-script correction

Ran the complete validation set in three independent groups:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh
mdbook build docs
test -f build/docs-book/conversation-flow.html
tools/inspect_bin.py build/dd1/all/327_BOSS.BIN --choices \
  > build/analysis/boss-dialogue-choices.txt
git diff --check

All 73 tests passed in 2.263 seconds. Every Python source compiled, run.sh passed shell syntax checking, mdBook built conversation-flow.html, the BOSS inspector output contained all five expected choices, and the whitespace check passed.

The first Rizin symbol audit exposed a script defect despite returning exit status zero. The eight new handler lines used f to create flags at addresses where recursive analysis had already created switch-case flags, so Rizin printed name-collision errors. Replaced those lines with fr commands that rename the existing case.0x4552.* flags. A second audit initially used bare f to list flags; current Rizin requires fl, so that audit itself printed a usage error unrelated to the symbol script. Repeated it correctly with:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'afl; fl; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/conversation-symbol-audit.txt \
  2> build/analysis/conversation-symbol-audit.err
rg 'show_study_bible|select_from_text_menu|show_dialogue_message|\
render_study_prompt|handle_study_bible_request|bin_handler_' \
  build/analysis/conversation-symbol-audit.txt

The corrected output contains all five conversation/study functions and all eight handler flags at their expected addresses, with no collision or usage errors from the script. Ran git diff --check again after the correction; it passed.

Preparing the conversation-flow checkpoint

The user requested a commit. Inspected git status --short, ran git diff --check, reviewed the diff statistics and path list, and read the four most recent commit subjects. The whitespace check passed. The intended checkpoint contains the plan, README, Rizin names, book summary, progress log, scene-bytecode, static-analysis, script-state, and text-format updates, the new conversation-flow chapter, the BIN inspector, and its tests. The current verification remains valid: all 73 tests pass, Python sources compile, run.sh passes shell syntax checking, mdBook builds the new chapter, the BOSS choice inventory matches QEMU, and all eight handler names load in Rizin.

Staged those 12 paths explicitly, then ran git diff --cached --check, git status --short, git diff --cached --stat, and git diff --cached --name-only. The staged check passed and confirmed that no unrelated path was included. Created checkpoint a0ed22e with subject scene: Recover conversation runtime and a detailed body covering the runtime model, QEMU evidence, inspector, regression tests, and documentation.

The immediate awk audit of git log -1 --pretty=%B found three body lines of 250, 195, and 126 characters. Multiple git commit -m paragraph arguments had not wrapped their contents, so the message violated the repository’s 72-character rule. The commit content itself was correct. Added this audit to the log and amended the same checkpoint with explicitly wrapped body lines, then repeated the message, status, and cleanliness checks.

2026-07-15: Combat and gameplay entities

The user asked to continue after checkpoint 953f414. Confirmed that the worktree was clean and reviewed the living plan, the scene-bytecode command table, the display-object boundary, and earlier opcode-clustering notes. The next open slice is the combat-heavy BIN command cluster and any executable runtime structures that connect it to display records, collision, faith damage, and encounter completion. The investigation begins with static corpus and handler analysis; visible, silent QEMU will be used only where live state is needed to distinguish competing interpretations.

Combat-program corpus

Listed and decoded COMBAT1.BIN through COMBAT7.BIN, then counted their commands, opcode-0x06 animation headers, contiguous opcode-0x07 steps, and opcode-0x3A definitions. The seven programs contain 5,443 commands in 35,449 bytes, 214 animation sequences, 2,596 steps, and 27 selectable-action definitions. Six programs define four actions and COMBAT6.BIN defines three. Selector counts are seven .11, six .12, seven .13, and seven .14 strings.

Compared each program’s resource loads. All seven load COMBTAGS first and COMBAT second, followed by encounter-specific artwork: BIG*, HELMET, MANTIS*, SNAKE*, CRAB, GUARD*, or ZAP*/SPRK. Attempted to render 162_COMBTAGS.ART with a guessed 054_ZAP.PAL; that command failed because the guessed path does not exist. Located the real 019_ZAP.PAL member and rendered all four frames under the ignored build/graphics/combat-tags/ directory. Visual inspection identified the frames as ATTACK, DEFEND, RETREAT, and COMBAT, establishing the .11 through .14 mapping independently of action control flow.

Decoded the exact COMBAT7.BIN action definitions:

0x0C06 -> 0x0EC8  (151,  61)  .11 ATTACK
0x0C11 -> 0x0EAB  (136, 153)  .12 DEFEND
0x0C1C -> 0x1053  ( 15, 167)  .13 RETREAT
0x0C27 -> 0x0FA7  (157,  62)  .14 COMBAT

Executable tables and handlers

Used Rizin disassembly and cross-references around the interpreter handlers, then followed their callees into the scene update and input paths. Opcode 0x3A appends ten-byte records at DS:480E, counted at DS:6EA4. The fields are an absolute BIN target, X, Y, current-BIN selector offset, active byte, and one padding byte. Opcodes 0x3B and 0x3C set and clear the active byte. The routine at 0x6A23 searches active records near the pointer and draws the selected label; the keyboard search is at 0x8558. Selection dispatches the record’s target through 0x7A5C.

Animation definitions create 12-byte slots at DS:6EBA, counted at DS:B114. Recovered first-step and current-step BIN offsets, the sequence interval, linked/parent index, mode/state, and render/display slot. The final two timing bytes remain only partly understood. The nine-byte step following an opcode-0x07 is frame, ART slot, signed X, signed Y, 8.8 scale, and flags. Followed the linked-transform resolver at 0x3B9B, single-slot renderer at 0x3D08, all-slot updater at 0x3DA8, start routine at 0x3F59, and stop routine at 0x3FDF. The updater implements modes 1 through 10 and advances the cursor by the ten-byte opcode-plus-payload step size.

Recovered additional interpreter operations from their handlers:

  • 0x08, 0x5F, and 0x09 start, start-linked, and stop animations;
  • 0x3F waits for an active animation and 0x80 branches on its state;
  • 0x41 and 0x42 enable and disable selectable-action input;
  • 0x3E starts a true BIN scheduler slot at an absolute target and 0x61 stops a slot;
  • 0x59 waits for digital playback, using a simulated timer without a digital driver;
  • 0x60 is a no-op which shares the main interpreter continuation;
  • 0x82 stores the runtime pseudorandom value modulo an immediate in a selected script variable.

The true BIN scheduler uses 16-byte records beginning at DS:8D44, with the current index at DS:7DB4. Proven fields are cursor +0, delay/timer +0x0C, active byte +0x0E, and status +0x0F. This is distinct from the other 16-byte record family created by opcode 0x02 and connected to a type-0x02 display record. The evidence therefore supports a script-driven combat system composed of actions, animations, random branches, progression flags, and faith loss. It does not support labeling a display record or action record as an enemy-health object.

Inspector and regression tests

Extended tools/inspect_bin.py with conservative names for the recovered combat/runtime opcodes, typed animation and action definitions, and two new views. --animations groups every opcode-0x06 header with its immediately following steps; --actions lists source, target, coordinates, selector, and the four rendered label names. Added variable annotation for the destination of opcode 0x82 and for the already identified variable operand of opcode 0x7D.

Added focused tests for all new semantic names, the exact four COMBAT7.BIN actions, its first animation sequence, and the complete seven-program corpus totals. The first focused test run failed with three NameError exceptions: an ambiguous patch had accidentally inserted the new imports below the file’s unittest.main() call. Inspected the file tail, moved those imports into the existing import block, removed the stray lines, and wrapped the affected assertions. Repeated:

python3 -m unittest tests.test_inspect_bin -v
tools/inspect_bin.py \
  build/dd1/all/337_COMBAT7.BIN --animations --actions

All 21 focused tests passed in 0.130 seconds, and the inspector printed 35 animation sequences, 293 steps, and the four expected labeled targets.

Visible QEMU attempt

Started the game with the required visible Cocoa window and silent audio:

./run.sh --trace-dos

The trace process used session 98634. Advanced from the intro, opened the Escape menu, checked Load Game, and found all nine labels EMPTY. Returned through TITLE and BOSS dialogue, selected the fifth response (OK! I'd better go do it!), and advanced the following prayer/transition. Automated input then left a black framebuffer. Repeated register checks remained at CS=0D66, DS=0000, EIP=3E04 in a loaded sound-driver segment rather than the game’s code/data segments. Waiting more than eight seconds did not change the screen; the most recent DOS trace also ended in that transition.

Captured the ignored transition frames under build/qemu-trace/, then sent quit through the monitor and confirmed QEMU exited with status zero. This run did not reach a verifiable encounter, so no animation, action, or thread table claim relies on it. A live combat capture remains open.

Documentation, symbols, and validation

Added the Combat Runtime chapter and linked it from the book. It documents the seven-resource corpus, animation steps and slots, selectable-action records and labels, true BIN scheduler, synchronization operations, inspector commands, QEMU boundary, and remaining questions. Updated the scene-bytecode, scene-display-object, static-analysis, README, and plan material. The plan now marks the action/animation/thread slice complete while leaving exact combat outcomes and remaining gameplay entities open.

The first combined analysis/cb.rz patch failed because it assumed initialize_scene and update_scene_threads were adjacent. Inspected the numbered file and reapplied the additions against their actual locations. A later combined scene-bytecode patch also failed because it expected separate 0x38 and 0x39 rows where the table has one combined row. Reapplied smaller patches against the exact context. Neither failed patch changed a file.

Named the five animation routines, action overlay, action-key search, and BIN-thread starter in analysis/cb.rz, and renamed the 14 existing switch case flags for the recovered handlers. Audited the script with:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'afl; fl; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/combat-symbol-audit.txt \
  2> build/analysis/combat-symbol-audit.err

All requested function and handler names appeared at the expected offsets. The five-byte standard-error file contains only a terminal progress escape, not a Rizin analysis or name-collision error.

Generated build/analysis/combat7-runtime.txt, checked its four action rows, and ran the complete validation set:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
bash -n run.sh
mdbook build docs
test -f build/docs-book/combat-runtime.html
git diff --check

All 77 tests passed in 2.319 seconds. Python compilation and shell syntax checking passed, mdBook generated combat-runtime.html, the COMBAT7 inspector output matched the recovered records, the Rizin symbol audit succeeded, and the whitespace check passed.

Performed one final consistency pass after completing the progress log: rebuilt mdBook, reran the 21 focused BIN tests (all passed in 0.134 seconds), recompiled the two changed Python files, reviewed status and the documentation, plan, README, and symbol-script diffs, and repeated git diff --check. The book and focused checks remained clean. Left the work uncommitted for review.

Preparing the combat-runtime checkpoint

The user requested a commit. Rechecked git status --short and git diff --check, confirmed that the only changed paths belong to this combat-runtime investigation, and retained the successful validation results: all 77 tests pass, the changed Python files compile, run.sh passes shell syntax checking, mdBook builds the new chapter, the COMBAT7 inventory matches the recovered action records, and the Rizin symbol audit contains every new name without collision errors. Prepared one checkpoint with the inspector, tests, symbol map, combat chapter, related documentation, plan, README, and this append-only activity record.

The first staged git diff --cached --check reported one extra blank line at the end of combat-runtime.md. Removed that blank line, recorded the finding here, and restaged the chapter and progress log before repeating the complete staged audit.

Created the checkpoint as a83072d with subject combat: Recover scripted encounter runtime. The immediate 72-column audit found three commit-body lines of 73 characters. The subject and committed content were correct, but the message missed the repository limit by one column on those lines. Recorded the correction here and amended the same checkpoint with shorter wrapping.

2026-07-15: Combat outcomes and progression

The user asked to continue after checkpoint 4b151c2. Confirmed the worktree was clean, reviewed the living plan and the new combat-runtime chapter, and selected the next open slice: trace each combat action target through script control flow and correlate outcomes with faith, power flags, map mutations, dialogue, and scene exits. During that review, noticed that the chapter’s example command still used the incorrect archive prefix 358_COMBAT7.BIN despite the actual extracted member being 337_COMBAT7.BIN; the README and progress log already used the correct path. This documentation typo will be corrected as part of the current work.

Reported the initial scope to the user: this pass would trace each action target through BIN control flow, correlate it with faith, flags, map changes, dialogue, and exits, and keep scene-local enemy variables distinct from proven persistent state.

Combat-script control-flow survey

Generated complete ignored listings for COMBAT1.BIN through COMBAT7.BIN with tools/inspect_bin.py, then repeatedly searched and inspected bounded regions around action definitions, action targets, faith writes, state flags, map writes, scene changes, and subroutine calls. Representative commands were:

for n in 1 2 3 4 5 6 7; do
  tools/inspect_bin.py "build/dd1/all/..._COMBAT${n}.BIN" \
    > "build/analysis/combat${n}.txt"
done
rg -n 'add_action_target|reduce_faith|set_current_map_cell|\
state_flag 0x38|change_scene|opcode_7a|opcode_7e' \
  build/analysis/combat*.txt
sed -n '600,730p' build/analysis/combat7.txt

The archive numeric prefixes differ per member, so the loop above represents the seven resolved paths rather than a literal shell glob. The individual listings record the exact offsets. The action destinations are:

ProgramATTACKDEFENDRETREATCOMBAT
COMBAT10x0BF30x0BCF0x0C380x0B30
COMBAT20x186F0x184F0x1BAC0x1B07
COMBAT30x0BB60x0B900x10C30x1031
COMBAT40x17D50x17AF0x1C9D0x1C18
COMBAT50x15D20x15B20x18F70x1839
COMBAT60x07E8none0x087B0x0827
COMBAT70x0EC80x0EAB0x10530x0FA7

Compared these paths with CB/MANUAL.TXT. The manual independently says manual combat offers Attack, Defend, and Retreat; automatic combat offers Combat and Retreat; attacks succeed only during a cyber’s vulnerable phase; Sword and Shield improve combat; and defeating the Zapper restores all faith. Its vulnerability hints remain useful for later visual correlation, but the present documentation does not assign exact animation counters from those prose descriptions without further proof.

All seven Retreat destinations begin with an unconditional jump into the post-victory exit. They skip the map mutation. Victory in programs 1 through 5 and 7 writes current-cell kind 0xB. Program 6 instead writes 0xA and copies parameter B to parameter A. The base Normal-difficulty faith-loss immediates are:

COMBAT1  533, 2011
COMBAT2  107, 102, 502
COMBAT3  1037, 531, 2011, 1703
COMBAT4  596, 1005
COMBAT5  213, 2009
COMBAT6  none
COMBAT7  233, 207

These are all static loss sites, not a claim that one execution reaches all of them. The previously recovered reduce_faith routine halves them on Easy, uses them directly on Normal, multiplies them by four on Difficult, and suppresses them in installation no-combat mode.

Six normal encounters set flag 0x38 during initialization and clear it at the shared exit. Disassembly of the Game Options routine shows flag 0x37 selecting the displayed Automatic Combat On/Off state, while flag 0x38 replaces that row’s normal target with the disabled target -2. This proves 0x37 is the automatic-combat setting and 0x38 is its combat-active lock. Program 6 never sets or clears 0x38, has no DEFEND action, and never calls the faith-loss opcode. Reported to the user that the corpus had converged on a shared normal epilogue plus a deliberately exceptional sixth encounter.

Rendered representative ignored enemy frames to build/graphics/combat-outcomes/guard0.png, crab0.png, and big0.png and inspected them visually. GUARD is a red tracked, tank-like robot; CRAB shows an orange shell or lid; and BIG shows a large helmeted or crested robot head. A JavaScript orchestration attempt to display all three failed with image detail must be a string when provided; the render commands had already succeeded. Reissued the three image-view operations correctly and inspected every image. These observations are not used to force speculative enemy names into the format documentation.

Zapper reward, POWER, and faith exhaustion

Inspected COMBAT7.BIN offsets 0x0D24..0x0DE9 and 0x0FA7..0x105E. Its victory path calls a subroutine which alternates ten direct writes to faith:

1, 10000, 1, 10000, 1, 10000, 1, 10000, 1, 10000

Each write is separated by a 500-tick delay. It ends at 10,000 and then writes map kind 0xB, proving the manual’s Zapper reward and its visible meter-flash implementation.

An initial attempt to inspect build/dd1/all/336_POWER.BIN failed with FileNotFoundError; the archive list shows that the correct extracted path is build/dd1/all/331_POWER.BIN. Used the archive parser directly and then the correct member to inspect the relevant commands. POWER.BIN configures the study prompt, returns from success by copying the combat identifier to variable 29, adds ASCII 0x30, patches the digit in inline combat1, and changes back to the selected combat. It is not the game-over scene.

Traced the central faith check from poll_input_event instead. Function 0x7B12 clamps negative faith to zero and calls 0x1B86. The latter selects the initialized strings OVER and seg, requests the new scene, starts the associated palette effect, and updates related state flags. Named these handle_faith_depletion and enter_game_over_scene.

Four more BIN operations

Used Rizin against build/analysis/CB_UNPACKED.EXE with analysis/cb.rz. The first combined Rizin command placed the executable before -c, causing Rizin to treat the command string as a filename and report Cannot open .... Repeated it with all options before the executable. The corrected disassembly recovered:

  • opcode 0x6C: reads inclusive minimum, inclusive maximum, signed step, and a script-variable offset; helper 0xB5A8 advances and wraps the phase, rotates the palette-index mapping, and schedules a palette update;
  • opcode 0x7A: reads a BIN byte offset and script-variable offset, then writes the variable’s low byte into the loaded BIN resource;
  • opcode 0x7E: calls 0x1B6C with 2; the palette updater immediately writes black and counts down the effect before a scene change;
  • opcode 0x8E: indexes a 16-by-16 byte table by current Y and X, then uses bits 0 through 4 to assign state flags 0x23 through 0x27.

The last name remains deliberately structural: sync_current_cell_flags_23_to_27. The underlying 256-byte table’s broader gameplay meaning is not yet proven. Named the other commands rotate_palette_range, patch_bin_byte_from_variable, and blackout_palette; named helpers rotate_palette_range, start_palette_blackout, and assign_state_flag.

Opcode 0x7A explains resource-name templates in two independent places. Every combat exit patches the C in CHAL from variable 16, selecting the current level’s hall. POWER.BIN patches the digit in combat1 from variable 29. Opcode 0x6C appears in persistent animation threads across combat and power scenes and uses their local variables as palette-cycle phases.

Inspector, tests, symbols, and documentation

Extended tools/inspect_bin.py with the four recovered semantic names and script-variable annotations for opcode 0x6C operand four and opcode 0x7A operand two. Added corpus regression tests which extract the original archive members and assert:

  • each Retreat action enters with an unconditional jump;
  • the complete per-program faith-loss lists above;
  • victory kinds 0xB for programs 1–5 and 7 and 0xA for program 6;
  • the presence of both set and clear operations for flag 0x38 except in program 6;
  • one current-level hall-name patch and all three return-scene templates in every program;
  • the Zapper’s exact alternating faith assignments; and
  • POWER.BIN’s selected-combat name patch and immediate scene change.

The first large apply_patch attempted code, tests, and symbols together but failed because one analysis/cb.rz context did not match; no part of that atomic patch was applied. Applied code and tests separately. Two subsequent combined symbol patches also rejected a seemingly matching multi-hunk context, so applied smaller exact hunks successfully. Two combined documentation patches likewise failed on line-wrapping context and changed nothing; split them into targeted chapter patches. A later rg command used an unescaped backtick in its shell string and failed with an unmatched-quote syntax error; reran the search with a single-quoted, backtick-free pattern.

Added the new executable and handler names to analysis/cb.rz. Updated the combat-runtime, script-state, scene-bytecode, world-map, and static-analysis chapters, corrected the 337_COMBAT7.BIN example path, expanded the README, and split the plan’s broad open item so combat outcomes are checked while remaining entities and progression stay open. Reported the proven Zapper, Retreat, map-kind, faith-loss, and sixth-encounter distinctions to the user as they were established.

Ran three focused new tests first; all passed. Then ran:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'afl; fl; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/combat-outcome-symbol-audit.txt \
  2> build/analysis/combat-outcome-symbol-audit.err
rg 'start_palette_blackout|enter_game_over_scene|handle_faith_depletion|\
rotate_palette_range|bin_handler_(rotate|patch|blackout|sync)' \
  build/analysis/combat-outcome-symbol-audit.txt

All 80 tests passed in 2.272 seconds and every Python source compiled. The symbol audit contains all four helpers/functions and four renamed command handlers at the expected offsets. Its five-byte standard-error file is only Rizin’s terminal progress escape.

Final consistency pass

Ran the remaining repository and documentation checks:

bash -n run.sh
mdbook build docs
test -f build/docs-book/combat-runtime.html
test -f build/docs-book/game-state.html
tools/inspect_bin.py build/dd1/all/337_COMBAT7.BIN | \
  rg 'rotate_palette_range|patch_bin_byte_from_variable|\
blackout_palette|sync_current_cell_flags_23_to_27|faith'
python3 -m unittest tests.test_inspect_bin -v
git diff --check
git status --short
git diff --stat

The shell script passed syntax checking. mdBook rebuilt the HTML book and both updated chapters exist. The live inspector output shows the four new semantic names, the map-level annotation on opcode 0x7A, both faith-loss sites, and all ten Zapper faith assignments. All 24 focused BIN tests passed in 0.143 seconds, and git diff --check found no whitespace errors. The 11 modified tracked files are exactly the plan, README, Rizin symbol map, relevant book chapters and progress log, BIN inspector, and its tests. Left the completed slice uncommitted because the user asked only to continue.

Prepared the user report with these results: combat-outcome and progression control flow is now documented and regression-tested; Retreat skips victory map writes; ordinary victories produce kind 0xB; the special guard produces kind 0xA and moves its parameter; flags 0x37 and 0x38 are automatic combat and its active-scene lock; the Zapper restores faith to 10,000; negative faith selects OVER; and opcodes 0x6C, 0x7A, 0x7E, and 0x8E now have conservative semantic names. The report also states that all 80 tests, Python compilation, Rizin symbol audit, shell syntax check, mdBook build, focused BIN tests, and whitespace checks passed, and that the work is intentionally uncommitted.

Preparing the combat-outcome checkpoint

The user requested a commit. Began a final checkpoint audit covering the 11 modified files from this slice: plan, README, Rizin symbols, five related book chapters plus this log, the BIN inspector, and its regression tests. Retained the completed validation evidence from immediately before the request: all 80 tests and Python compilation passed, the Rizin audit resolved every new name, run.sh passed syntax checking, mdBook built successfully, all 24 focused BIN tests passed, and git diff --check was clean.

Staged exactly those 11 paths and ran git diff --cached --check; it passed. Audited every proposed commit-message line with a small Python length check; the subject and body were all at most 72 columns. Created the checkpoint with subject combat: Document encounter outcomes and a detailed body explaining the shared epilogues, guard exception, faith behavior, new BIN semantics, archive-backed tests, symbol updates, and synchronized documentation. Added this successful commit action to the append-only log and amended it into the same checkpoint before the final clean-worktree audit.

2026-07-15: Room classes and entrance encoding

Selecting the next progression slice

The user asked to continue after commit 030de07. Ran git status --short, git log -1, and read the open Phase 4 plan item plus the current world-map, script-state, README, and progress documentation. The worktree was clean. Reported that the next bounded target would be remaining gameplay entities and progression, beginning with map-cell kind/parameter handlers and scene corpus correlations rather than speculative animation-local variables.

Inspected the existing Rizin output for process_current_map_cell at load offset 0x0C6C, the map screen and world-map command handlers, the full unpacked code listing, all 21 MAP resources, the seven hall programs, and ROOM1 through ROOM4. Generated ignored focused disassemblies at:

build/analysis/ahal.txt
build/analysis/room1.txt
build/analysis/room2.txt
build/analysis/room3.txt
build/analysis/room4.txt

An early numeric filename loop looked for ROOM programs at outdated archive indices, suppressed its missing-file errors, and produced no useful output. Later used rg --files to recover the correct members: indices 333 through 336 in reverse ROOM-number order.

Packed connection and room fields

Traced the high-nibble tests in process_current_map_cell through the action flags and hall movement targets. This establishes the individual connection bits:

0x10 up    0x20 down    0x40 left    0x80 right

For example, the down action increments map Y, right increments X, left decrements X, and up decrements Y. Reported that the map encoding was yielding a five-by-three entity model which matched the manual’s five mapped room classes, pending scene-dispatch correlation.

The room path is selected when the connection nibble is zero. For low kinds 1 through 15, the executable subtracts one and divides by three, placing the quotient in script variable 13 and remainder in variable 14. The quotient selects five classes. Hall programs dispatch quotient zero to their level-specific victim scene and patch the digit in room1 for quotients one through four.

Initially examined raw executable file offset 0x01DA for the orientation lookup and saw code/zero bytes because the disassembly used a data-segment relative address. Corrected the translation by adding the data-segment load base 0xEBA0 and MZ header size 0x0200. The table at load offset 0xED7A is:

00 02 01 03 02 01 03 02 01 03 02 01 03 02 01 03

Neighbor checks correlate values 2, 1, and 3 with a room right, left, and above the hall. Therefore each three-code group represents west-, east-, and south-side entrances respectively. No fourth code permits a room below the hall.

Decoded the five groups and independently checked their scene resources:

KindsClassDispatched scene and resource evidence
13VictimPer-level JELO through NAGE scene
46TrapROOM1; TRAP, TRAP2, and TRAP3
79PrayerROOM2; PRAY
ACCommunicationsROOM3; COMM, COMM2, and FACE1
DFJump TunnelROOM4; TUNNEL, TUNNEL2, and MONST1

The current room’s parameters are copied to variables 17 and 18. Trap scene control flow uses parameter A as a study-prompt selector and clears it after resolution. Hall neighbor processing copies the parameter B of right, left, and upper Trap rooms into variables 23, 24, and 25. A correct adjacent study result clears that byte in the neighboring room. Kept the meaning of the same parameter positions in other room classes open.

Connected hall cells form a separate low-kind namespace. Hall logic uses kind 0xA for a Scripture-station interaction, kinds 1 through 5 and 7 in cyber encounter paths, and other values for special or completed states. Because the combat pass already proved mutations to kinds 0xA and 0xB, did not assign global entity names to these values merely from the room quotient. Reported this contextual distinction to the user.

Map inspector and regression coverage

Extended tools/inspect_map.py with named connection directions and decoded room_class and room_entrance_side properties. Added --rooms, which lists only zero-connection room cells with class, entrance, kind, and both mutable parameters. The existing --cells display now prints direction names next to the raw connection nibble.

Added focused tests for bit decoding, all 15 synthetic room codes, rejection of connected and empty cells as rooms, the combinations used by all 21 archive maps, four ROOM resource families, and all seven hall-to-victim scene associations. The first corpus test expected every class/orientation pair and failed because the archive contains no zero-connection kind 0xF: a south-entry Jump Tunnel. Adjusted the assertion to encode that observed 14-of-15 corpus fact while retaining synthetic coverage for the executable’s full domain. The seven focused tests then passed.

Ran the inspector against CE.MAP with --rooms. It decoded 14 room cells, including Prayer, Trap, Victim, and both used Jump Tunnel orientations, and showed their parameters. A resource-inspection loop immediately after the first test run again used three incorrect ROOM indices and printed no scene matches. Located the actual filenames with rg --files, reran the BIN inspector, and confirmed the resource names above. ROOM3.BIN requires its known split decode regions, so its names were also verified directly from the resource bytes.

Added the 16-byte lookup table as room_entrance_code_by_kind in analysis/cb.rz. Updated the world-map and script-state chapters, README, and plan. The documentation now records the connection bits, quotient and remainder, class/scene mapping, entrances, contextual low-kind namespaces, Trap parameters, map-local variables, and the new inspector mode. Replaced the broad open Phase 4 item with a completed room-decoding task and a narrower open task for hallway entities and endgame progression.

One combined documentation patch failed atomically because its context split the sentence about station parameters differently from the expected lines; no part was applied. Reapplied the changes in smaller exact patches.

Validation

Ran the complete validation pass:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
mdbook build docs
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'fl~room_entrance; q' build/analysis/CB_UNPACKED.EXE \
  > build/analysis/room-symbol-audit.txt \
  2> build/analysis/room-symbol-audit.err
bash -n run.sh
git diff --check
git status --short
git diff --stat

All 83 tests passed in 2.855 seconds, every Python source compiled, and mdBook rebuilt the HTML book. Rizin resolved room_entrance_code_by_kind at 0xED7A with size 16; its five-byte standard error is only the terminal progress escape. The run script passed shell syntax checking and git diff --check found no whitespace errors. Eight tracked files are modified: plan, README, Rizin symbols, the world-map and script-state chapters, this log, the map inspector, and its tests. Left the slice uncommitted because the user asked to continue rather than commit.

After the full pass, wrapped several long test assertions and renamed the corpus test to state its 14-pair expectation precisely. Reran all eight map tests, compiled the two affected Python files, rebuilt mdBook, verified the world-map HTML and three key room-symbol strings, and repeated the whitespace and status checks. Every check passed; the tracked file set remained the same.

Room-decoding checkpoint

The user requested a commit. Audited the eight modified paths with status, whitespace, name, and statistics commands. Staged exactly PLAN.md, README.md, analysis/cb.rz, the two updated book chapters, this log, the map inspector, and its test module. git diff --cached --check passed and the cached statistics remained 394 insertions and 12 deletions.

Checked the proposed detailed commit message against the repository’s 72-column rule. The first three drafts were rejected locally for individual body lines of 74, 75, and 74 columns. Rewrapped those sentences, retained the required rationale and evidence, and created commit 2eba974 with subject:

maps: Decode room classes and entrances

The detailed body explains the quotient/remainder and lookup-table evidence, the inspector and archive-backed coverage, contextual separation of hallway kinds, Trap parameters, Rizin symbol, and synchronized documentation. Added this successful checkpoint record afterward for amendment into the same commit, rather than leaving the action log outside the work it describes.

2026-07-15: Connected hallway entities

Hall-program and resource correlation

The user asked to continue after the room-decoding checkpoint. Verified the worktree was clean at commit 2d24588, read the remaining Phase 4 item, and searched existing chapters for hallway, endgame, GANTRY, BOSS, and Unibot findings. Reported that the next step would trace connected-cell low kinds across all seven hall programs and correlate branches with actions, scene changes, artwork, and map mutations before assigning names.

Decoded AHAL.BIN through GHAL.BIN into ignored command listings under build/analysis/. The first display loop used a glob that omitted the archive-index prefixes and passed the literal nonexistent path [a-g]hal-commands.txt to rg; it failed without changing tracked files. Listed the generated filenames, corrected the glob to *hal-commands.txt, and extracted references to variables 13–18, action targets, scene changes, map mutations, flags, and resource loads.

Traced the common hall feature dispatcher in detail, particularly AHAL’s kind branches at 0x0EC8, dynamic SML1 patch at 0x1008, Confront Cyber targets, Get Verse path, Spider conversion, movement entry points, and room dispatch. Compared all seven programs to distinguish shared behavior from level-specific animation and exit logic.

Searched the manual sections for Cybers, stations, locked doors, hall behavior, and the Unibot. Section 7 supplies eight player-facing Cyber names and specifically states that the Spider may drop behind the player, the Leech sits on Scripture stations, and the Zapper may be walked under while damaging faith. Section 7 also names the Get Verse, Confront Cyber, and Unlock actions.

Inspected the resource headers for all seven combat programs:

COMBAT1  BIG*      Macho
COMBAT2  HELMET    Armored
COMBAT3  MANTIS*   Mantis
COMBAT4  SNAKE*    Snake
COMBAT5  CRAB      Spider
COMBAT6  GUARD*    Leech
COMBAT7  ZAP*      Zapper

The scripts join connected kinds 1 through 7 to these same combat numbers. Their hall renderer patches the digit of SML1 from the current kind; Confront Cyber enters POWER, which patches combat1 and returns to the selected combat program.

Two state transitions independently resolve the nonliteral internal names. Kind 0x9 conditionally writes kind 0x5 in every hall program, matching the manual’s hidden Spider drop and the CRAB combat. Kind 0x6 enters the special GUARD combat; victory writes kind 0xA, copies parameter B to A, and clears B. Kind 0xA is the Get Verse station state, so the transition reveals the station and its saved verse selector exactly as described for the Leech Cyber.

Kind 0x7 has a passive hallway branch that applies base faith loss 400, while COMBAT7 restores full faith on victory. This matches both halves of the Zapper description. Kind 0xA enables Get Verse from parameter A and sets the associated text-record state. Ordinary victories write kind 0xB, and kind 0xE enters the per-level exit sequence. Cyber parameter A selects the lie used by the confrontation; kind-0x6 parameter B preserves the verse which becomes active when the station is freed.

Reported the emerging mapping to the user twice: first that kinds 1..15 formed the earlier five-by-three room model, and in this slice that kinds 1..7 select the seven combat programs, with kind 6 restoring a station and kind 9 becoming the Spider combat state.

Ran a read-only corpus inventory over all 21 MAP members, counting connected low kinds, parameters, and sample coordinates. The resources use kinds 0, 17, 9, A, CF. No connected kind 8 or initial kind B occurs; B is a runtime cleared state. Kinds C, D, and F are common visual/environmental states whose exact player-facing meanings remain unproven, so they were deliberately left unnamed.

Listed and rendered selected CRAB and GUARD ART frames with their native palettes into ignored files under build/graphics/, then inspected the images. The GUARD art visibly depicts the station-covering Cyber, but the documented mapping rests on the stronger map transition and manual evidence. An unused shell loop while locating those files constructed no useful path; the subsequent rg --files query found the correct archive-indexed ART and PAL resources.

Inspector and action labels

Added conservative hall_feature decoding to tools/inspect_map.py for the seven Cybers, hidden Spider trigger, Scripture station, cleared encounter, and level exit. Added --hall-features, which lists only proven nonempty features and leaves kinds C, D, and F visible only in the raw cell view. This preserves the important distinction between connected hall kinds and zero-connection room codes.

Added hall selector labels to tools/inspect_bin.py: movement for .u, .d, .l, and .r; Confront Cyber for .c; Unlock for .x; and Get Verse for .v. The three Unlock targets use parameter B of an adjacent Trap-room cell as their study prompt and clear it after the correct verse, while the Trap encounter inside uses parameter A.

Added archive-backed tests for the conservative hall feature table, all seven combat resource families, every hall program’s kind-9 to kind-5 transition, POWER and Verse-loaded paths, and the complete 12-target AHAL action selector sequence. The first focused run covered ten map tests plus the new action test; all 11 passed. Ran --hall-features on CE.MAP, which identified Cybers, stations, two covered stations, and the level exit with their directions and parameters. Ran AHAL with --actions; all movement, Confront Cyber, Unlock, and Get Verse labels appeared as expected.

Updated the world-map, combat-runtime, scene-bytecode, and static-analysis chapters, plus README and PLAN. The combat table now joins the internal ART bases to the manual identities and corrects the earlier conservative GUARD description to the Leech-covered station. Split the completed hallway work from the still-open endgame and Unibot task.

One combined scene-bytecode/README patch failed atomically because the README context began on a different wrapped line; neither file changed. Reapplied the scene-bytecode hunk separately. A second larger README hunk also missed the exact wrapping, so changed the two affected label lines with a smaller patch. An earlier large documentation patch for the principal chapters applied successfully.

Validation

Ran the complete regression and documentation pass:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
mdbook build docs
test -f build/docs-book/world-maps.html
test -f build/docs-book/combat-runtime.html
bash -n run.sh
tools/inspect_map.py CB/DD1.DAT --map CE --hall-features | \
  rg 'armored-cyber|leech-covered-station|scripture-station|level-exit'
tools/inspect_bin.py build/dd1/all/368_AHAL.BIN --actions | \
  rg 'CONFRONT CYBER|UNLOCK|GET VERSE|MOVE (UP|DOWN|LEFT|RIGHT)'
git diff --check
git status --short
git diff --stat

All 86 tests passed in 3.568 seconds and every Python source compiled. mdBook rebuilt both affected HTML chapters, the run script passed shell syntax checking, and the two live inspector filters showed all requested feature and action labels. git diff --check found no whitespace errors. Eleven tracked files are modified: plan, README, four book chapters plus this log, both inspectors, and their test modules. Left the completed hallway slice uncommitted because the user asked only to continue.

Hallway-entity checkpoint

The user requested a commit. Audited status, whitespace, statistics, and path names for the 11 modified files. Staged exactly the plan, README, combat, bytecode, static-analysis, world-map, and progress chapters, both inspectors, and both inspector test modules. git diff --cached --check passed; the staged change contained 381 insertions and 26 deletions.

Checked a detailed proposed commit message against the repository’s 72-column rule. Seven intermediate drafts were rejected locally as individual lines of 73, 73, 73, 75, 73, 74, and 73 columns were found. Rewrapped each sentence until all 16 lines were at most 72 columns, then created commit 68b446c with subject:

maps: Decode connected hallway features

The body records the combat-resource, hall-transition, and manual evidence; the conservative unresolved-state boundary; the new inspector views and labels; archive-backed regressions; and synchronized documentation. Added this successful checkpoint entry afterward so it can be amended into the same commit rather than leaving the action log outside the work it records.

2026-07-15: Unibot graph and endgame state machine

The user asked to continue the reverse-engineering work. Began with git status --short, git log -1 --oneline, targeted rg searches, and reads of PLAN, README, the book summary, and the existing state and bytecode chapters. The worktree was clean and the observed HEAD was fe06da8. The only open Phase 4 system task was the endgame and Unibot progression.

Reported the intended scope to the user: start at GANTRY.BIN’s seven-rescue gate and follow every transition through the Unibot, energy pylons, Tower, and ending, grounding the result in bytecode rather than resource names.

Resource and command-stream inspection

Listed the late archive members and extracted-BIN paths with rg, find, and the existing DD1 tooling. The relevant sequence is archive members 309 through 317 (OVER, WIN, KABLAM, FACE, ROBOT, CP3, CP2, CP1, and GANTRY), with approach programs CW6 through CW1 at 344 through 349.

Decoded each relevant resource with tools/inspect_bin.py, saving ignored listings under build/analysis/:

OVER-commands.txt       WIN-commands.txt        KABLAM-commands.txt
FACE-commands.txt       ROBOT-commands.txt      CP3-commands.txt
CP2-commands.txt        CP1-commands.txt        GANTRY-commands.txt
BOSS-commands.txt       HOLE1-commands.txt      HOLE-commands.txt
DOME-commands.txt       344_CW6-commands.txt    345_CW5-commands.txt
346_CW4-commands.txt    347_CW3-commands.txt    348_CW2-commands.txt
349_CW1-commands.txt

Used targeted rg searches over those listings for variable writes/tests, flag operations, selectors, resource loads, and scene changes. Used wc -c, xxd, and read-only Python struct.unpack table dumps to locate and test the CP2 trailer. One display loop used printf separators even though repository command guidance discourages noisy separators; it changed nothing, and later output inspection used direct file reads and rg instead.

GANTRY.BIN tests rescue flags 0x3A..0x40 and mirrors them one-for-one into crew flags 0x42..0x48. CP1.BIN counts those seven flags in variable 27. Zero crew produces the empty-craft/eight-people message; one through six reports the number still needed; exactly seven enters ROBOT. ROBOT.BIN clears powerup flags 0x30..0x34, initializes variables 53, 54, and 55 to zero, and changes to CP2. CW1..CW6 also mirror the rescue flags but belong to the outdoor approach traversal, not the CP2 navigation graph.

The exact 7,765-byte CP2 layout is:

  • commands through offset 0x1D54;
  • 64 next-node words at 0x1D55;
  • 16 node-type words at 0x1DD5;
  • 16 transition/render words at 0x1DF5;
  • 32 coordinate words at 0x1E15.

The first table is 16 nodes by four headings. Heading zero is north, with east, south, and west following right turns; 100 means blocked. All edges are reciprocal and the coordinates reproduce the lower-right map. Pylons are nodes 3, 5, 7, 8, 10, 11, and 13; node 14 is the Tower. Variables 54 and 55 are the node and heading, 56 through 62 are the seven pylon results, 63 is the looked-up next node, 64 is the active pylon, and 65 drives the final Tower dialogue. Variable 53 is used as a turn/rotation offset. Added those names to tools/inspect_save.py, which also improves operand annotations emitted by tools/inspect_bin.py.

Reported this intermediate result to the user, including the seven exact pylon nodes, Tower node 14, and the gate requiring all seven variables. A second report stated the heading order, blocked sentinel, exact node set, and plan to make the tables inspectable with archive-backed tests.

The CP2 actions are .r, .l, and .u for right, left, and forward. The ordinary-road branch contains the manual’s one-time Annoy Cyber event: it loads ANNOY, removes verses, sets flag 0x54, and does not start combat. Each pylon dispatches to selector 0x11..0x17; success sets the matching variable 56 through 62, destroys the pylon, and recovers its captive crew member, while failure enters OVER. Moving into the Tower with any pylon variable clear also enters OVER.

Decoded the complete final state machine. FACE.BIN renders states 0, 1, 2, and 9. CP3.BIN advances 0 to 1; in state 1, study selector 0x20 advances a correct response to 2 or a wrong response to 9. State 2 enters KABLAM, which enters WIN; state 9 enters OVER. CP3’s exact scene-change sequence in file order is KABLAM, three FACE branches, and OVER.

Inspector and regression tests

Created executable tools/inspect_unibot.py. It parses all four CP2 tables, prints every node and compass exit, and validates exact size, destination domain, node types, reciprocal edges, the pylon-node set, and Tower node 14. Ran:

chmod +x tools/inspect_unibot.py
tools/inspect_unibot.py build/dd1/all/315_CP2.BIN

The output listed all 16 nodes successfully. The graph begins at node 0, branches from node 1 toward the Tower and the east/west road networks, and contains the seven expected dead-end pylon nodes. Kept the fourth table’s label conservative as transition_value, because its exact visual use is not yet proven.

Created tests/test_inspect_unibot.py with seven archive-backed tests. They cover the graph and coordinates, pylon and Tower nodes, GANTRY flag mirroring, the CP1 crew gate, ROBOT initialization, pylon result assignments, the FACE/CP3 success and failure states, the ending resource chain, and rejection of wrong-sized or nonreciprocal input. The focused command was:

python3 -m unittest tests.test_inspect_unibot -v

All seven tests passed. A prior five-test version also passed before the CP1 and Tower-state regressions were added.

Documentation and validation

Added the Unibot and Endgame Progression chapter and linked it from the book summary. It records the rescue gate, trailer schema, every node, variables, Annoy event, pylon branches, Tower gate, and ending state machine. Updated the state, bytecode, and static-analysis chapters, README inspector instructions, and marked the endgame task complete in PLAN.

An initial combined apply_patch failed atomically because its final static-analysis context did not match the current wrapping; no part of that patch applied. Reapplied the tool, new chapter, and plan changes, then the remaining documentation in smaller successful patches. This failure was not a source or data error.

Ran the complete regression suite:

python3 -m unittest discover -s tests -v

All 93 tests passed in 4.160 seconds. Then ran:

python3 -m py_compile tools/*.py tests/*.py
mdbook build docs
test -f build/docs-book/endgame.html
bash -n run.sh
tools/inspect_unibot.py build/dd1/all/315_CP2.BIN
git diff --check
git status --short
git diff --stat

Every Python file compiled, mdBook generated the new HTML chapter, the run script passed shell syntax checking, the inspector again printed the complete graph, and the whitespace check passed. At this pre-log checkpoint, seven tracked files were modified and the new chapter, inspector, and test module were untracked. The work remains uncommitted because the user asked only to continue.

A final consistency search found three older tests/test_inspect_bin.py bounds still ending CP2 at 0x1D5A. Examined the boundary directly with:

xxd -g 1 -s 0x1d40 -l 64 build/dd1/all/315_CP2.BIN
tools/inspect_bin.py \
  build/dd1/all/315_CP2.BIN --start 0x1d30 --limit 0x1d5a

The actual final instruction is the one-byte return at 0x1D54. When the decoder is deliberately allowed to continue, the first five table bytes coincidentally resemble two valid commands. Corrected all three generic test bounds to 0x1D55, so the regression suite now enforces the recovered code/data boundary instead of accepting those false commands.

Reran python3 -m unittest discover -s tests -v after that correction. All 93 tests passed again in 4.171 seconds, including the corrected complete-region regression and the seven new Unibot tests.

Repeated Python compilation, mdBook generation, the new HTML existence check, run-script syntax validation, a silent full Unibot inspector run, whitespace checking, status, and diff statistics after the log update. Every check passed. The final worktree contains nine modified tracked files plus three new files: the endgame chapter, Unibot inspector, and Unibot test module.

Unibot/endgame checkpoint

The user requested a commit. Audited the complete worktree with status, whitespace, statistics, tracked diffs, and explicit no-index diffs for each new file. The file set contains only the Unibot/endgame implementation, regressions, plan, README, and book updates described above. Wrapped three long assertions in the new test module during that review without changing their behavior. Prepared a detailed commit message under the repository’s 72-column limit and staged all 12 files for a single checkpoint.

Created the checkpoint with subject scripts: Decode Unibot endgame. Git reported 725 insertions and 9 deletions across the 12 files, including the three new files. Verified the resulting commit metadata, checked every message line against the 72-column rule, and confirmed that the worktree was clean. Amended this final success record into the same checkpoint.

2026-07-15: Checked symbol and function catalog

The user asked to continue. Began Phase 5 consolidation with a clean worktree at observed HEAD d6e5014. Read the remaining PLAN tasks, the complete Rizin script, the static-analysis function table, executable address conventions, README instructions, and prior symbol-related log entries. Reported that this slice would reconcile analysis/cb.rz with the documentation, attach explicit evidence and confidence to every name, and identify stale or unsupported map entries.

Counted declarations directly from analysis/cb.rz with awk. It contains 108 afn function names, 26 renamed switch-case handlers, and 9 named data flags. The book’s former High-confidence table covered only a subset and had neither per-entry evidence nor a synchronization check. Duplicate-name and duplicate-offset scans found no collisions.

Rizin resolution audit

Confirmed that build/analysis/CB_UNPACKED.EXE was present. The first handler query attempted the older f~bin_handler_ syntax:

rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'f~bin_handler_' build/analysis/CB_UNPACKED.EXE

Current Rizin treated that as an invalid f invocation and printed 29 lines of usage text. It did not indicate a script failure. Repeated the audit with -c fl, saved the complete flag listing, and filtered it with rg. Rizin loaded without stderr and resolved exactly 26 bin_handler_* flags. Saved the ignored audit artifacts as:

build/analysis/consolidation-functions.txt
build/analysis/consolidation-functions.err
build/analysis/consolidation-flags.txt
build/analysis/consolidation-flags.err
build/analysis/consolidation-handlers.txt

The resolved handlers span 0x4672..0x5905. This supplies exact load offsets for names which appear only as fr renames of Rizin-generated case flags in cb.rz. Reported to the user that the operational script had 108 functions and 26 handler names and that the checked map would include those handlers instead of silently omitting them.

Catalog and inspector

Added analysis/symbol-map.tsv with six columns: kind, load offset, name, confidence, subsystem, and concise evidence. It covers every named function, handler, and data flag in cb.rz. Classified 77 entries as Verified because static semantics also agree with an independent runtime capture, traced I/O, supplied save, exhaustive resource decode, or byte-level output comparison. Classified the remaining 66 as High because implementation, call sites, data layout, and cross-resource use uniquely support their descriptive names. No Medium entries are currently promoted into Rizin; ambiguous candidates remain unnamed.

Added executable tools/inspect_symbol_map.py and set its execute bit. The tool validates TSV structure, controlled kinds and confidence labels, unique names and kind/offset pairs, nonempty evidence, and the 16-bit load range. It then parses afn, fr, and f declarations from analysis/cb.rz and demands exact set equality. An optional --rizin-flags input also verifies every handler’s resolved address from fl output. Filters can show one kind or one confidence level.

Added four tests in tests/test_inspect_symbol_map.py. The first focused run failed all four during setup because eight early TSV records had five fields: their evidence had accidentally occupied the subsystem column. Located every short row with:

awk -F '\t' 'NF != 6 || $5 == "" {print NR, NF, $0}' \
  analysis/symbol-map.tsv

Added the missing subsystem values (startup, dialogue, bytecode, and graphics) and repeated the focused run. All four tests passed in 0.003 seconds. The tests enforce exact 108/26/9 coverage, the Verified/High set, handler-flag parsing, nonempty evidence, and malformed-header rejection.

Ran the inspector against the saved Rizin handler listing:

tools/inspect_symbol_map.py \
  --rizin-flags build/analysis/consolidation-handlers.txt

It reported symbols=143 data=9 function=108 handler=26 verified=77 high=66 medium=0 and printed the full offset-sorted map. Repeated the audit against the complete unfiltered consolidation-flags.txt; it also passed. Saved a function-only listing and the final complete audit under ignored build/analysis/ files.

Reported the completed counts and confidence boundary to the user. Explicitly noted that Rizin still proposes about 340 recursive-analysis candidates, but the catalog does not treat an algorithmically discovered candidate as a recovered function without semantic evidence.

Documentation and validation

Added the Symbol and Function Map chapter and linked it after Static Analysis. It documents address translation, confidence definitions, counts and evidence by subsystem, complete audit commands, handler-address verification, and the boundary between named functions and unresolved Rizin candidates. Replaced the stale selected function table in Static Analysis with the checked catalog summary, added the inspector command to README, and marked the first Phase 5 task complete in PLAN.

Ran the final catalog audit and repository validation:

tools/inspect_symbol_map.py \
  --rizin-flags build/analysis/consolidation-flags.txt \
  > build/analysis/symbol-map-audit.txt
python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
mdbook build docs
test -f build/docs-book/function-map.html
bash -n run.sh
git diff --check
git status --short
git diff --stat

All 97 tests passed in 4.135 seconds, every Python file compiled, mdBook built the new chapter, the HTML existence and shell syntax checks passed, and Git found no whitespace errors. Four tracked documentation/plan files are modified and the catalog, chapter, inspector, and test module are new. Left the complete symbol-map slice uncommitted because the user asked only to continue.

Symbol-map checkpoint

The user requested a commit. Audited status, whitespace, tracked statistics, new-file line counts, mode summaries, the complete Rizin-handler catalog, and the focused symbol-map tests. The audit again reported 143 synchronized names with 77 Verified and 66 High entries; all four focused tests passed in 0.003 seconds. Confirmed that the intended checkpoint consists only of PLAN, README, book, catalog, inspector, and regression changes from this consolidation slice. Prepared to stage all nine files and commit them with a detailed message conforming to the repository’s subject and 72-column rules.

Created the checkpoint with subject analysis: Add checked symbol catalog. Git reported 706 insertions and 86 deletions across nine files, including the four new catalog, chapter, inspector, and test files. The deletion count is the old partial function table replaced by the checked catalog summary. Added this success record and amended it into the same checkpoint, then verified the final message line lengths and clean worktree.

2026-07-15: Reproducibility and book consistency audit

The user asked to continue for a sustained interval rather than stopping after one small task. Started a five-step consolidation plan: audit structure and claims, add end-to-end reproduction and automatic documentation checks, resolve contradictions, reconcile PLAN, and run the full verification stack. The observed starting HEAD was 024ccce, and the worktree was clean.

Read all of PLAN, book.toml, SUMMARY, the file inventory, environment, introduction, dynamic-analysis and save chapters, plus targeted sections in every format/system chapter. Searched current documentation (excluding this historical log where appropriate) for TODO language, unresolved claims, repeated resource counts, old test totals, bytecode boundaries, and command paths.

The initial audit found two genuinely stale narrative statements:

  • Dynamic Analysis still called the role of DDLC and the other DDL* files open even though the text-format work has recovered and exhaustively joined them.
  • Environment still said command-line switches and player prefixes would be investigated later even though their parser and save behavior are complete.

Reported both stale claims to the user before changing them.

Automated documentation integrity

Added executable tools/check_documentation.py. It checks that every Markdown chapter appears exactly once in SUMMARY, no SUMMARY entry is nonexistent, local Markdown targets exist, referenced heading anchors exist, shell examples parse, and repository commands beginning with tools/ or ./ exist and have an execute bit. It scans README as well as the book.

Added tests/test_check_documentation.py with a live repository regression, heading-slug examples, and a synthetic repository containing an unlisted chapter, broken local link, and missing command. Set the checker executable and ran:

python3 -m unittest tests.test_check_documentation -v
tools/check_documentation.py

All three tests passed in 0.012 seconds. The first live run reported 20 chapters plus README; later runs advanced to 21 and 22 as the two consolidation chapters were added.

Reproduction and evidence-boundary chapters

Added Reproducing the Results after the environment chapter. It gives one ordered path through input hashes, FreeDOS setup, EXEPACK reconstruction, Rizin, the checked symbol catalog, DD1 extraction, graphics/gallery generation, BIN/gameplay inspection, audio/text conversion, saves/maps, optional QEMU DOS tracing, and the complete fast verification suite. It distinguishes tracked research outputs from ignored generated artifacts and says explicitly which interactive checks are outside the noninteractive suite.

Added Known Gaps and Evidence Boundaries before the progress log. It consolidates the remaining dynamic captures, third-party sound-driver ABI, partially named runtime fields, conservative world-map states, and static coverage limits. It also lists former gaps which current chapters have resolved, clarifying that early progress-log hypotheses are chronological and not the current format reference.

Linked both chapters from SUMMARY and README. Added the documentation checker to README and the end-to-end verification command list. An initial README patch left the old standalone mdBook command immediately after the new combined checker/build block; inspected the tail and removed that duplication.

Updated Dynamic Analysis to identify directly opened DDLC as tagged companion text bank C and link it to the recovered external DDL versus internal index split. Updated Environment and Introduction with the completed CLI, formats, systems, and honest remaining scope.

Numeric and claim audit

One rg command accidentally placed Markdown backticks inside the shell command string. Bash attempted to run BIN and printed BIN: command not found; the surrounding read-only searches still ran. Repeated later searches without backticks and recorded this quoting error rather than treating it as a source failure.

The CP2 code/data correction exposed a stale global command count. The first read-only Python recount passed a string to DD1Archive.from_path, which expects a Path, and failed with AttributeError: 'str' object has no attribute 'read_bytes'. Repeated with Path("CB/DD1.DAT") and the 64 exact code regions. The corrected corpus contains 25,840 commands and 122 opcodes, not the old 25,837 figure. Reported this correction to the user.

Updated Scene Bytecode and Static Analysis to 25,840. Added test_complete_command_corpus_count, which independently requires 64 regions, 25,840 commands, and 122 used opcodes with the CP2 and ROOM3 boundaries stated explicitly. The documentation checker plus 26 BIN-focused tests all passed.

Repeated searches found no current references to 25,837, a 251-byte CP2 trailer, 0x1D5A, the obsolete DDL open question, or future CLI analysis. The remaining “not yet” and “open” phrases all correspond to entries in Known Gaps: unnamed world-map environments, save bytes, scene-object fields, dialogue formatting, and live combat tables.

PLAN reconciliation

Split broad stale tasks rather than marking ambiguous work complete. PLAN now records that the visible game-bearing play image was user-confirmed, while interactive input/normal exit remains open and host audio is intentionally silent. It marks game-owned DD/save formats complete, separately records all SOUND file/product/lock identifications, and leaves the third-party driver ABI and timbre format open.

Likewise, it marks startup/resource/driver/audio tracing and runtime/static correlation complete while retaining focused interactive input/save traces and major-screen coverage. Marked reproducible procedures and discovered-system documentation complete after adding the end-to-end chapter and checker.

Replayed noninteractive results

Ran the main reproduction path rather than only checking that its commands were spelled correctly:

tools/analyze_cb_exe.py CB/CB.EXE \
  --output build/analysis/CB_UNPACKED.EXE
tools/extract_dd1.py --extract-all build/dd1/all CB/DD1.DAT
tools/render_art.py build/dd1/all/003_LOGO.ART \
  --palette build/dd1/all/002_LOGO.PAL --canvas --scale 2 \
  --output build/graphics/logo-repro.png
tools/render_fullscreen_gallery.py CB/DD1.DAT \
  --output build/graphics/full-screen-gallery-repro.png
tools/convert_abt.py build/dd1/all/306_D003.ABT \
  --output build/audio/d003-repro.wav
tools/inspect_xmi.py build/dd1/all/267_MUS001.XMI
tools/inspect_text_resources.py CB/DD1.DAT --data-dir CB \
  --translation N --bank A --record 0

Also ran the object, choice, combat, room, hall-feature, Unibot, save-variable, and symbol inspectors, saving their output under ignored build/analysis/ *-repro.txt files. Every command succeeded. The executable hash remained 4875f83d...9747643; the gallery contained the same 11 full-screen frames; D003 decoded to 9,064 samples at 9,000 Hz; MUS001 reported 12 timbres and 446 events; and NIV bank A record zero joined to Exodus 20:15.

Hashed the reconstructed executable and generated graphics/audio outputs, counted all inspector reports, and compared the new gallery and WAV with their prior generated counterparts. Both comparisons were byte-identical. Ran ./run.sh --setup-only; it returned success without opening QEMU or printing errors.

Finally reran Rizin with analysis/cb.rz, saved complete fl output, and validated it with the symbol inspector. Rizin emitted no stderr and the audit again reported 143 symbols: 108 functions, 26 handlers, 9 data, 77 Verified, and 66 High.

Full validation and consolidation closeout

Ran the complete final suite:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py \
  --rizin-flags build/analysis/final-book-flags.txt \
  > build/analysis/final-verification-symbols.txt
bash -n run.sh tools/build_qemu_dos_trace.sh
mdbook build docs
test -f build/docs-book/index.html
test -f build/docs-book/reproducing-results.html
test -f build/docs-book/known-gaps.html
git diff --check

All 101 tests passed in 4.235 seconds. Every Python file compiled, the 22-book- chapter plus README integrity audit passed, all 143 symbols and handler addresses remained synchronized, both shell scripts parsed, mdBook generated the index and both new chapters, and Git found no whitespace errors.

Marked the reproducible-procedures, book-review, and consolidated-book build tasks complete in PLAN. Did not erase the remaining research scope: focused interactive input/save traces, broad major-screen exercise, normal-exit user confirmation, and the third-party sound-driver ABI remain explicit open tasks. This long consolidation slice remains uncommitted pending the user’s next instruction.

Reproducibility-audit checkpoint

The user requested a commit. Audited all 14 modified/new paths, whitespace, tracked statistics, new-file line counts, documentation integrity, the focused documentation tests, the 25,840-command corpus regression, and a fresh mdBook build. The checker again reported 22 chapters plus README, all four focused tests passed in 0.075 seconds, and the book built without warnings. Confirmed that the file set is limited to the plan reconciliation, reproduction and known-gap chapters, stale-claim/count corrections, checker, tests, and this complete log. Prepared to stage those 14 files for one detailed checkpoint.

Created the checkpoint with subject docs: Consolidate reproducible research. Git reported 780 insertions and 16 deletions across 14 files, including the two new chapters, checker, and checker tests. Added this success record to the same checkpoint. The first post-commit message audit found body line 12 was 73 characters, one over the repository limit. Rewrapped that sentence, amended again, and then verified every message line and the clean worktree.

2026-07-15: DIGPAK/MIDPAK ABI and timbre library

Static interrupt inventory

Continued with the last concrete static-analysis gap: the bundled third-party sound layer. Began from clean commit 26a7275 and inspected PLAN, README, the audio/dynamic chapters, run.sh, tools/qemu_dos_trace.c, the current symbol map, and the installed sound files. Reported to the user that this slice would map every game-side int 66h, inspect SOUND.1 through SOUND.4, extend the QEMU tracer without enabling host audio, and reconcile static and live calls.

The principal read-only commands included:

git status --short
git log -5 --oneline
file CB/SOUND.{1,2,3,4}
stat -f '%z %N' CB/SOUND.{1,2,3,4,5}
shasum -a 256 CB/SOUND.{1,2,3,4,5}
strings -a CB/SOUND.1
strings -a CB/SOUND.3
sed -n '1,120p' CB/SETSOUND.BAT
rizin -q -b 16 -e scr.color=false \
  -c 'aaa' -c '/x cd66' build/analysis/CB_UNPACKED.EXE

Rizin found exactly 34 literal CD 66 sites. Precise pD views and raw xxd checks mapped 31 small wrappers, a driver-detection routine, and the DIGPAK and MIDPAK bootstrap calls. The API families are AX=0688h..0697h and AX=0701h..0710h. The game-owned high-level calls now have an exact chain: ABT decode, DIGPAK 068A preformat, DIGPAK 068B playback; and XMI load, MIDPAK 0704 registration, MIDPAK 0702 sequence playback.

The installer batch independently maps soundrv.com, midpak.adv, tmidpak.com, and midpak.ad to SOUND.1 through SOUND.4. File strings identify DIGPAK Sound Blaster 16 version 3.40 and the MIDPAK/Miles components. The five sound-file SHA-256 values remain those in the inventory; the first four sizes are 4,824, 16,263, 13,312, and 3,622 bytes.

Consulted the Ralf Brown Interrupt List pages for INT 66 and each service used by the game. These supply the contemporary service names, input registers, return values, capability bits, and the 12-byte SNDSTRUC definition. Reported the interim result to the user: the 34 sites split cleanly between the two published service families. This external lookup corrected provisional names such as “play decoded PCM” to the API’s exact preformat/play pair.

Tracer extension and visible captures

The existing plugin recognized only CD 21, inferred only AH, and did not log result registers. A first large apply_patch attempt failed atomically because its expected context included a duplicate function-09 branch that was not actually present. Re-read the complete source and applied the change in small hunks. The plugin now recognizes interrupt 21h and 66h, distinguishes the vector in each record, retains complete inferred AX as a fallback, counts DOS and driver calls separately, and pairs each call with its vector.

Built and syntax-checked it with:

tools/build_qemu_dos_trace.sh build/qemu-trace/qemu_dos_trace.so
bash -n run.sh tools/build_qemu_dos_trace.sh
git diff --check

Started ./run.sh --trace-dos in a PTY. The requested Cocoa window was visible with zoom-to-fit=on; -audiodev none kept host audio silent. After eight seconds, the first trace had 27,027 calls, including 26,832 driver calls. The large count was mostly the game’s status loops running under deliberately slow one-instruction translation blocks. Sent quit through build/qemu-trace/monitor.sock with nc -U and confirmed QEMU exited zero.

That run revealed an implementation detail rather than a game error. The register list included EAX, but the plugin still printed its fallback header. QEMU uses opaque handle value zero for the first x86 register; the plugin’s ordinary null-handle guard therefore mistook valid EAX for absence. Entry AX was still correct via inference, but return AX was unavailable. Added an explicit have_eax flag and an unchecked accessor for the known-present EAX descriptor.

Rebuilt and started a second visible, silent capture. The header then stated that AX was live, DOS version entry showed AX=3017, and driver returns became meaningful. The bounded run recorded MIDPAK registration/play/status and both DIGPAK idle and playing states. It contained 20,609 calls: 180 DOS and 20,429 driver. Its temporary trace hash was cd4412e02b5100684c196c1f773ef10368c73d39cbcbade11d26fb56da428cba. The next run intentionally replaced this ignored artifact.

Extended return records with BX, CX, DX, SI, DI, DS, and ES. Stored input AX in the pending call and, for service 068C, read the returned BX:CX ID string. The third visible capture directly produced:

CALL ... pc=0627:ABED int=66 AX=068C ...
RET  ... from=0627:ABED int=66 AX=0FC1 BX=2E88 CX=010C ...
         result="Sound Blaster 16"

Stopped it through the monitor after two seconds. QEMU again exited zero. The current ignored trace has SHA-256 ae9d9b5952171f35d9dff8a75548e399a15fb01ee27d4058b2800891766404a6 and 8,003 calls: 159 DOS and 7,844 driver. It captured two identical capability queries, successful 0710 driver load, ABT preformat/play, stop calls, and both DIGPAK status results. Every captured driver return had carry clear.

SOUND.4 format recovery

Used a short read-only Python struct probe to interpret the start of SOUND.4 as repeated <BBI records. It found 181 entries, FF FF at 0x43E, and first data at 0x440. IDs are bank-zero patches 0 through 127, then bank-0x7F percussion patches 35 through 87. Every absolute offset is 14 bytes after the previous one, and the final record ends at the exact 3,622-byte file boundary.

Web search located OPL3BankEditor’s AIL global-timbre-library support. Cloned the repository read-only to /tmp/opl3bankeditor and inspected pinned commit 992008e2edbaabcd8809df6be6bc91925597f1a9:

git clone --depth 1 \
  https://github.com/Wohlstand/OPL3BankEditor.git \
  /tmp/opl3bankeditor
sed -n '1,430p' \
  /tmp/opl3bankeditor/src/FileFormats/format_ail2_gtl.cpp

Its structure recovered from Miles AIL source explains every byte: six-byte patch/bank/offset directory entries, a two-byte record length, signed transpose or percussion note, five modulator OPL registers, feedback/ connection, and five carrier registers. All Captain Bible records are the 14-byte two-operator form.

Added executable tools/inspect_midpak_ad.py with strict directory, identity, offset, length, and whole-file validation. Added three focused tests covering the exact 128/53 population, first and last boundaries, OPL field regression, missing terminator, and bad length. The first test run failed because the test expectation accidentally included the transpose byte in the five-byte modulator tuple. The parser output was correct; fixed the expected bytes and all three tests passed.

Symbols and documentation

Added 31 API wrapper names and detect_digpak_driver to analysis/cb.rz and analysis/symbol-map.tsv. The first Rizin invocation used a single semicolon- separated -c string that this Rizin version rejected. A later script load also failed because newly named offsets had not all been explicitly declared as functions. Added af declarations, reran the normal -i analysis/cb.rz workflow, and resolved all 32 new functions with no stderr. The catalog now contains 175 symbols: 140 functions, 26 handlers, and 9 data; 82 are Verified and 93 High. Audio is now the largest subsystem with 41 named entries.

Added sound-drivers.md with installed-file identities, both ABI tables, SNDSTRUC, bootstrap and playback sequences, live trace evidence, and the exact AIL timbre layout. Linked it from SUMMARY, the audio chapter, README, and the reproduction guide. Updated Dynamic Analysis for live AX and both traced vectors, removed the resolved sound gap, corrected all current symbol counts, and marked the PLAN sound-driver task complete.

One claim-audit rg command included Markdown backticks inside a double-quoted shell string; Bash attempted to execute 21h and printed /bin/bash: 21h: command not found. The read-only search still completed, but repeated the search safely and recorded the quoting failure here. A combined documentation patch also failed atomically because its Dynamic Analysis context did not match exact line wrapping. Applied the updates in smaller verified patches.

Focused verification passed:

python3 -m unittest \
  tests.test_midpak_ad tests.test_inspect_symbol_map -v
tools/inspect_symbol_map.py
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c fl build/analysis/CB_UNPACKED.EXE \
  > build/analysis/cb-flags.txt
tools/inspect_symbol_map.py \
  --rizin-flags build/analysis/cb-flags.txt
tools/check_documentation.py
mdbook build docs
test -f build/docs-book/index.html
git diff --check

All seven focused tests passed, Rizin emitted no errors, catalog/script/flag coverage matched, documentation integrity passed, mdBook built the new chapter, and Git found no whitespace errors.

Full sound-layer validation

Ran the complete repository verification after the focused checks:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py \
  --rizin-flags build/analysis/cb-flags.txt \
  > build/analysis/final-sound-symbols.txt
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/build_qemu_dos_trace.sh \
  build/qemu-trace/qemu_dos_trace.so
mdbook build docs
test -f build/docs-book/index.html
test -f build/docs-book/sound-drivers.html
git diff --check

All 104 tests passed in 4.300 seconds. Every Python source compiled; the checker reported 23 chapters plus README; the 175-entry catalog and Rizin flags remained synchronized; both shell scripts parsed; the C plugin rebuilt without warnings; mdBook generated the new sound-driver chapter; both HTML checks passed; and Git found no whitespace errors. The remaining PLAN work is interactive input/save tracing, representative major-screen exercise, and the user’s normal-exit confirmation. This sound-driver slice is deliberately left uncommitted until the user asks for a checkpoint.

A final C review noticed that trace_interrupt called the EAX accessor before checking have_eax; harmless on the tested i386 target but unsafe for the documented fallback path. Made initialization conditional, normalized the EAX/ EBX branch formatting, rebuilt the plugin without warnings, reran the 23-chapter documentation check and mdBook build, and repeated git diff --check. All passed.

Sound-driver checkpoint

The user requested a commit. Rechecked the complete 18-path worktree with git status --short and git diff --check, reran the documentation checker, and audited the 175-entry symbol catalog. The checker again reported 23 chapters plus README, the symbol audit succeeded, and no whitespace errors were present. Reviewed the change statistics and prepared one detailed commit covering the tracer, static symbols, AIL inspector and tests, book chapter, PLAN/README reconciliation, and this append-only log.

Created commit 0b34759 with subject audio: Recover DIGPAK and MIDPAK interfaces. Git recorded 846 insertions and 83 deletions across 18 files, including the executable timbre inspector, its tests, and the sound-driver chapter. Appended this success record, staged only the progress log, and amended the same checkpoint so the working tree would remain a single coherent commit.

2026-07-15: Interactive environment confirmation

The user reported that keyboard and mouse input work and that Captain Bible exits normally through its menus. Recorded this as user-verified evidence in the environment chapter, checked the final Phase 1 PLAN item, and removed the now-stale normal-exit request from Known Gaps. This confirmation complements the existing automated boot marker and bounded title-screen capture; it does not replace the still-open focused input/save tracing or representative major- screen capture tasks.

Interactive-confirmation checkpoint

The user requested a commit. Audited the four modified files, ran git diff --check, repeated the 23-chapter documentation integrity check, and reviewed the complete patch. The changes are limited to the user-confirmed playability result, PLAN closure, removal of the stale gap, and this log. Prepared one detailed documentation checkpoint.

Created commit 0f2b4b7 with subject docs: Record interactive playability. Git recorded 24 insertions and four deletions across the four intended files. Appended this success record and amended the same checkpoint so the confirmation remains one coherent commit.

2026-07-15: Formal input/save and gameplay captures

The user requested the two remaining formal PLAN tasks: focused interactive input/save tracing and representative major-screen/gameplay exercise. Reported that these would be handled as one visible, silent QEMU pass, including a combat-state memory/table capture, before updating the plan and book.

Reviewed PLAN.md, the existing dynamic-analysis chapters, save documentation, manual controls, prior QEMU monitor transcripts, ignored captures under build/qemu-trace/, and the current disk-image contents. The existing tracer already observed DOS writes but did not observe the BIOS keyboard and DOS mouse interfaces. Extended tools/qemu_dos_trace.c to recognize game-originated interrupts 16h and 33h, preserve their AX service value, and count keyboard, DOS, mouse, and driver calls separately. Reported this tracer extension to the user before starting the focused run.

Built the extended plugin and prepared a disposable analysis image:

tools/build_qemu_dos_trace.sh \
  build/qemu-trace/qemu_dos_trace.so
mkdir -p build/formal-captures
cp -c build/captain-bible/captain-bible.img \
  build/formal-captures/formal-tasks.img
mdel -i build/formal-captures/formal-tasks.img@@1048576 \
  ::/CBDOME/SOUND.1 ::/CBDOME/SOUND.2 \
  ::/CBDOME/SOUND.3 ::/CBDOME/SOUND.4

The four sound drivers were removed only from this generated clone to avoid sound-driver status loops during the instruction-boundary trace. The user’s persistent play image was not changed. Copied the initial DDGAMES.SV0; its SHA-256 was 68c0953383164d281197be5b073d4b911a4785a47a855d78197480e5812c130f.

Launched QEMU with TCG one-instruction translation blocks, the tracer, silent Sound Blaster/AdLib devices, and the required visible display:

qemu-system-i386 \
  -name 'Captain Bible formal capture' \
  -machine pc -accel tcg,one-insn-per-tb=on \
  -cpu pentium -m 16 -boot c \
  -drive file=build/formal-captures/formal-tasks.img,\
format=raw,if=ide,index=0,media=disk \
  -vga std \
  -plugin build/qemu-trace/qemu_dos_trace.so,\
log=build/formal-captures/formal-calls.log,cs=0x627,start=0xCB5C \
  -monitor unix:build/formal-captures/formal-monitor.sock,\
server=on,wait=off \
  -audiodev none,id=audio0 \
  -device sb16,audiodev=audio0 \
  -device adlib,audiodev=audio0 \
  -display cocoa,zoom-to-fit=on

Captured and visually inspected the initial playable screen. The live trace showed keyboard polling at 0627:E9DC, mouse motion at 0627:8D8A, and mouse position/buttons at 0627:8DCD.

Quick save and mouse evidence

Sent F10 through the QEMU monitor. BIOS service 0101 returned AX 4400, and the consuming service 0000 returned the same scan/ASCII word. The next file sequence opened DDGAMES.SV0, then opened/created/wrote DDGAMES.SVQ. The write entries covered the recovered 200, 200, 66, 660, four-by-20, five-by-2, and two-by-768 state regions, with the C library’s buffered leading byte accounting for the first 200-byte block split.

Copied both files out after the operation. SV0 retained its original hash; the 2,752-byte quick save had SHA-256 5e329e21f32d2e6c3e564d3a3ad717ab07ad55aaedde2587725756945597e43f. tools/inspect_save.py --variables decoded translation/music/effects value 1, text bank C, INTRO/seg resource strings, 46 active text descriptors, and the expected script-variable blocks. Reported that the F10 input and resulting guest write were joined in one timeline.

Queried QEMU monitor help for mouse_move and mouse_button, moved the mouse by (48,24), then held and released the left button in separate monitor commands so polling could observe the held state. Service 0003 changed from X/Y 0140:0064 to 01D0:0088; while held, repeated returns had BX=0001 at that position. Extracted the relevant ignored trace portions to build/formal-captures/mouse-focused.log. Reported this game-boundary mouse evidence to the user.

Normal save and representative screens

Used F9 to return from the accidentally selected commander conversation to the quick-saved introduction state. Escape opened the game options menu. Sent two Down keys and Enter to choose Save Game, selected a numbered slot, typed formal, and pressed Enter. The interface appended the typed characters to the existing visible label, producing EMPTYformal in slot 2 rather than clearing EMPTY; this UI behavior is preserved as observed rather than corrected in the image.

The trace recorded BIOS word 1C0D for Enter, then rewrote DDGAMES.SV0 and DDGAMES.SV2. After stopping QEMU, extracted both files. Their SHA-256 values were:

84c18787ecd4d9190943a188884b612d574dcd682f2480d110cbc8eb5175343d  SV0
659b27f3ed7412fa66cb69dac6b2b5d0b44c200c5069660b362f299aa67fac63  SV2

The first attempt to run tools/inspect_save_index.py failed because no such separate tool exists. Corrected the command to tools/inspect_save.py build/formal-captures/normal-after.SV0; it decoded all nine 27-byte labels and the new EMPTYformal slot. The state inspector confirmed an exact 2,752-byte state with INTRO/seg, bank C, and the expected named variables. Extracted the focused normal-save trace around calls 1000172 through 1000203.

Started a fresh disposable QEMU clone without the slow tracer, keeping -audiodev none and -display cocoa,zoom-to-fit=on. Used monitor sendkey, mouse_move, mouse_button, and screendump commands, converting PPM captures to PNG with sips for visual inspection. Exercised and inspected:

  • the primary title and landscape title transition;
  • the commander conversation and its continue dialogue;
  • the difficulty selector and Normal selection;
  • active exterior-platform gameplay and an interior hall;
  • the F1 Bible interface, F2 map, and F3 faith overlay;
  • gameplay options, save-slot selection, and name entry;
  • the later controlled combat and defeat screens.

An F1 sent during the introductory sequence did not open the Bible; it advanced to the difficulty selector. After Normal gameplay began, F1 opened the Bible interface as expected. Sent repeated Enter keys to advance the commander conversation into gameplay. Mouse target experiments used clamped relative motion; an initial delta of 143 overflowed the effective signed event range and left the pointer at the edge, so subsequent movement used deltas below 128. Clicking the exterior door successfully changed to the hall screen. These intermediate pointer experiments changed only the disposable image.

Controlled COMBAT1 entry and runtime tables

Quick-saved from the hall, stopped QEMU, and extracted the resulting SVQ. tools/inspect_save.py --variables identified snapshot/live names MENU/CHAL, extension seg, Normal difficulty, and live faith 10,000. Added tools/patch_save_scene.py, which validates the exact 2,752-byte size and changes only the two 20-byte scene-name fields. Added tests for both-field replacement, invalid names, and invalid state size.

The first focused test command used pytest, but the project and host use standard-library unittest and pytest was not installed. Rewrote the test in unittest style and confirmed all three cases passed:

python3 -m unittest tests.test_patch_save_scene -v
python3 tools/patch_save_scene.py \
  build/formal-captures/hall.SVQ COMBAT1 \
  build/formal-captures/combat1.SVQ
python3 tools/inspect_save.py \
  build/formal-captures/combat1.SVQ --variables

cmp -l showed changes only in the two saved scene-name fields. Injected the patched quick save with mtools, copied it back out, and confirmed a byte-for- byte round trip. Restarted visible, silent QEMU, entered the game, and sent F9. The resulting screen visibly showed the COMBAT1 Macho encounter and ATTACK selector.

Saved one MiB of physical memory and captured info registers; the game was at CS:IP=0627:E9DC with DS=ES=SS=14E1. The first dump had action/animation counts 0/1 because it caught initialization between redraws, so it was retained as historical evidence but not used for comparison. Sent the A key, observed a green attack effect, waited, and saved a second one-MiB dump. The run later reached the “Don’t Give Up!” defeat screen, consistent with the controlled checkpoint’s zero snapshot faith; it is not treated as an ordinary combat outcome.

The first attempt to inspect static COMBAT1 used stale extraction prefix 331_COMBAT1.BIN and failed with FileNotFoundError. Located the current member with rg --files; the correct path is build/dd1/all/343_COMBAT1.BIN. A read-only Python comparison then established four of four action records and 33 of 33 animation first-step/interval pairs.

Added executable tools/inspect_runtime_tables.py to make that comparison reproducible. It accepts a physical dump and runtime DS, decodes the counted ten-byte action and 12-byte animation records, preserves ten 16-byte thread records, and optionally compares a loaded BIN. Added three tests for exact field decoding, short dumps, and implausible counts. The first direct tool run failed with permission denied because chmod +x followed it in the same command block. Applied the executable bit, reran it successfully, and obtained:

actions: 4
animations: 33
threads: current=0; shown=10
comparison: actions=4/4; animations=33/33

ATTACK, DEFEND, and RETREAT were active; COMBAT was present and inactive. Thread slots 0, 5, 7, and 8 had active byte 1. The stable memory dump has SHA-256 becc98dd2eba0bad502f1bf6b7aef4ef2638fa48d0e0e62688ad879085bc1654. Reported the successful live table match and controlled-entry caveat to the user.

Tracer return matching and interruption

While reviewing the combined input/save trace, noticed DOS return callbacks at 0070:018C. Translation-time return callbacks are global: adding input vectors made it more likely that an interrupt inside DOS would execute at an address registered for some other interrupt while a game call was pending. Entry records remained correct, but these return records could not be trusted.

Added the exact pending linear return address to struct pending_call and made trace_return require the current CS:IP to match it. Built a separate fixed plugin and ran another visible, silent disposable-image capture. It ended with:

# captured calls: 119824 (keyboard=39903, DOS=115, mouse=79806, driver=0)

An rg audit found no pc=0070:018C false returns. The new run also showed correct paired DOS startup returns and paired keyboard/mouse polling returns.

The user then said to stop. Closed every QEMU instance and reported that the captures and tools were complete but PLAN/README/mdBook/progress-log updates and final validation remained unfinished. No commit was made at that point.

Preparing the formal-capture checkpoint

The user subsequently requested a commit. Resumed only the consolidation work: checked both Phase 3 PLAN tasks, updated README and the reproduction guide for the two new tools and four traced vectors, documented focused input/save and representative-screen evidence, replaced the obsolete combat-capture gap with the live COMBAT1 result, and preserved the controlled scene-entry limitation in both Combat Runtime and Known Gaps. Prepared to run the complete repository verification before creating the checkpoint.

Full validation passed:

git diff --check
python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py
bash -n run.sh tools/build_qemu_dos_trace.sh
tools/build_qemu_dos_trace.sh build/qemu-trace/qemu_dos_trace.so
mdbook build docs
test -f build/docs-book/index.html
test -f build/docs-book/dynamic-analysis.html
test -f build/docs-book/combat-runtime.html

All 110 tests passed in 4.261 seconds. Every Python source compiled, the checker reported 23 chapters plus README, the 175-entry symbol catalog passed, both shell scripts parsed, the QEMU plugin rebuilt without warnings, mdBook generated the complete book and both updated chapters, and Git found no whitespace errors.

Created the requested checkpoint with subject dynamic: Validate input saves and combat. Git recorded 13 changed files, 965 insertions, and 50 deletions, including four new tested/executable support files. Appended this success record for the same checkpoint before its final amendment.

GitHub Pages publishing

The user requested a GitHub workflow that publishes the mdBook to GitHub Pages. Inspected the repository files, docs/book.toml, README documentation instructions, PLAN, recent commits, origin URL, and existing working-tree changes. Found pre-existing uncommitted edits to .gitignore, README, and docs/book.toml, including the move of generated output from build/docs-book/ to ignored docs/book/; preserved those edits.

Checked GitHub’s official Pages documentation and action repositories. The documented custom-workflow shape is checkout, static-site build, Pages artifact upload, and deployment through a github-pages environment with pages: write and id-token: write. Confirmed the current official major releases used here: actions/checkout@v6, actions/upload-pages-artifact@v5, and actions/deploy-pages@v5.

Queried the official mdBook v0.5.3 GitHub release API for Linux assets. The first Python one-liner failed with a syntax error because escaped quotes were used inside an f-string expression. Replaced it with str.format, listed the assets successfully, and read the API-provided digest for the GNU x86-64 archive:

sha256:e2fd508a4fac06cbaa9f88b97d27bdc3b55a08946304ca845879fe26a3699e11

Added .github/workflows/publish-docs.yml. It triggers for relevant pushes to main and manual dispatches, downloads and verifies the tested mdBook binary, builds docs/book/, uploads that directory as the Pages artifact, and deploys it in a separate least-privilege job. Kept in-progress deployments from being cancelled. Corrected mdBook’s project-site base URL from /captain-bible-re/docs/ to /captain-bible-re/, documented the public URL and one-time Pages setting in README, and added a publication phase to PLAN.

Validated the workflow and book with:

git diff --check
ruby -e 'require "yaml"; YAML.parse_file(".github/workflows/publish-docs.yml")'
curl --fail --location --silent --show-error \
  --output "$archive" \
  https://github.com/rust-lang/mdBook/releases/download/v0.5.3/mdbook-v0.5.3-x86_64-unknown-linux-gnu.tar.gz
shasum -a 256 --check
tar --list --gzip --file "$archive"
python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
mdbook build docs
test -f docs/book/index.html

Ruby parsed the workflow successfully. actionlint was not installed, so its optional semantic check was reported and skipped rather than implied to have run. The downloaded archive matched the pinned digest and contained mdbook. All 110 tests passed in 4.222 seconds, every Python file compiled, the documentation checker reported 23 chapters plus README, and the local book build completed at docs/book/. Confirmed that its generated 404.html uses <base href="/captain-bible-re/">. Git found no whitespace errors in tracked changes; the untracked workflow received a separate no-index whitespace check during final review.

The user selected GitHub Actions as the repository’s Pages source and confirmed that the first deployment completed successfully. Checked the last Phase 6 PLAN item and replaced README’s one-time setup instruction with the active publication state. The book is now available at https://peterkelly.github.io/captain-bible-re/; later relevant pushes to main publish automatically through the workflow.

Preparing the Pages publication checkpoint

The user requested a commit. Reviewed both index and working-tree changes. The combined checkpoint includes the already staged Pages workflow and deletion of the superseded FREEDOS_SETUP_INSTRUCTIONS.md, the publication configuration and records above, the generated-book ignore rule, and the project-repository link added to the book introduction. Confirmed that no live tracked file other than this historical log refers to the deleted setup brief; its implemented procedure remains documented in README, the environment chapter, and tools/setup_freedos_image.py.

Ran the final combined validation:

git diff --check
git diff --cached --check
ruby -e 'require "yaml"; YAML.parse_file(".github/workflows/publish-docs.yml")'
python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py
bash -n run.sh tools/build_qemu_dos_trace.sh
mdbook build docs
test -f docs/book/index.html

All 110 tests passed in 4.176 seconds. Ruby parsed the workflow, every Python source compiled, the documentation checker reported 23 chapters plus README, the 175-entry symbol map passed, both shell scripts parsed, and mdBook produced docs/book/index.html. The generated 404 page retained the required /captain-bible-re/ base, and both staged and unstaged diffs had no whitespace errors. Prepared to stage the exact eight-file publication checkpoint and commit it with the repository’s required detailed message.

The first combined git add named the already deleted FREEDOS_SETUP_INSTRUCTIONS.md as a normal path and failed with pathspec did not match any files. The deletion was already staged and remained intact. Staged the seven existing files separately and retained that indexed deletion as the eighth change.

Created the requested checkpoint with subject docs: Publish mdBook with GitHub Pages. Git recorded eight changed files, 199 insertions, and 177 deletions, including the new workflow and removal of the superseded setup brief. Appended this success record before amending the same checkpoint.

2026-07-19: Completing the BIN opcode catalog

The user asked to determine the currently unknown opcode meanings. Began by checking the clean worktree and searching the plan, README, scene-bytecode chapter, gap list, inspector, tests, Rizin script, and symbol catalog:

git status --short
rg -n "Phase|opcode|unknown|OPCODE_NAMES|SCRIPT_VARIABLE_OPERANDS" \
  PLAN.md README.md docs/src/scene-bytecode.md docs/src/known-gaps.md \
  tools/inspect_bin.py tests analysis/cb.rz analysis/symbol-map.tsv
sed -n '1,330p' tools/inspect_bin.py
sed -n '1,130p' PLAN.md
sed -n '1,130p' docs/src/known-gaps.md
sed -n '1,280p' docs/src/scene-bytecode.md

OPCODE_SCHEMAS covered values 0x01..0x91, while 51 values still fell back to structural opcode_XX names. Parsed the 145-word dispatch table at load offset 0x59AB and mapped every such value to its concrete handler. The first Python formatting attempt failed with SyntaxError: unexpected character after line continuation character because an escaped quote appeared inside an f-string expression. Replaced that expression with str.format and obtained the complete mapping.

The first corpus counter also used (0, 0x08C6) as ROOM3.BIN’s initial region and stopped at invalid opcode zero at 0x0336. Re-read the regression test and corrected the three regions to 0x0000..0x0336, 0x0C96..0x1754, and 0x1768..EOF; CP2.BIN remained limited to 0x0000..0x1D55. The initial structural-name inventory contained 51 values, 13 of which were absent from shipped code.

Inspected handler bodies, shared state consumers, and corpus contexts with commands including:

rizin -q -b 16 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz -c 'pdf @ 0x67bb' -c 'pdf @ 0x6822' \
  -c 'pdf @ 0x6889' -c 'pdf @ 0x6da5' -c q \
  build/analysis/CB_UNPACKED.EXE
rizin -q -b 16 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz -c 'pdf @ 0x6ebd' -c 'pdf @ 0x6f46' \
  -c 'pdf @ 0x7469' -c 'pdf @ 0x737f' -c q \
  build/analysis/CB_UNPACKED.EXE
rizin -q -b 16 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz -c 'axt @ 0x7dc0' -c 'axt @ 0x7dc2' \
  -c 'axt @ 0x7dc4' -c 'axt @ 0x7dc7' -c q \
  build/analysis/CB_UNPACKED.EXE
rizin -q -b 16 -e scr.color=false -e asm.bytes=false \
  -i analysis/cb.rz -c 'pdf @ 0xb53a' -c 'axt @ 0x82e4' \
  -c 'pd 55 @ 0xb55c' -c q build/analysis/CB_UNPACKED.EXE

The navigation system provided the largest cluster. Opcode 0x0B appends two-node edges searched recursively by 0x6DA5. Opcodes 0x11 and 0x12 append destination-arrival and source-departure callbacks consumed at 0x67BB and 0x6822. Shared handler 0x4F44 records edge callbacks: 0x17 forward departure, 0x18 reverse departure, 0x19 reverse arrival, and 0x1A forward arrival. Opcode 0x54 requests motion to a node, 0x5B selects one of four directions, and 0x0A waits until motion is idle. Opcodes 0x53, 0x40, 0x1C, and 0x1D initialize the node, set the motion state, and enable or disable a scene-thread selector. Opcode 0x10 supplies that selector’s coordinates and inline label. Opcode 0x79 clears the four entry and callback counts.

Tracing 0x7CC0 to the main loop at 0x87C1 proved that opcode 0x64 branches on and consumes the Enter-or-click latch. Tracing state word 0x7A through game_main showed that opcode 0x67 requests mode 2, which restores the retained save buffers. The SOUND.5 installation analysis established that opcode 0x8C branches on the no-combat flag. Opcode 0x5A tests the digital-audio fallback flag, but only after guards that require a real driver and the capability whose absence sets that flag. Its branch is therefore unreachable in the recovered shipped engine path; the documentation records the contradictory guards instead of hiding the apparent bug.

The study path at handle_study_bible_request established three configuration forms. Opcode 0x15 selects a text record and clears success continuations; unused 0x4F adds a navigation-node continuation; opcode 0x51 adds a BIN target and scheduler-slot continuation. Unused 0x50 clears the active record selectors. Opcodes 0x5C and 0x5D write the three presentation fields for Captain Bible and character dialogue respectively. Unused opcode 0x47 seeds a modal menu selection, while unused 0x5E stores a target later started by the main loop in scheduler slot 2.

Other direct effects recovered in this pass include full-screen fill (0x4C), palette-map range fill from a variable (0x16), mouse X/Y reads (0x62/0x63), signed 1,280-unit variable wrapping (0x68), direct and indirect BIN word loads (0x69/0x71), BIN word patching (0x6A), BIN byte loading (0x84), companion text-bank loading (0x6B), text-component copying into BIN memory (0x83), finished-animation branching (0x8A), file-open failure branching (0x8D), and current-map-cell byte modulo (0x91). Unused opcode 0x8B consumes one random available text descriptor when variable zero is 2. Unused 0x6E starts, and 0x6F waits for, a primary scene-thread transient overlay. Opcodes 0x0E, 0x4A, 0x4B, and 0x56 all dispatch to the exact same no-op continuation address 0x4535.

Correcting opcode 0x69’s boundary

While checking the binary-memory commands byte by byte, disassembly at 0x56F4 showed that opcode 0x69 calls bin_read_u16, enters the shared BIN word-load path, and then calls bin_read_u16 again for its destination variable. The schema incorrectly said H rather than HH. The old decoder still reached every region boundary because each destination’s low byte was a valid opcode; in CP2.BIN the word 0x0040 appeared as a false opcode 0x40 command.

Changed 0x69 to HH, added all newly proven variable-operand positions, and rebuilt the entire archive corpus with a Python counter. The corrected result is 64 regions, 25,829 commands, and the same 122 genuinely used opcode values. There are eleven 0x69 commands, exactly accounting for the reduction from 25,840. Added a regression for CP2.BIN offset 0x15D4: it now decodes as load_bin_word 0x0DEB, var@0x0040 ending at 0x15D9, with no phantom command at 0x15D7.

Assigned names to all 145 entries in OPCODE_NAMES and added a set-equality test against OPCODE_SCHEMAS. Added 45 distinct newly recovered handler flags to analysis/cb.rz and matching evidence rows to the checked symbol map. Four no-op opcodes and the four directional callback opcodes share implementations, so the expanded executable catalog contains 71 distinct BIN handler symbols, not 145. tools/inspect_symbol_map.py reports 220 total entries: 140 functions, 71 handlers, and 9 data symbols.

Updated PLAN with the opcode-completion phase; updated README, Static Analysis, Scene Bytecode, Function Map, and Known Gaps. The scene-bytecode chapter now contains a complete table for the 51 formerly structural values, explicitly marks the 13 values absent from shipped code, documents the unreachable 0x5A branch condition, and explains the 0x69 correction. Historical progress entries retain their older counts as a chronological record.

The first full test run after expanding the symbol catalog failed one of 112 tests. The catalog correctly used the already supported medium confidence for the two unused handlers whose higher-level roles remain uncertain, but the test still asserted that the observed confidence set was exactly verified and high. Kept the honest confidence values and updated that test to accept all three levels allowed by inspect_symbol_map.py. The next full run passed all 112 tests in 4.265 seconds.

Ran the remaining reconciliation checks:

python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py
mdbook build docs
test -f docs/book/index.html
git diff --check
rizin -q -b 16 -e scr.color=false -i analysis/cb.rz \
  -c 'fl~bin_handler' -c q build/analysis/CB_UNPACKED.EXE | \
  python3 -c '...compare live flags with the TSV catalog...'
tools/inspect_bin.py build/dd1/all/315_CP2.BIN --limit 0x1d55 | \
  rg -C 2 '^15d4'

All Python files compiled. The documentation checker reported 23 chapters plus README, mdBook built docs/book/index.html, and Git found no whitespace errors. The symbol checker reported 220 entries with 115 verified, 103 high, and 2 medium-confidence entries. The live Rizin output matched all 71 handler address/name pairs exactly. The final CP2 inspector check shows 0x15D4 as the five-byte load_bin_word 0x0DEB, var[32]@0x0040, followed immediately by the real subtraction at 0x15D9.

After checking the last two PLAN items, ran git diff --check, the documentation checker, and the focused BIN/symbol-map suites once more. Git reported no whitespace errors, documentation remained consistent, and all 32 focused tests passed in 0.177 seconds. The worktree contains only the 12 intended modified source, analysis, test, and documentation files; no generated book output is tracked.

2026-07-19: Full-width mdBook layout

The user reported that large tables remained difficult to read when widening the browser because mdBook kept the article at a fixed maximum width. Checked the clean worktree and current documentation layout with git status --short, rg --files docs, and focused sed reads of docs/book.toml, PLAN.md, README.md, and this progress log. Ran mdbook --version, which reported 0.5.3, and searched the generated theme CSS. The built-in variables stylesheet sets --content-max-width: 750px, and the general stylesheet applies that variable as the max-width of .content main.

Created docs/theme/wide.css with --content-max-width: none on :root and registered it through additional-css in docs/book.toml. Using the existing variable removes the limit consistently from the article and the related search layout while preserving mdBook’s sidebar, padding, and responsive rules. Added a completed publication task to PLAN and described the override in README.

Ran the documentation checker and rebuilt the book. The checker reported 23 chapters plus README, and mdBook wrote docs/book/index.html. An initial test for the literal output path docs/book/theme/wide.css stopped because mdBook 0.5.3 fingerprints additional stylesheets. A find and HTML-head inspection showed the actual output as theme/wide-d12ccf4c.css, linked after the built-in stylesheets. Its contents retain --content-max-width: none. Started a temporary localhost server for a rendered layout check; the sandbox rejected the first loopback bind, so repeated it with the required approval. The user then inspected the rendered book visually and confirmed that the full-width fix works. Stopped the temporary server immediately afterward.

Before the requested commit, reviewed the complete five-file change set with git status --short, git diff, git diff --stat, and git diff --check. The diff contained only the stylesheet, its mdBook configuration, and the corresponding PLAN, README, and progress-log updates; the whitespace check passed.

2026-07-19: Independent BIN opcode audit

The user requested an extremely thorough second pass because correcting opcode 0x69 from one word to two showed that a stream reaching its expected end is not sufficient proof of an operand schema. Added Phase 8 to PLAN. The audit will independently re-derive every dispatch target and operand-read path from CB_UNPACKED.EXE, then verify handler semantics, script-variable roles, shipped-corpus usage, tests, the symbol map, Rizin annotations, and all mdBook claims.

Started with git status --short, broad rg inventories of opcode references, and focused reads of tools/inspect_bin.py, tests/test_inspect_bin.py, analysis/cb.rz, analysis/symbol-map.tsv, and the scene-bytecode chapter. Checked the available analysis artifacts and ran Rizin 0.9.1 against build/analysis/CB_UNPACKED.EXE. pdfj reports 1,817 instructions in execute_bin_commands, and pxhj 290 @ 0x59ab returns exactly 145 dispatch pointers. The JSON instruction records expose direct call targets and both edges of conditional branches, which is sufficient to machine-walk each handler’s calls to bin_read_u8, bin_read_u16, and bin_read_cstring_offset, including shared tails such as opcodes 0x69, 0x71, and 0x84.

The initial inventory found one definite evidence-label error before the schema walk: the symbol-map row for bin_handler_configure_study_prompt says opcode 0x7E, although its dispatch entry is 0x7D; opcode 0x7E is the separate palette-blackout handler. The scene-bytecode table and inspector use the correct values, so this is a catalog description defect rather than a decoder boundary error.

Continued with git status --short and the focused command python3 -m unittest tests.test_audit_bin_opcodes tests.test_inspect_bin tests.test_inspect_symbol_map. After updating the catalog-count expectation, all 39 tests passed. A small Python csv/Counter inventory then established the complete expanded symbol totals: 283 entries comprising 140 functions, 134 handlers, and 9 data symbols. The subsystem total is 141 bytecode entries; the prior function-map chapter still contained several obsolete 71-handler and 175-entry statements.

Added tools/audit_bin_opcodes.py. It reads the 145 little-endian dispatch pointers with Rizin pxhj, reads the complete interpreter CFG with pdfj, and walks every reachable path from each target. Calls to bin_read_u8, bin_read_u16, and bin_read_cstring_offset produce independent operand events. Strongly connected-component analysis identifies the four inline byte-reading string loops at 0x4D8C, 0x4DA4, 0x4DE9, and 0x540F; a direct add word [0xf6], 9 identifies opcode 0x07’s raw record. The audit expands conditional BHs into both BH and BHH, and permits an operand-free retry path only for the shared dialogue handler at 0x52A3.

The dispatch table has 145 entries but 134 distinct addresses. The only shared groups are opcodes 04/43, the no-ops 0E/4A/4B/56/60, dialogue 14/48/4E, edge callbacks 17/18/19/1A, and palette loads 4D/6D. Expanded analysis/cb.rz and analysis/symbol-map.tsv so every one of the 134 targets has a checked handler symbol. Generated analysis/opcode-audit.tsv, joining each opcode to its address, symbol, confidence, declared schema, observed read paths, variable operands, use count, resource set, and first site. Running tools/audit_bin_opcodes.py --write-report analysis/opcode-audit.tsv reports 145 opcodes, 134 handlers, 122 used values, and 25,829 commands.

The structural walk confirmed all byte and word counts after the earlier 0x69 fix, but found that the old z marker conflated two implementations. Opcodes 01, 0D, 4D, and 6D use inline-only NUL loops. Opcodes 0C, 10, 14, 3A, 44, 48, and 4E call the pointer reader, which also accepts FF plus a word offset. Split those encodings into z and p in the decoder and tests. An exhaustive corpus assertion found exactly two explicit offset uses: ROOM3.BIN:0x181C (0x48) and ROOM3.BIN:0x18CE (0x4E), both pointing to 0x0336.

Tried Rizin’s pdc on three handlers, but this Rizin 0.9.1 build has no decompiler command; its help redirected the work to ordinary pd disassembly. Also tried the older psz spelling while checking initialized strings; this build uses the newer ps syntax. An initial inspector command used nonexistent extracted index 345_TITLE.BIN; rg --files build found the correct 332_TITLE.BIN, whose bytes and decoded listing confirm opcode 0x8D at 0x012C with target 0x012F, exactly its fallthrough.

Reviewed disassembly around 0x4623, 0x491C, 0x500E, 0x5089, 0x514D, 0x52A3, 0x54DC, 0x556E, 0x56C2, 0x589F, 0x58F1, and 0x5971, plus motion-state consumers at 0x758A and 0x7778. This produced several semantic refinements: opcode 0x0F subtracts its word from scheduler delay; unused 0x1B sets a consumed motion-transition latch rather than a proven completion signal; 0x68 makes only one signed-threshold correction; 0x7B ORs an unmasked variable byte with the preserved map high nibble; 0x84 sign-extends its loaded byte; and 0x91 uses signed division after zero-extending the cell byte. Opcode 0x5A is now described by its exact four nonzero guards rather than the older unreachable-path inference.

Resolved opcode 0x8D by joining its handler with the save filename routines and sole script site. It copies the active player prefix, appends the mutable suffix, and opens the result as rb; at title startup this is the .SV0 index. Failure selects 0x012F, which is also fallthrough, so both outcomes enter the same INTRO scene change. The handler calls fclose after either path, even when fopen returned null. Updated the current-reference chapters, symbol evidence, README, and reproducing commands; chronological older progress entries remain unchanged.

Continued the semantic pass through all 134 distinct handler bodies and their direct callees rather than stopping after operand-width agreement. The review confirmed that opcode 0x3F retries unless animation state is exactly 0, 5, or 6; unused 0x65 reads first, count; unused 0x66 reads first, count, minimum, maximum; and unused 0x8B tests descriptor state byte +4, clears it, and starts a 3,000-tick timer only when variable zero is 2. It also confirmed that 0x08 reads animation, mode, 0x5F reads animation, linked animation, mode, and 0x3A reads target, x, y, label. Corrected the symbol evidence that had called 0x5F’s second operand a display record and had described opcode 0x10’s pointer-capable label as inline-only.

While applying the corpus join uniformly, found one lingering mixed-resource boundary of 0x1D5A in the gallery tool and a map test. Direct inspection shows that CP2.BIN code ends at 0x1D55, immediately before its 256-byte Unibot graph. Added code_regions() to tools/inspect_bin.py as the single source for the CP2.BIN and ROOM3.BIN regions, then changed the audit, gallery, and tests to use it. Regression tests now require the exact CP2 end and all three ROOM3 regions.

Added tests/test_opcode_documentation.py and made the Scene Bytecode catalog one-to-one by splitting grouped rows whose opcodes have different names. The test extracts all 145 rows from the Markdown, compares every schema and name with OPCODE_SCHEMAS and OPCODE_NAMES, and compares every Unused marker with the zero-use rows in the checked audit report. It also walks all other mdBook chapters and checks every compact opcode schema table against the same catalog. This prevents a correction in the decoder from leaving a conflicting schema in World Maps, Conversation Flow, or another secondary chapter.

The cross-chapter rg review found one address-description ambiguity in World Maps: load offset 0x034F is load_map_resource, called by opcode 0x78’s handler at 0x460E, rather than the handler itself. Corrected that wording. Replaced the broad statement that shipped opcode-0x7B callers use values in 0x00..0x0F with the exact observed set 00, 05, 0A, 0B, and 0C. A regression checks all 30 sites: each is immediately preceded by set_variable for the same operand, and the immediate values equal that five-value set. Marked the unused direct-display and frame-range commands in both the canonical catalog and the display-object chapter.

The documentation reconciliation also removed stale handler totals and old open questions from Function Map, Known Gaps, Conversation Flow, Static Analysis, Combat Runtime, Save Formats, and the other current-reference chapters. The final catalog is 283 symbols: 140 named functions, 134 distinct handler addresses, and 9 data symbols. Confidence totals are 163 verified, 118 high, and 2 medium. The two medium entries are deliberately limited to the higher-level gameplay roles of unused opcodes 0x1B and 0x47; their operand widths and direct state effects are still established.

Ran the focused audit command repeatedly while making the corrections:

python3 -m unittest \
  tests.test_opcode_documentation \
  tests.test_audit_bin_opcodes \
  tests.test_inspect_bin \
  tests.test_inspect_map \
  tests.test_fullscreen_gallery \
  tests.test_inspect_symbol_map
tools/audit_bin_opcodes.py --write-report analysis/opcode-audit.tsv
git diff --check

The final focused run passed 61 tests. The regenerated report again contained 145 opcodes, 134 handlers, 122 used values, and 25,829 commands. A live Rizin flag dump loaded analysis/cb.rz without errors, and tools/inspect_symbol_map.py --rizin-flags matched all cataloged names and addresses. Searches for the superseded handler counts, CP2 boundary, 0x68 wrap name, 0x5A reachability claim, 0x7B masking claim, and 0x7D/0x7E prompt mix-up found no remaining current-reference instances.

Ran the complete noninteractive verification:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/inspect_symbol_map.py
tools/audit_bin_opcodes.py
bash -n run.sh tools/build_qemu_dos_trace.sh
mdbook build docs
test -f docs/book/index.html
git diff --check

All 128 tests passed in 5.421 seconds. Python compilation and shell syntax checks were silent and successful. The documentation checker reported 23 chapters plus README, the opcode audit reproduced its checked report, and mdBook wrote docs/book/index.html. This build exposed a stale command in the Reproducing Results chapter that tested build/docs-book/index.html; corrected both that command and its output-directory sentence to the actual configured docs/book/, then rebuilt and rechecked the book. The final whitespace check passed.

Before the requested commit, ran git status --short, git diff --check, git diff --stat, and git diff --name-only. The worktree contained the 20 intended modified files plus the four new audit report, audit tool, and test files; no unrelated changes or whitespace errors were present. The rendered book remains ignored and is not part of the commit.

Staged those 24 reviewed paths explicitly with git add, then ran git diff --cached --check, git status --short, and git diff --cached --stat. The staged snapshot contained 1,623 insertions and 163 deletions with no whitespace errors. Created the requested commit with the subject Audit all BIN opcode documentation; its body records why the audit uses the executable CFG, why the string and mixed-region corrections matter, and which regression boundaries were added.

2026-07-19: Clean-room engine specification

The user requested a second mdBook under spec/ that a developer with no prior knowledge of Captain Bible can use to build a compatible engine. The book must explain the game mechanics as well as the technical contracts, and must not depend on implementation details of the legacy DOS program.

Started with git status --short, rg --files docs/src, the research-book summary and heading inventory, and focused reads of the manual plus every current system chapter. Defined the specification boundary as observable game behavior and original data compatibility: resource names and byte layouts, scene opcodes, signed arithmetic, state transitions, save files, controls, and player-facing timing are in scope. DOS addresses, segmented pointers, compiler artifacts, disassembly workflow, QEMU captures, and names of functions in the legacy executable are evidence and therefore excluded from the new book.

Added Phase 9 to PLAN. The intended specification structure begins with story, objective, controls, difficulty, and the normal play loop, then progresses through engine lifecycle, resources, rendering, sound, text, all 145 scene opcodes, scene scheduling and animation, maps, conversations, combat, progression, saves, configuration, and conformance. Unknown producer-side fields and behavior not exercised by shipped data will be identified as compatibility boundaries rather than filled with invented requirements.

Created spec/book.toml, spec/theme/wide.css, and a 19-chapter source tree under spec/src. The book begins with a zero-prior-knowledge explanation of the story, objective, difficulty modes, hall actions, study interactions, manual and automatic combat, room types, Cyber vulnerabilities, controls, and the complete seven-building and Unibot progression. The technical chapters then specify engine lifecycle, DD1.DAT and its compression, every resource format, text joining, all 145 scene opcodes, cooperative scheduling, display objects, animation, map cells, dialogue, combat, state, saves, launch options, text export, conformance tests, and deliberately bounded behavior.

Kept the new book clean-room by expressing the VM in terms of resource-relative offsets, signed script words, logical records, and visible state transitions. It contains no addresses from the legacy program and no dependency on QEMU, Rizin, disassembly, compiler conventions, or segmented memory. Original file offsets, opcode values, resource names, map values, and save fields remain in scope because a new implementation must consume them.

During the cross-chapter reconciliation, corrected several easy-to-confuse contracts before treating the book as complete. Opcode 02 carries thread, X, Y, and scale rather than a delay. Conditional BHs callback operands use a nonnegative target with default thread -1 or a negative thread selector followed by an explicit target. Opcode 7D obtains its selector from a script variable. Opcode 91 takes divisor then destination and reads the separate 16-by-16 auxiliary cell table also used by opcode 8E, not byte zero of the three-byte MAP grid. Save restore copies checkpoint fields into live state rather than resuming the arbitrary serialized live dialogue position. The specification also records the exploration rows within variables 37 through 52 and the exact four-table CP2.BIN trailer.

Expanded tools/check_documentation.py to discover both docs/src and spec/src, checking each summary, all local links and anchors, and repository commands. Added an optional-second-book regression to tests/test_check_documentation.py. Added tests/test_specification.py to require the exact 19-chapter set, prohibit dependencies on reverse-engineering methods, and compare all 145 specification opcode names and schemas with the machine-readable decoder catalog. Updated README with the book purpose and both build commands, updated PLAN, and ignored generated spec/book/ output.

Ran the focused checks while drafting and reconciliation:

python3 -m unittest \
  tests.test_specification \
  tests.test_check_documentation \
  tests.test_opcode_documentation -v
tools/check_documentation.py
mdbook build spec
test -f spec/book/index.html
mdbook build docs
test -f docs/book/index.html
git diff --check

All 11 focused tests passed. The documentation checker reported 23 research chapters and 19 specification chapters plus README. Both books built their index pages and the whitespace check was silent.

After adding the fuller player-facing mechanics and final semantic fixes, ran the complete noninteractive verification:

python3 -m unittest discover -s tests -v
python3 -m py_compile tools/*.py tests/*.py
tools/check_documentation.py
tools/audit_bin_opcodes.py
mdbook build spec
test -f spec/book/index.html
mdbook build docs
test -f docs/book/index.html
git diff --check

All 132 tests passed in 5.570 seconds. Python compilation was silent. The opcode audit again reported 145 opcodes, 134 distinct handlers, 122 used values, and 25,829 shipped commands. Both mdBooks built successfully and the final whitespace check passed. Generated books remain ignored.

The detailed reconciliation also used sed and rg over scene-bytecode.md, world-maps.md, game-state.md, conversation-flow.md, combat-runtime.md, endgame.md, save-formats.md, static-analysis.md, and MANUAL.TXT. A narrow Rizin pd check of the conditional callback handlers confirmed that a negative BHs word is negated into the explicit thread slot before the extra word becomes the target. The first broader pdf form printed the containing interpreter and was truncated, so repeated it with bounded pd ranges. A read-only Python comparison found four shortened conditional-branch mnemonics in the draft; changed them to the canonical decoder names and extended the regression to compare names as well as schemas.

Two large patch applications initially failed cleanly because their expected context crossed a wrapped line in bytecode.md and then the end of this log. No partial changes were made. Reapplied each as smaller exact-context patches. An early repository-wide rg also entered generated docs/book search data and produced truncated output; later searches excluded generated books.

After updating PLAN, README, this log, and .gitignore, ran the final bounded documentation check:

python3 -m unittest \
  tests.test_specification \
  tests.test_check_documentation -v
tools/check_documentation.py
mdbook build spec
test -f spec/book/index.html
mdbook build docs
test -f docs/book/index.html
git diff --check
git status --short

All seven tests passed, the checker again reported 23 plus 19 chapters, both index files were rebuilt, and the whitespace check passed. git status showed only this task’s specification, tracking, checker, and test changes. A final git status --untracked-files=all spec confirmed that generated spec/book/ files are ignored and only book.toml, theme/wide.css, and src/*.md are untracked specification inputs.

The user then requested a commit. Ran git status --short --untracked-files=all, git diff --check, git diff --stat, and a focused status of spec/ plus tests/test_specification.py. The worktree contained the six intended modified tracking/checker files and 23 intended new inputs: the specification configuration, stylesheet, summary, 19 chapters, and its regression test. Generated spec/book/ output was absent from the untracked list. The whitespace check passed and no unrelated path was present.

Prepared one commit covering the complete clean-room specification and its validation support. The commit message explains why the separate book exists, why portable behavior and original data contracts are documented instead of legacy implementation structure, and why both books now share integrity checks. The immediately preceding validation remains 132 complete tests, a successful 145-opcode audit, two successful mdBook builds, and the final seven focused documentation/specification tests.

Staged the 29 intended paths explicitly. The first git diff --cached --check reported trailing blank lines at the ends of configuration.md and conformance.md. Removed those two blank lines with no content change, then restaged the corrected files and this log before repeating the cached checks.

The repeated cached whitespace check passed. The staged summary contained the expected 29 files, 2,466 insertions, and 16 deletions. The first git commit attempt could not create .git/index.lock under the restricted filesystem and made no commit. Retried the same reviewed, 72-column-wrapped message with Git repository write approval and created the requested commit, then amended that same commit only to include this final failure/retry record in the progress log.

2026-07-24: Interactive selector and verse fixes

Compared the supplied DOS and Rust recordings of the first interactive hall sequence frame by frame. The DOS recording showed transient yellow navigation arrows near active exits, one Captain Bible sprite during Scripture-station collection, and a Verse loaded dialogue containing both the citation and the complete verse. The Rust recording had no navigation hints, retained its ordinary actor underneath the collection pose, and emitted only the citation.

Traced the pointer behavior through update_action_selector_overlay at 0x6A23. It scans enabled scene-thread selectors before opcode-3A action records. The distance helper at 0x3315 computes the larger axis difference plus half the smaller difference. Candidates above 0x46 are ignored and strict less-than replacement preserves the first target on a tie. Direction selectors map to one-based STUFF.ART frames 8 through 11 and use transient render slot 0x97. Listing and rendering STUFF.ART independently confirmed the zero-based Up, Down, Left, and Right frames at indexes 7 through 10 and their signed anchor descriptors.

Decoded the Scripture-station path in CHAL.BIN at 0x0B9B. Animation 5 uses one-based frame 2 of SSTN.ART, which is a complete replacement pose of Captain Bible operating the station, while animation 7 supplies the changing station effects. The script writes motion state zero before its delay and restores state two before stopping those animations. The DOS scene-motion updater at 0x7469 releases the scene thread’s normal render slot when that state is zero. The Rust interpreter had retained the state byte but its actor render path did not consult it, causing both the ordinary RUN.ART actor and the replacement pose to remain visible.

Followed the dialogue preprocessing path at 0x224F. The literal CHAL script is Verse loaded: &; ampersand switches the formatter to the selected text record’s complete citation|verse index string. While copying that record, the DOS code replaces its internal vertical bar with -. The former Rust implementation instead replaced ampersand with only the citation and treated a vertical bar in the script template as a separate verse placeholder.

Updated the clean-room input, scene-runtime, bytecode, and text chapters with the exact proximity rule, arrow resources, motion-state render suppression, and selected-record substitution. Updated the research chapters covering graphics, selectable actions, bytecode, conversations, and world-map verse collection with the supporting asset, script, and executable evidence.

Implemented the three behaviors in the Rust engine and SDL frontend. Pointer hit testing now uses the recovered integer metric and 70-pixel radius, retains scene-thread priority, and refuses out-of-range clicks. The frontend extracts the four original arrow frames and draws the appropriate one at the selected target’s authored coordinate, except while a modal UI is active. Actor display sources now disappear while their matching scene thread has motion state zero. Dialogue ampersands now expand to citation - verse, and a literal template vertical bar is no longer mistaken for the verse placeholder.

Added focused regressions for the DOS distance calculation, FIRST’s right-exit hover and cutoff, motion-state actor suppression, full selected-record expansion, original arrow descriptors, and signed-anchor arrow drawing. Ran:

cd rust-engine
cargo fmt
cargo test
cargo clippy --all-targets -- -D warnings
cd ..
python3 -m unittest discover -s tests -v
python3 -B tools/check_documentation.py
python3 -B tools/audit_bin_opcodes.py
mdbook build docs
mdbook build spec
git diff --check

All 66 Rust tests passed, including the shipped-data tests. Clippy completed without warnings. All 132 repository tests passed, the documentation checker reported 23 research and 19 specification chapters, and the opcode audit reported all 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands. Both books built successfully and the whitespace check was clean.

2026-07-24: Combat interaction and animation fixes

Reproduced COMBAT1 from the preserved formal-capture save with positive faith. The Rust engine rendered the encounter and exposed .11, .12, and .13, but hovering .11 produced no label, A/D/R/C selected nothing, and selecting .11 directly left the same actions visible until the primary thread’s old negative idle delay counted back to zero.

Traced the overlay’s numeric-selector branch from 0x6B94 through 0x6CCE. It parses .XY as loaded ART slot X plus one-based frame Y and submits that frame at the target anchor. All combat scripts load COMBTAGS.ART into slot 1, so .11 through .14 select the authored ATTACK, DEFEND, RETREAT, and COMBAT frames. Confirmed the first descriptor independently as origin (-14,-3) and size 28 by 7. The Rust overlay had recognized only .u, .d, .l, and .r.

Traced the selector matcher at 0x8558 and keyboard translation at 0x866B. Ordinary selectors compare the character after the dot, while .1N compares N. The switch table at 0x86E8 maps A and S to 1, D to 2, R to 3, G to v, and U to x. C first tries .c and falls back to 4. The Rust implementation had instead searched for the first alphabetic character, which numeric combat selectors do not contain.

Disassembled start_scene_thread at 0x7A5C. It activates the slot, replaces its cursor, primes its timer from the negated current delta, clears its status, and immediately updates that slot. Opcode 3E uses the same path. The former Rust path retained stale delays and return stacks and deferred named slots to ordinary scan order, making combat choices appear unresponsive and changing script command ordering.

Finally, followed animation start and update routines 0x3F59 and 0x3DA8. Mode 8 is not among the reverse modes that start at the final record. A mode-7 overrun retains the final record and changes to mode 8; a mode-8 underrun retains the first record and changes to mode 7. Rust started mode 8 at the end and skipped both turnaround endpoints, visibly disrupting ping-pong combat sequences.

Implemented data-driven numeric selector artwork, the recovered keyboard table, scene-thread-first selector priority, fresh action/callback dispatch, synchronous opcode-3E scheduling, and exact modes 7/8. A live COMBAT1 probe then displayed the slot-1 frame-1 overlay, accepted A immediately, disabled the choices during the attack, briefly enabled Defend at the scripted phase, and restored the manual action set afterward.

Updated the combat, input, scene-runtime, and bytecode specification chapters with the recovered contracts. Updated the combat-runtime and bytecode research chapters with the disassembly evidence and removed animation transitions from the known-gaps list.

Ran cargo fmt, all 70 Rust tests, and Clippy across every target with warnings denied. Ran all 132 repository tests, the documentation consistency checker, the complete opcode audit, both mdBook builds, and git diff --check. All passed; the checker reported 23 research and 19 specification chapters, while the audit reported 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands.

2026-07-24: Computer Bible encounter prompts

Traced the missing pre-combat lie from opcode 7D through the original study browser. The Rust interpreter retained the expected selector and prompt component, but its frontend discarded both values when it opened the Computer Bible and passed only the acquired verse list to the modal.

Disassembled render_study_prompt at load offset 0x446F. Prompt values zero and nine suppress text, 2A selects the expected descriptor’s * component, 64 selects P, and every other value selects L. The routine looks up the expected descriptor directly rather than filtering by acquired state. It wraps the prompt at logical (6,22) to 309 pixels and at most three lines, with | forcing a line break. The three component cases use text styles 10, 9, and 7 respectively.

Recovered the final two entries of the executable’s font-style table, which contains ten triplets rather than the previously documented eight. Style 9 maps font source indexes through (15,32,36) and style 10 maps them through (15,1,8).

The engine now resolves the configured prompt when opcode 49 emits its study request. The SDL modal retains and draws it above BOOK.ART using the original geometry, wrapping, line limit, and component style. Headless mode prints the same resolved text. Added regressions for direct lookup of an unacquired expected descriptor, prompt value nine, forced line breaks, the three-line limit, output coordinates, and all three styles.

Ran cargo fmt, all 74 Rust tests, and Clippy across every target with warnings denied. Ran all 132 repository tests, the documentation consistency checker, the complete opcode audit, both mdBook builds, and git diff --check. All passed; the checker reported 23 research and 19 specification chapters, while the audit reported 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands.

2026-07-24: Autonomous compatibility playthrough

The user asked for an autonomous DOS/Rust comparison pass through the remaining game. Began from commit 17fc464, preserved the unrelated untracked run-audio.sh and run-compiled-qemu.sh files, and added a bounded Rust compatibility-probe example. The helper starts from an original save, promotes its live state to the checkpoint, can override scene variables and flags, drives authored action labels, confirms dialogue, chooses responses, applies the expected study record, reports all runtime events, and optionally writes a PPM. It is analysis support rather than a player-facing launch mode.

Ran entry smoke probes across the shipped scene resources, followed by natural action routes through CP1, GANTRY, the CP2 road graph, a pylon encounter, all seven combat resources, the Tower gate, every FACE/CP3 state, KABLAM, WIN, and OVER. The CP2 route exercised the one-time Annoy sequence, a pylon study answer, pylon destruction, crew release, and the final Tower transition. The combat probes exercised retreat in every resource and repeated manual attacks through representative wins and defeat. Endgame probes verified FACE states 0, 1, 2, and 9, CP3’s correct selector 0x20, the CP3-to-KABLAM transition, and both terminal scenes.

CP1 inline records and entry dispatch

The first new interpreter failure was CP1.BIN:0x0026, where six opcode-07 records occur before the first opcode-06 animation definition. The Rust engine treated 07 as a host-side append operation and required an existing animation. Rechecking the DOS handler showed that execution merely advances the BIN cursor by nine bytes. Animation state retains a stream position and consumes its owned records later, so unowned inline records are valid.

After accepting those records, CP1 displayed its introduction twice. The first entry-dispatch correction made opcode 05 stop the offset-zero loader instead of falling through into the arrival handler before movement completed. That removed the duplicate introduction, but entry movement still started synchronously inside the loader’s opcode-05 handler. A departure callback could therefore reactivate thread zero only for the same handler to deactivate it immediately afterward. The later FIRST beamer investigation exposed that remaining ordering error. Added regressions for the orphan records and single CP1 introduction; the stronger departure-callback coverage is recorded below.

Status controls and Game Options

The exploratory pass also checked controls that are not reachable through scene action labels. Disassembled the status dispatcher at 0x83B6. Its F3 path divides variable 21 by 100 and patches two integer digits into Your faith is at 00%., with a separate 100-percent string. It displays the ordinary dialogue panel at (24,28) with width 150 and retains only the selected status icon. Recovered all five exact power descriptions from the executable. Rust had displayed a fractional uppercase faith notice and invented power labels in a generic centered box. The frontend now uses the original strings, integer formatting, geometry, and icon-only presentation.

Escape did not open any Game Options interface in Rust. Recovered the complete dispatcher at 0x2F36, the save selector at 0x2B6F, and confirmations at 0x2EEC. Implemented the exact row order and text-menu geometry, disabled and omitted Automatic Combat cases, translation cycling and bank reload, music and effects state, normal save/load selectors, New Game and Quit confirmations, restart, and keyboard/mouse selection. The options and name editor pause ordinary scene execution and suppress the gameplay status row.

Unlabeled gantry navigation

A GANTRY probe settled normally but exposed no labeled actions, matching the reported apparently locked scene. Static listing showed nodes 2 and 3 at (190,70), no opcode-10 strings, entry seg ending at node 2, and node 3’s arrival callback entering CP1. The DOS main input path around 0x87D3..0x8842 still includes unlabeled opcode-02 scene-thread records in pointer hit-testing. Dispatcher 0x851E writes the selected node index to the movement controller rather than jumping to a script action.

Rust had incorrectly used the hover-label candidate list for clicks, excluding all empty selectors. Pointer selection now considers enabled navigation nodes first, uses opcode-02 geometry when there is no selector anchor, excludes the current unlabeled node, preserves table order, and then considers opcode-3A actions. Empty labels remain absent from keyboard matching, cycling, and hover artwork. A GANTRY regression clicks (190,70), selects node 3 rather than coincident current node 2, and reaches CP1.

Save-name editor

The newly reachable normal-save path revealed another approximation. Rust accepted only lowercase letters, digits, and space, let any mouse click commit the name, and could save a blank label. Disassembled the blocking editor at 0x2C8B, its caller at 0x2DF7, and initialize_empty_save_slot at 0x815A. The editor accepts at most 26 bytes from ASCII ranges 20..3B, 3F..5A, and 61..7A, excluding & and *; it handles Backspace, Enter, and Escape and has no mouse-confirm path. The caller clears (EMPTY) before editing. After Enter, an empty or (EMPTY) result becomes Game 1 through Game 9.

Implemented the exact filter with Shift and Caps Lock handling, keyboard-only completion, pre-edit clearing, and post-edit default. The supplied literal EMPTY remains editable as authored, while the executable’s (EMPTY) value starts blank. Added regressions for character ranges, excluded punctuation, case modifiers, the edit/default sequence, mouse nonactivation, and the 26-byte limit.

Updated the clean-room bytecode, scene-runtime, input/UI, and save contracts as each behavior was established. Updated the research chapters for scene objects, bytecode, state/options, and save editing, plus the Rust README and plan date. The specification now explicitly distinguishes an unlabeled pointer-capable navigation node from a labeled hover/keyboard selector.

Validation used:

cd rust-engine
cargo fmt --all
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cd ..
python3 -m unittest discover -s tests -v
python3 -B tools/check_documentation.py
python3 -B tools/audit_bin_opcodes.py
mdbook build docs
mdbook build spec
git diff --check

All 83 Rust target tests passed and Clippy completed with warnings denied. All 132 repository tests passed. The documentation checker reported 23 research and 19 specification chapters, and the opcode audit again reported all 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands. Both books built successfully.

Second autonomous compatibility pass

Audited the post-gameplay-fix engine for discrepancies outside the previously exercised scene routes. Three concrete gaps appeared in random behavior, retained restore, and normal-save presentation.

The Rust engine seeded a fixed 64-bit xorshift value at startup and again on New Game. Opcode 82 and the X answer shuffle consumed that stream, while unused opcode 8B used a second unrelated 64-bit LCG. This made ordinary randomized branches repeat on every launch and could not reproduce DOS call ordering. Disassembly of startup 0x3363, clock helper 0x02CF, seed routine 0xE954, and random routine 0xE966 recovered the complete behavior. DOS seeds with local seconds since midnight masked by 0x7FFF, advances one shared 32-bit state with 0x000343FD * state + 0x00269EC3, and returns the upper 15 bits. New Game does not reseed it.

Rechecked every random call site. Opcode 82 stores the returned value modulo its immediate. The text X shuffle consumes two values for each of 20 swaps. Opcode 8B starts at rand() % descriptor_count, scans cyclically past unavailable descriptors, clears the first available record, and writes 3,000 to variable byte offset 0x5C. The Rust runtime now shares the recovered DOS generator across all three paths. Normal play starts from the current clock; the compatibility probe accepts CB_PROBE_SEED to inject a deterministic initial state without changing the generator.

Opcode 67 previously emitted RestoreRequested, but the SDL frontend ignored that event and left the issuing scene suspended. The DOS main loop does not open a disk-load dialog there: mode 2 copies the already retained checkpoint buffers back to live state and reloads the checkpoint scene and text bank. Rust now performs that retained restore in the engine and schedules the checkpoint scene directly.

Finally, choose_save_slot at 0x2B6F compares each numbered row with the mutable save filename suffix. If the matching state file exists, it copies the label to a temporary buffer and appends <<. Rust now tracks successful normal save/load selection and applies that marker only in the load selector. Quick operations clear the numbered marker, matching the DOS F9/F10 path that temporarily uses suffix Q and restores suffix 0.

Added generator-sequence, retained-restore, and active-slot label regressions. Deterministic automatic-COMBAT1 probes with injected seeds completed without runtime errors, and a direct OVER probe confirmed that a checkpoint patched to OVER loops through retained restoration rather than hanging on an ignored host event. Updated the clean-room compatibility, lifecycle, bytecode, text, combat, UI, and boundaries chapters, plus the static-analysis, bytecode, and state/UI research notes.

Final verification passed all 86 Rust tests and Clippy with warnings denied, all 132 repository tests, the documentation checker, and the complete opcode audit. The audit still reports 145 opcode values, 134 distinct handlers, 122 values used by shipped scripts, and 25,829 decoded shipped commands. Both mdBooks rebuilt successfully and git diff --check was clean.

Third autonomous compatibility pass

This pass extended the comparison beyond the previously exercised visual, conversation, map, and combat paths. It concentrated on audio-driver lifecycle, restart and input state, the two unusual random-byte commands, and bounded execution of every shipped scene.

Digital-effect lifecycle

Revisited the DIGPAK call path rather than treating opcode 57 as immediate playback. The loader at 0x417F stops and releases the preceding sample, decodes the selected ABT resource, constructs the playback state, and invokes the driver’s preformat service. It does not start playback. The routine at 0x4235, used by opcode 58, stops an active instance, resets completion state, and starts the retained preformatted sample. Opcode 59 waits for that instance only when a usable effects backend is present; the no-driver path advances through its authored timer fallback.

Rust now models prepare, start/restart, completion, stop, replacement, and scene-bound sample release separately. SDL receives the original unsigned eight-bit PCM at the script-supplied rate. A reference-timer countdown keeps the VM wait aligned with sample duration, while an unavailable or failed SDL effects stream switches the engine to the silent DOS fallback. Regressions cover first prepare, delayed start, replacement, explicit zero release, completion, scene transition, and the HOLE1 no-driver path which had previously exhausted the instruction budget.

Music lifecycle and native playback

Disassembled play_music_resource and the Game Options toggle path. A request for the already current nonzero identifier returns without restarting it. Identifier zero stops playback. Turning music off stops the registered sequence; turning it back on restarts the retained current sequence when one exists. Disabled scene requests neither play nor replace that retained identifier.

Added explicit music-stop events and reproduced this lifecycle in the engine. The SDL frontend now uses a second audio stream independent of digital effects. It parses the first XMIDI EVNT sequence at the format’s 120 Hz timebase, applies program, bank, volume, note velocity, duration, and the installed SOUND.4 AIL timbres, and renders signed 16-bit mono PCM. The two-operator synthesizer uses timbre transpose or percussion pitch, operator frequency multipliers and total levels, envelope fields, waveforms, and connection mode. It is deliberately a portable FM approximation rather than a cycle-level YM3812 emulator. Rendered tracks are cached and queued continuously until stopped or replaced.

Extended inspect_xmi.py with exact controller and channel-status inventories. The 16 MUS resources contain no pitch-bend or embedded loop events. Their controller traffic consists of bank, volume, pan, and MUS016’s RPN/NRPN setup; the latter has no pitch bend for those settings to modify. A corpus test renders all 16 resources through all required melodic and percussion timbres and rejects silence or missing patches.

Restart, pointer, and scheduler fidelity

The F9 missing-file path was still treated as an ordinary I/O error. Disassembly showed that the DOS reader returns mode 1 and the top-level dispatcher starts a new session. Rust now does the same while retaining translation, music, effects, and Automatic Combat options. A malformed existing quick save remains an error.

Scene construction had also reset the logical pointer to (0,0). The DOS mouse subsystem owns that position independently from scene records, so both scene changes and New Game now retain it. Opcode 72 was corrected from a permanent suspension to the DOS cooperative-yield behavior: it writes -1 to the scheduler delay and resumes at the following command on a later controller update.

Auxiliary randomness boundary

Instruction-boundary QEMU probes and executable disassembly explained the historical source used by opcodes 8E and 91. The DOS handlers calculate a 16-by-16 coordinate index into an initialized object that is only 80 bytes long; lower rows consequently read later data-segment objects and allocator-dependent segment words. This remains documented as reverse-engineering evidence, but it is not part of the clean-room behavioral contract.

The specification now requires only that each execution obtain a random byte in 0..255; algorithm, seed, and state are implementation-defined. Rust uses a conventional time-seeded xorshift64* generator dedicated to these two commands. It does not serialize a fabricated coordinate table and cannot perturb the separately recovered Microsoft-compatible generator and call order used by opcode 82, the text shuffle, and unused opcode 8B.

This boundary was later superseded by the portable shared-generator work in the 2026-07-30 entry. The paragraph above records the conclusion at the time of this pass, not the current contract.

Corpus execution and tooling

Added optional coordinate patching to the save-state probe and configurable instruction-boundary memory captures to the QEMU tracer. A bounded compatibility run forced all 62 shipped BIN scenes through initialization and 2,000 controller updates. Fifty-nine ran directly from the supplied base state. The remaining three were invalid forced-state combinations rather than engine failures:

  • ROOM1 and ROOM2 need variable 16 to contain a valid map-level letter before their authored CHAL string patch; setting it to C produced the expected transition.
  • ROOM3’s random face-selection loop assumes at least one of flags 42..48 is set; setting its first valid face flag let the scene run through the full bound.

With those authored preconditions, all 62 scenes completed the smoke pass. The SDL frontend also opened and ran under dummy video and audio drivers without an initialization or stream error.

Updated the clean-room audio, bytecode, input/UI, save, boundary, and conformance chapters as behavior was established. The research books retain the DOS-specific handler names and memory evidence, while the specification uses portable random-behavior names for opcodes 8E and 91.

Validation used:

cargo fmt --manifest-path rust-engine/Cargo.toml --all
cargo test --manifest-path rust-engine/Cargo.toml --all-targets
cargo clippy --manifest-path rust-engine/Cargo.toml --all-targets -- -D warnings
python3 -m unittest discover -s tests
python3 -B tools/check_documentation.py
python3 -B tools/audit_bin_opcodes.py
mdbook build docs
mdbook build spec
git diff --check

All 99 Rust target tests passed and Clippy completed with warnings denied. All 135 repository tests passed. The documentation checker reported 23 research and 19 specification chapters, and the opcode audit again reported all 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands. Both books built successfully and the final diff check was clean.

2026-07-24: Selector cycling and modal input

A further input audit found that SDL ignored Space and treated Enter as only a script-confirmation event. The specification already described keyboard selector cycling, but it did not preserve the recovered table scan, pointer movement, or the action-selection gate closely enough to expose the missing implementation.

Disassembled the main input loop at 0x875D. With global action selection disabled at DS:004C, Enter and primary-button event 0x7E set the opcode-64 latch at DS:7CC0. With action selection enabled, the same events query the current selector, require its index at DS:00EE to be nonnegative, and dispatch it through 0x851E. No selector means no action; the event does not fall back to the script latch.

Traced Space into 0x6939. The routine updates the current hover, then scans enabled 16-byte scene-thread records before active 10-byte opcode-3A records, wrapping between tables. It accepts only records with signed coordinates X below 320 and Y below 200. A successful record writes X and Y+4 to all logical mouse-coordinate copies and refreshes the hover. The 100-iteration guard bounds malformed table state.

Rechecked opcode-02 initialization while following that scan. Its separate selector X/Y start at 30000 and its string pointer at -1; opcode 10 replaces those fields. Thus a navigation-only GANTRY node remains clickable through its navigation geometry but cannot accidentally enter Space cycling. The shipped scripts also contain intentional targets at Y 200 which the strict comparison excludes.

The surrounding arrow-key branches revealed a second missing input mode. With action selection enabled, BIOS events C8, D0, CB, and CD search selectors u, d, l, and r. With selection disabled, the branches at 0x889C, 0x88BD, 0x88F7, and 0x88D7 instead move the logical pointer by eight pixels. The former SDL path always searched for an action and therefore made those keyboard pointer controls inert.

The standalone Computer Bible supplied a related lifecycle check. show_study_bible at 0x1C88 saves action-selection state at 0x1D5F, clears it at 0x1D66, and remains in a nested input loop until the interface closes. Map, status notice, options, and save interfaces are likewise top-level blockers. Rust already paused for map, options, and name entry, but continued running scene commands behind the standalone Bible and Faith/power notices.

Added explicit cycle and directional input events to the engine. Space now uses the recovered table order, bounds, wrap, and Y+4 pointer anchor. Enter and mouse clicks honor the action-selection gate, arrows switch between selector dispatch and eight-pixel pointer movement, and the SDL frontend warps the host cursor when keyboard input changes the logical position. New scene-thread records now retain the DOS selector sentinel. Host-owned Bible and notice modals pause the scene controller, while opcode-49 study remains engine-owned so its suspended thread can receive Apply or Off normally.

Updated the clean-room input and scene-runtime contracts with the recovered ordering, coordinates, latch boundary, arrow modes, sentinel initialization, and modal ownership distinction. Added focused regressions for cycling order and wrap, excluded records, Enter/click gating, arrow pointer movement, directional action dispatch, and host-owned modal pausing.

Validation used the complete Rust target suite, Clippy with warnings denied, all repository tests, both mdBook builds, the documentation checker, opcode audit, whitespace check, a 30,000-update headless run, and an SDL launch with dummy video and audio drivers. All 103 Rust target tests and all 135 repository tests passed. The documentation checker reported 23 research and 19 specification chapters; the opcode audit reported all 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands. Both books built, Clippy was clean, the headless run completed, and the SDL frontend reached LOGO without an initialization or audio error.

2026-07-24: Top-level status and options input

Continued the input compatibility audit from the selector-control pass. Disassembly of the main loop at 0x875D, status dispatcher at 0x83B6, and status renderer at 0x641B exposed four related frontend discrepancies.

Escape reaches the options routine at 0x2F36 after the action-selection branches have rejoined. Rust had incorrectly required the gameplay status row to be visible and otherwise forwarded Escape as a scene cancellation event. The frontend now lets an active modal consume Escape first and opens Game Options unconditionally at the top-level scene loop.

Rust also treated toolbar visibility, keyboard availability, and positive Faith as one condition. DOS uses persistent flag 0x36 to draw the Computer Bible, Map, Faith, and power artwork, but word 004C independently gates F1 through F8. Pointer activation requires both. The status renderer clamps Faith for frame selection and still displays its empty frame at exactly zero; only negative Faith triggers the later game-over transition. The engine and frontend now preserve these distinct gates, including scenes that explicitly clear and restore flag 0x36.

The branch at 0x8845 additionally sends ASCII b to the Computer Bible routine whenever action selection is enabled. Rust had passed that key to ordinary selector matching. B now shares the F1 route.

Finally, the exact 13-word status switch table contains the eight ordinary controls, four no-op entries, and an options entry at index 12. The click dispatcher special-cases index 12 before both toolbar gates. This is the always-visible frame-11 disk indicator. Rust already drew that artwork but did not hit-test it; a press within its original bounds now opens the same options hierarchy as Escape.

Added regressions for modal-first Escape handling, options access without the status row, the distinct artwork and keyboard gates, zero-Faith visibility, the B/F1 mapping, and the disk artwork’s exact hit bounds. Updated the clean-room input and state contracts, executable state/UI notes, and Rust usage guide.

Validation passed all 107 Rust target tests and Clippy across every target with warnings denied, all 135 repository tests, the documentation consistency checker, complete opcode audit, both mdBook builds, git diff --check, a 30,000-unit headless run, and an SDL dummy-video/audio launch. The checker reported 23 research and 19 specification chapters; the audit reported all 145 opcodes, 134 handlers, 122 used values, and 25,829 shipped commands. The headless run stopped normally in dome; SDL reached LOGO and TITLE.

2026-07-24: Nested options cancellation

Continued from the top-level options audit into the blocking text-menu and save-name continuations. The common selector at 0x2556 returns -1 through the Escape path at 0x278E..0x28A3. Its callers do not all interpret that result as closing the whole options hierarchy.

The main options caller exits on -1. The save/load selector calls at 0x3257 and 0x3276 instead rebuild the main options menu, and the confirmation wrapper at 0x2EEC treats a negative selection as not confirmed. The save-name path is deliberately different: the editor at 0x2C8B rejects its temporary label on Escape, but 0x3261 still calls save_selected_slot with the preceding label. An empty slot consequently receives the normal Game N default and the options interface closes.

Rust had represented all option panels as the same modal. Escape therefore closed save/load selectors and confirmation screens completely, while name entry returned to the parent menu without saving. Added an explicit per-panel Escape continuation and retained the original label alongside the editable copy. The frontend now closes only the main panel, returns from child selectors and confirmations, and completes a selected save after a cancelled label edit using the preceding/default label.

The adjacent keyboard branch exposed another missing behavior. At 0x27AA..0x28E7, the shared selector lowercases an unmatched key, scans row strings from the beginning, and turns the first matching initial into the same activation event as Enter. This applies to conversation choices, options, save/load rows, and confirmations. It deliberately does not skip a disabled -2 target.

Added first-letter activation to both host-owned options and engine-owned choice modals. Duplicate initials select the first row, and selecting a disabled row follows the DOS rebuild continuation instead of falling through to a later duplicate.

The same loop handles Home (C7) and End (CF) separately from Up/Down. Those keys assign row zero or the final row without the arrow paths’ disabled row scan. Rust now exposes the same direct edge selection, including the disabled continuation if Enter follows an unavailable last row.

Focused frontend tests cover both menu continuations, label-editor Escape, first-match choice/options shortcuts, Home/End, and disabled-row handling, in addition to the existing 26-byte edit and default-label regressions.

The ordinary dialogue loop revealed one more nested continuation. Its Escape branch at 0x2AAA stores status event 12 and returns -1; poll_input_event at 0x7C3F converts that event back to Escape for the main dispatcher at 0x8904. The dialogue state is not accepted or cleared. The common choice selector uses the same path and likewise preserves its records and selection. DOS consequently opens Game Options over either modal and redraws the interrupted interface after Continue.

Rust had instead cancelled dialogue and ignored Escape in choices. The SDL UI now suspends either modal while the options hierarchy is active, retains it across child menus and save-label editing, discards it when loading or starting a different session, and restores it when options return normally. Focused regressions cover both the overlay and restoration lifecycle.

The same two DOS loops propagate BIOS key values BBh..C5h through status word 0052. poll_input_event reconstructs the original function-key code. The top-level loop gates F1..F8 on action-selection word 004C, while the F9 and F10 quick-load/save branches remain unconditional. Rust had incorrectly folded engine-modal state into the action-selection gate and suppressed every function key while text was present.

Separated that gate as well. Dialogue and choice modals now forward the global shortcuts. The Computer Bible, map, Faith, and available power notices suspend and restore the interrupted modal; their own Off, Escape, Enter, and click handling closes only the host-owned status interface rather than sending a cancellation into the underlying scene program. Quick Save leaves the text modal intact, and Quick Load continues through the normal scene replacement path, which clears the whole overlay stack.

The ordinary dialogue click path exposed another broad Rust hit target. show_dialogue_message creates one input record at DS:0248, anchored at the text midpoint and text Y plus five. Selector 0x6CE5 compares the pointer through distance helper 0x3315, accepts only a result at most 100, and the exit branch at 0x2ADD additionally requires the primary-button latch. DOS does not treat the whole screen or dialogue panel as a continue button.

Rust had accepted every primary click while a dialogue or Faith/power notice was visible. Added the authored anchor and exact integer proximity test, leaving out-of-range clicks unconsumed. Enter remains an unconditional keyboard confirmation, and the status-notice variant closes only its host-owned overlay. A focused regression covers both an ignored distant click and acceptance at the DOS anchor.

Separating host-owned overlays from scene cancellation exposed a same-frame frontend leak. SDL interpreted a None return from a closing UI as though no UI had handled the event. Enter on a status notice or save-name editor could therefore also confirm the restored scene, while an Off click on the Bible or map could hit a world target underneath it. DOS cannot do this because each interface’s blocking input routine consumes the event before returning.

The frontend now records whether a modal owned the event before dispatch. Closing that modal prevents both keyboard confirmation and pointer fallthrough for the remainder of the frame. A regression checks an empty-scene Enter against the same key used to close a status notice.

Keyboard repeat and extended corpus sweep

Continued the input audit below the main dispatcher. The original keyboard wrapper checks BIOS interrupt 16h service 01h, then consumes one queued word through service 00h. A held key therefore returns later BIOS typematic words through exactly the same dispatcher as its first press.

The SDL frontend had instead inferred every key solely from a comparison of the current keyboard-state array with the preceding frame. Holding a cursor key consequently moved only one choice, Bible row, map cell, action, or eight-pixel pointer step. It also prevented normal repeat in save-label editing. The frontend now admits SDL key-down repeat events alongside rising edges. Repeats retain all existing modal and global-command gates; mouse buttons remain transition based. A focused event-layout regression verifies that only repeated key-down events contribute the extra scancode.

Repeated the bounded compatibility probe across all 62 shipped BIN scenes for 6,000 driven controller updates. Fifty-nine scenes ran directly without a VM or resource error. As in the earlier 2,000-update sweep, the three forced invalid states were ROOM1/ROOM2 without an ASCII level letter and ROOM3 without any rescued-crew face flag. Supplying level C and flag 42 respectively produced the authored hall transitions; the longer ROOM3 run also completed its dialogue and choice sequence. HOLE1 remained stable through the longer bound.

All 118 Rust tests across library, binary, shipped-data, and example targets passed after the input change, and git diff --check remained clean.

Reviewing the directional branches then found a separate modal leak. Left and Right already required the absence of any SDL modal before constructing a scene-direction event, but Up and Down did not. A dialogue keeps the scene controller running for background animation, so either vertical key could reach the engine and select an active .u or .d exploration action behind the text.

Unified all four directions behind one unclaimed-input test. Choice, Bible, map, and options navigation retain their explicit routes; an ordinary dialogue now consumes otherwise unused directions. A regression checks every direction with and without a dialogue, and both focused frontend suites passed.

Common text-selector geometry

Continued from the dialogue click audit into the choice records constructed at 0x25E8..0x26AC. Each wrapped menu row receives a 16-byte input record. Offsets zero and two are not rectangle corners: they are a single proximity anchor at logical (text_x + 72,row_y + 2). Offsets eight and ten hold the independent SELECT sprite anchor. Its X is text_x - 11 when the text origin is at least 23, or text_x + text_width + 12 otherwise; Y remains row_y + 2.

Selector 0x6CE5 passes each row anchor through distance helper 0x3315, keeps a strictly closer result at distance 100 or less, and consequently breaks ties in source order. It chooses the nearest record before checking the marker byte at offset 15, so a nearest disabled row suppresses pointer selection instead of exposing an enabled row behind it. This is the same record loop used by dialogue choices, Game Options, save/load slot lists, and confirmation menus.

Rust had modeled choice and options rows as rectangular host controls. Choice rectangles were widened by 96 display pixels to include the right-hand caption, pointer motion outside them retained an old selection, and options did not draw the authored SELECT artwork at all. Replaced those hit tests with the recovered nearest-anchor calculation, allowed a pointer move to clear the highlight, positioned frame 28 from the two DOS left/right rules, and applied the shared behavior to the options hierarchy. Keyboard movement continues to clamp from row zero when no pointer row is active. Focused tests cover selection from outside the drawn panel but inside the DOS radius, rejection beyond radius 100, wrapped-row proximity, right-side choice art, and left-side options art.

The QEMU gameplay-options capture also shows no selected row while its stationary crosshair is far below the menu. The DOS blocking selector resolves the current pointer as soon as the menu opens; it does not wait for a later motion packet. Added a one-shot pointer refresh to newly opened or restored choice/options menus. This removes the Rust-only initial Continue highlight without making a stationary pointer overwrite subsequent keyboard selection on every frame.

The subsequent SDL scan exposed a frontend-only break in the already recovered combat selector table. Engine matching correctly accepts numeric keys 1 through 4 for .11 through .14, but the window loop forwarded only scancodes A through Z outside save-label editing. Direct numeric combat selection, and any nonalphabetic first-character menu accelerator, therefore never reached the engine.

Refactored the existing DOS/US printable scancode conversion so gameplay and the save editor share it while retaining the editor’s narrower accepted-byte filter. SDL now forwards number-row and punctuation keys to ordinary selectors and common text menus, continues to reserve Space for selector cycling, and preserves Shift/Caps behavior. Added frontend conversion checks and direct engine assertions for all four numeric combat choices.

The preserved QEMU save-name-entry capture then exposed a rendering discrepancy in the same options path. DOS keeps the complete ten-row save panel on screen: it replaces row zero with Enter New Name, retains all nine slot labels, and redraws only the chosen slot as text is edited. Rust had opened a fresh panel containing only the prompt and edited label, leaving the other eight slot rows blank.

The static path at 0x2DF7 confirms the capture. It builds the prompt plus nine-label table, asks select_from_text_menu to render it with a fixed selected index, releases the SELECT slots, and invokes editor 0x2C8B with the chosen row’s render slot and coordinates. The SDL name-entry state now retains the full index and renders all nine labels, with only the edited slot using style 2. A pixel-level regression checks an early unchanged row, the selected row, and the final slot.

Finally revisited the 19-pixel cursor rectangle already isolated by the startup VGA/resource comparison. Subtracting INTRO.ART from the captured framebuffer gives the complete mask at center (160,100): sixteen white index-1 pixels form four separated arms from offsets 3 through 6, and three index-15 pixels form the upper-right shadow. Rust had left SDL’s platform cursor visible and did not place a cursor in the game framebuffer.

The SDL adapter now hides the platform cursor and composites the recovered logical mask after scene, status, and modal UI layers. It uses the current host pointer even while a host-owned modal pauses the scene controller, so menus retain live mouse feedback. The renderer clips the mask at viewport edges, and a focused regression checks its captured colors, offsets, center gap, and exact 16/3 pixel counts.

Function keys inside Game Options

Followed the shared text selector’s BBh..C5h branch through every Game Options caller. The branch at 0x2758 records the function-key offset and returns -1, but unlike dialogue and choice handling, the Options stack consumes that negative result before poll_input_event can reconstruct the global key. The main list exits through case -1 at 0x3267. A save/load slot selector returns nonzero to 0x325D or 0x327C, rebuilding the parent list, while the confirmation wrapper converts the negative selection to false at 0x2F2B. The separate save-name editor simply rejects the key.

Rust previously suppressed F1 through F10 while any Options panel was open, leaving the screen unchanged. The SDL loop now consumes those keys at the owning selector level: the main list closes and restores a suspended text modal, nested lists return to the main list, and the key cannot fall through to a status screen or quick-save/load action in the same frame. Focused frontend regressions cover both nesting levels and the non-Options fall-through guard.

The separate save-name loop exposed two more concrete differences. At 0x2D5E DOS treats both Backspace (08h) and Left (CBh) as deletion, while Rust had accepted Backspace alone. The timer branch at 0x2CA5..0x2CD6 also toggles a literal > after each 700 accumulated reference units and redraws the selected row; Rust had no editing cursor. SDL now routes both keys to deletion and advances an independent modal cursor timer while the scene controller is paused. Character insertion and deletion hide the cursor as the DOS buffer terminator writes do. Regressions cover the scancode pair, the 699/700-unit boundary, repeated toggles, and edit-time hiding.

The modal shortcut comparison then found that Rust had overgeneralized the Computer Bible’s printable B alias. DOS dialogue propagates only BIOS function-key words; B remains inside the dialogue loop, and the common choice selector can consume it as a first-letter response accelerator. The top-level Bible comparison at 0x884C is never reached in either case. Rust had opened the Bible before giving the active modal that key. The frontend now permits B only without a modal while retaining F1 through F10 propagation from dialogue and choices. A regression contrasts B and F1 ownership with a dialogue active.

The underlying top-level comparison is specifically against ASCII 62h, not the physical B key. SDL had still ignored that distinction and opened the Bible for Shift+B and Caps-Lock B. Shortcut routing now derives the printable character with the same Shift/Caps XOR used by other input: lowercase b opens the Bible, either modifier alone produces uppercase and does not, and both modifiers together produce lowercase again. The modifier combinations are covered alongside the modal-ownership regression.

Rechecked the one-shot pointer refresh against the SDL frame order. Menus opened by a key were refreshed before the existing pointer phase, but menus created later in the frame—by the disk click, an options command, or a scene choice event—could still present once with row zero highlighted. Added a second pending-only refresh after command and engine-event dispatch. Newly created text selectors now resolve the stationary pointer before their first visible frame, as the blocking DOS selector does.

Modified keys and the Ctrl+V verse jump

Extended the key comparison through the BIOS conversion routine at 0x0010. Shift-, Ctrl-, and Alt-modified function keys have different extended codes from the plain BBh..C4h range, while Ctrl/Alt letter input is not the printable byte used by action and menu accelerators. SDL had routed physical scancodes without those distinctions, so modified F-keys could open status screens or abort Options, and Ctrl/Alt letters could activate ordinary commands.

The study browser contains one intentional control-key exception. At 0x1E2C it recognizes ASCII 16h (Ctrl+V), finds the encounter’s expected selector, verifies the verse is acquired, and makes it the current record without applying it. Rust’s study UI previously discarded the expected selector after deciding whether Apply was enabled.

SDL now suppresses Ctrl/Alt printable and navigation routing, requires plain F1 through F10 for status, quick, and Options behavior, and implements Ctrl+V as the recovered expected-verse jump. The study modal retains the selector, finds it only within the acquired records, and changes to its 14-row page. Focused tests cover modified shortcut rejection and both the present and missing expected-verse cases.

Function keys in status descriptions

Continued the modal-ownership trace through the Faith and power descriptions. These screens use the ordinary dialogue reader, so its 0x2A89 function-key branch still records a plain F1 through F10 and returns negative. Unlike an ordinary scene dialogue, however, status wrapper 0x2AFD and dispatcher 0x83B6 ignore that return. The description closes, but the function key cannot reach the main dispatcher to open another status screen or perform a quick save/load.

Rust previously suppressed all function keys while a status notice was open, leaving the notice visible. The SDL frontend now lets a plain F1 through F10 close the owning notice, restores any interrupted dialogue or choice, and consumes the key for the rest of the frame. Modified function keys remain unmatched. A focused regression covers the close-and-restore lifecycle.

The same BIOS conversion exposed physical-key overmatching for Enter and Escape. Converter 0x0010 returns nonzero ASCII directly and maps only a zero-ASCII BIOS word into the extended range. Shift+Enter therefore remains 0Dh, Ctrl+Enter becomes 0Ah, and Alt+Enter is extended. Alt+Escape is also extended, while plain, Shift, and Ctrl Escape remain 1Bh. SDL now confirms only the two DOS Enter forms and excludes Alt+Escape from the Escape path. A modifier-matrix regression preserves those distinctions.

Finally, routing SDL physical scancodes had omitted the numeric keypad even though DOS receives already translated BIOS words. Added keypad Enter, navigation-mode arrows/Home/End/Page Up/Page Down, numeric-mode digits and decimal point, and the four operator keys. Shift reverses effective Num Lock as on the BIOS path. The save editor also accepts navigation-mode keypad Left as deletion. Conversion and editor-filter regressions cover both keypad modes and the rejected * label character.

Scene-bound music lifecycle

Continued from input ownership into the outer scene loop. At 0x877D, main_menu_and_game_loop tests helper 0x8740; a pending scene name or state flag 40 returns from the inner loop. game_main then unconditionally calls play_music_resource(0) at 0x8C35 before it dispatches New Game, retained restore, or the next scene initialization. With music enabled, the call stops MIDPAK and changes the current identifier to zero.

Rust released scene-owned digital effects at that boundary but retained current_music. This allowed an old track to bleed into the successor and made a successor request for the same identifier disappear as an already-current no-op. Scene entry and New Game now reproduce the outer music-zero call before releasing effects and announcing the new scene.

The disassembly also ruled out an unconditional clear. play_music_resource checks the music enable word before storing a new identifier. While muted, the boundary call is suppressed and the preceding registration remains available to the options-menu restart path. Rust therefore stops and clears only enabled music at a boundary. A regression covers both enabled clearing with event ordering and disabled retention.

The neighboring resource-family branch exposed a host-only hard coding. Startup sets word 0x76E8 to zero and changes it to one only if installed driver SOUND.2 contains ASCII IBM at offsets 0xD2..0xD4. Music loading then selects MUS###.XMI or IBM###.XMI from that word. The supplied Creative FM driver contains 00 20 02, explaining the observed MUS choice, but Rust had used MUS for every installation.

Engine startup now derives the family from the same driver bytes and the SDL frontend asks the engine for the selected resource rather than formatting a MUS name itself. A bounded marker regression includes the positive, negative, and truncated-driver cases.

The same installation path exposed a launch-parser mismatch. DOS appends a separator to -iDIR and uses it for SOUND.1 through SOUND.5; archive, companion text, and player/save paths are independent. Rust had overwritten its general data directory, redirecting DD1.DAT, DDL*, and relative saves along with the sound assets.

LaunchConfig now carries separate game-data and installation directories. Modern --data supplies both defaults, while original -i changes only the installation directory. Policy loading, driver-family detection, and OPL timbres use that directory; archive, text, and relative save behavior continue to use the game-data side. A parser regression fixes all three resulting paths.

Unlabeled target keyboard parity

Revisited the target selection fixed earlier for GANTRY pointer input. DOS maintains one resolved selected-target index before the main input dispatcher. The Enter path passes that index to 0x851E, and Space uses it as the starting record for its guarded two-table cycle. Neither path requires the opcode-10 selector string which controls only the transient label artwork.

Rust’s primary-click path used the complete opcode-02 navigation geometry, but Enter and Space consulted the label-only overlay candidate. The opening at (190,70) could therefore be entered by mouse click yet ignored by Enter, and Space could restart from the first visible selector instead of advancing from an unlabeled target.

Enter and Space now share the complete proximity selection used by pointer activation. The existing cycle regression now uses realistic navigation-node records, and the GANTRY scenario verifies both click and pointer-positioned Enter transitions to CP1.

Translation changes retain collected verses

Continued the persistent-state audit through the options translation row. Selection case 0x31D0 advances word 007C modulo four and calls load_text_bank for the current bank. Disassembly of that loader at 0x629C shows writes to the descriptor pointer, selector, companion offset, and span fields, but no write to acquisition state byte +4. The existing state byte is therefore retained by descriptor index.

This is intentionally different from scene opcode 6B. Its handler loads the new bank and then runs an explicit 66-record loop at 0x5837..0x5850 that zeros byte +4. Rust had applied that clearing behavior to both paths by replacing the translated bank with newly zeroed descriptors. Cycling Bible version in Game Options consequently erased every verse collected in the current bank.

The translation setter now copies the existing descriptor-state bytes into the newly parsed translation before installing it. A shipped-data regression marks a mixed set of NIV bank-C records, changes to RSV, verifies that the verse strings change, and verifies that every acquisition state remains at the same descriptor index.

Effects-option playback continuity

A later settings audit found that Rust treated the effects On/Off option as a stop command. The DOS selection case at 0x31F5 only XORs byte 004A and returns to the menu; it has no call to the stop/release path. The wait handler at 0x511E separately checks the driver word, effects word, and driver-state bit. With effects Off it subtracts 100 from the calling thread delay, even if the physical driver is still finishing a sample.

Rust now leaves active playback and its retained timing state intact when the setting changes. Digital-effect elapsed time also continues while a host modal screen pauses scene scripts, matching the independently serviced DOS driver. A regression turns effects Off and back On around elapsed audio time and verifies that neither transition emits a stop event or discards the remaining duration.

Restore replaces the scene at the loop boundary

Audited the disk-restore path after noticing that Rust left the restored scene in pending_scene. The next host tick consequently advanced animations and ran every eligible thread in the predecessor scene once against the restored checkpoint variables before installing the requested scene.

DOS does not have that interval. F9 calls read_save_state at 0x8939, stores its mode in word 007A, and returns from main_menu_and_game_loop through the test at 0x8966. game_main handles mode 2 with copy_save_buffers_to_live_state at 0x8C75, then starts a new scene-loop invocation. Numbered loading reaches the same outer dispatcher.

Rust disk loading now parses the state, reconstructs its text bank, and builds the checkpoint scene before changing any live engine field. It then installs state and scene together, releases scene-owned audio, and emits the scene change immediately. This both removes the extra predecessor update and makes an invalid saved scene resource an atomic load error. Regressions cover immediate replacement and preservation of the complete live state when scene construction fails.

Quick-save palette flash

The adjacent F10 branch exposed a missing visible transition. DOS calls start_palette_blackout(1) at 0x8915, services the compositor at 0x8920, and only then calls write_save_state. The start helper stores 0081; the palette updater clears bit 7, submits a fully black 256-entry VGA palette, and decrements the remaining count to zero. Its next update restores the current mapped and adjusted palette.

Rust quick save now schedules that black palette for the frame presented by the accepting engine update and restores the scene palette at the start of the next update. It does not alter framebuffer indexes or serialized state. A regression uses the shipped logo palette, verifies the all-zero F10 presentation, and verifies exact restoration one update later.

Rebuilt the compatibility probe and ran every one of the 62 shipped scene programs for 2,500 reference-timer units from the captured hall state. The stale predecessor update had previously made ROOM1 and ROOM2 construct an invalid AHAL resource name; both now complete the bounded probe. ROOM3 remains the expected synthetic-state exception because its face-selection loop requires at least one of the progression flags. Supplying flag 42 makes that scene complete the same bound as well.

2026-07-25

Mature-topic policy belongs to map loading

Continued the compatibility pass through the still-unverified installation policy path. Rust had interpreted no-mature mode by removing selectors E0 and above from each runtime Bible bank. The executable’s load_text_bank routine at 0x629C has no such branch: it constructs every ten-byte descriptor. The supplied no-mature hall.SVQ independently contains all 46 bank-C descriptors in source order, including E3, E2, E4, and E1.

The actual gameplay filter is in load_map_resource at 0x034F. After loading a new 16-by-16 map, it scans the cells column by column. If a cell has connections, has low kind 1 through A, and either parameter is at least E0, it preserves the connection nibble but clears the kind and both parameters. Inspection of all 21 resources finds 67 affected cells across twelve Normal and Difficult maps; Easy maps contain none. The export routine has its own selector check, so command-line study output remains filtered without changing descriptor ordinals.

Rust now retains every runtime descriptor and applies the recovered rewrite only to newly loaded map resources. This restores original save-table layout and prevents mature encounters from remaining reachable with missing text. Unit coverage distinguishes eligible connected encounters and stations from rooms, empty halls, later kinds, and ordinary selectors. A shipped-resource regression checks the complete no-mature bank-C descriptor sequence and the known mature cell at (4,1) in CN.MAP.

Map-load identity and exploration reset

The same helper exposed two related lifecycle differences. DOS compares the requested level letter with variable 16 and returns immediately when they match. Rust reloaded the archive member every time, which could erase mutated encounters when a scene requested its current level again. Conversely, when a different level is loaded, DOS clears variables 37 through 52, the sixteen exploration bitmaps, while Rust retained the preceding building’s explored rows.

Opcode 78 now performs the same level-identity guard. A genuinely new level loads and filters its resource, records the level in variable 16, and clears all exploration rows; a same-level request preserves the mutable map and exploration state exactly. The regression mutates the filtered CN.MAP cell, marks an exploration row, repeats the load, and verifies that both values survive.

Validation completed with all 142 Rust tests and all 135 Python tests passing, Clippy clean under -D warnings, all 369 archive resources accepted by the release validator, and both documentation books passing integrity checks and rebuilding successfully. A release-mode OUTC probe from the captured hall state also ran for 2,500 controller updates without a VM or resource error.

2026-07-30

Portable shared random generator

The deterministic-comparison investigation began by separating two mechanisms that earlier notes had grouped together. The executable’s ordinary rand routine at load-module offset 0xE966 is a conventional self-contained 32-bit Microsoft-compatible LCG backed by DS:3968; it does not sample arbitrary game memory. The unusual behavior belongs to the separate opcode 8E and 91 handlers. They calculate 16*y+x into a coordinate-indexed object that is only 80 bytes long, so rows 5 through 15 read whatever globals follow it.

An instruction-boundary ROOM3 capture made the alias concrete. Opcode 91 formed index 006D, making its source DS:008A+006D = DS:00F7. That byte was the high byte 16h of the live BIN cursor 169Ch stored at DS:00F6. This is useful evidence about the DOS implementation, but it is unsuitable as a portable behavioral source of randomness.

The comparison contract now replaces the complete DOS random service rather than merely holding its clock read fixed. All random consumers share this full-period 16-bit recurrence:

state = (state * 0x6255 + 0x3619) modulo 2^16
value = state >> 1

The multiplier is one modulo four and the increment is odd, satisfying the full-period conditions for a power-of-two modulus. A 16-bit implementation fits comfortably in the original real-mode routine, is independent of host integer width, and can be reproduced directly by DOS, Rust, or a future engine. State 1 begins 19511, 30543, 10098, 22502, 1941, 10629.

tools/patch_deterministic_rng.py accepts only the analyzed packed executable hash, reconstructs the independently verified unpacked MZ, and checks every original instruction signature before making four length-preserving edits:

  • 0xE957 replaces the seed argument load with the requested immediate state;
  • 0xE966 replaces the old 38-byte generator and pads the unused tail;
  • 0x5905 removes opcode 8E’s unchecked address calculation and calls the new generator once; and
  • 0x5971 does the same for opcode 91.

The latter handlers clear AH after the call and therefore retain the low byte of the 15-bit result. The patcher rejects relocation overlap and any changed byte outside those four declared extents, rebuilds the MZ checksum, refuses spelling, symlink, hard-link, and case-folded aliases of the input, and writes the output atomically. The original packed CB/CB.EXE remains a read-only input.

run.sh --rng-seed N applies this transformation immediately before QEMU setup. It creates a unique per-run directory, clones the normal play disk, injects the patched executable only into that clone, copies the guest file back out for an exact comparison, and launches QEMU with snapshot writes. The launcher removes that run directory after QEMU exits; --setup-only performs the build and verification without booting and retains the directory for inspection. Concurrent RNG builds with the same seed use independent paths, as do concurrent persistent-image setup work areas. Initial setup can create the normal play image, and an explicit --rebuild can replace it, but ordinary deterministic launches do not mutate an existing persistent image.

Rust now holds the same u16 state and exposes the same decimal --rng-seed 0..65535 launch control. Opcode 82, both values of every 20-swap X shuffle iteration, unused opcode 8B, and opcodes 8E and 91 all advance one shared stream. Restart, scene changes, save, and restore do not reseed it; the historical save format does not serialize it.

The call-reference audit also found a dormant consumer at 0x649F. Opcode 8B starts a 3,000-unit transient-status timer. On an ordinary DOS controller update, the status routine runs before and after VM execution; each eligible call subtracts the same batched elapsed delta and advances the generator once. A draw that expires the signed timer still occurs before the timer is clamped to zero. Clearing flag 36 freezes the timer and suppresses the draw. Rust now brackets each batched update with the same two checks, including the one-draw case when opcode 8B creates the timer between them, and uses the low two bits as the original X/Y flip flags for STUFF.ART frame 27. Because each outer boundary causes draws independently of its elapsed delta, deterministic replay also fixes the controller-boundary and delta sequence; presentation frames may be batched without changing those logical boundaries.

Two QEMU boots with seed 1 at different wall-clock moments both reached RNG state 986Eh after the first draw and produced byte-identical captured frames with SHA-256 9050f1aae93f922f5c8f85007b647d08f53bddd24c7528ae9537ce32b74accc4. The patched seed-1 executable hash was 37be8c2993462cc1f0d5f0d86b5185cca6610b4b1bc8423b955c0121146311f5; the canonical packed input hash remained 2b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0. Two release-mode Rust runs with the same seed, 16,000-tick bound, and automatic confirmation inputs likewise emitted identical screenshots with SHA-256 6fbef089c97e7c1c888943b868e97e5d6c300deefd8e5368f4d548aadb0c36fb.

Final validation passed all 141 Rust library tests, four binary tests, five shipped-data tests, and all 146 repository Python tests. Clippy was clean with warnings denied. Two simultaneous seed-1 setup-only launches built and verified distinct disposable images with the same executable hash, both documentation books rebuilt, and git diff --check was clean.

The deterministic CHAL comparison exposed an over-clear in Rust’s opcode 79 handler. The DOS handler at 0x46F2..0x4700 writes zero to only four count words: scene entries, directional-edge callbacks, node arrivals, and node departures. Navigation edges have a separate count and active movement is not altered.

Rust had additionally erased the navigation-edge table and canceled the current movement. CHAL constructs its graph before executing opcode 79 and then reuses it for Scripture-station and doorway routes. With the graph gone, those opcode-54 requests found no route, so the collection animation appeared without Captain walking to the station and doors opened while he remained at his previous node.

The Rust handler now clears only the four callback collections. A focused regression executes the opcode with all collections populated and a partially completed route, then verifies that the handlers are empty while graph topology, route progress, and motion state remain intact. The clean-room specification now makes both preservation requirements explicit.

Validation passed all 142 Rust library tests, four binary tests, five shipped-data tests, and all 146 repository Python tests. Clippy was clean with warnings denied, and both documentation books rebuilt successfully.

Directional entry callbacks survive loader return

The next deterministic comparison found that FIRST omitted Captain Bible’s beamer materialization when gameplay began. FIRST.BIN stores edge zero as nodes 1 then 0, declares the beamer entry with the same two bytes, and registers opcode 17 at 0x019F to start the animation and sound handler at 0x01CE. Rust moved the actor but never ran that handler.

Rechecking the route controller resolved two coupled errors. The recursive search at 0x6DA5 records direction 1 for stored second-to-first traversal and direction 2 for first-to-second traversal. Dispatcher 0x6889 combines that direction with departure or arrival: opcode 17 is forward departure, 18 is reverse departure, 19 is reverse arrival, and 1A is forward arrival. Rust and the earlier opcode notes had every direction reversed. Opcode 0C itself was already interpreted in the right order: its first byte initializes the current node and its second byte is passed as the movement destination.

The DOS scene initializer calls the offset-zero command stream at 0x67A2 and lets it return at 0x67BA. Only afterward does the caller at 0x7872 find the named opcode-0C record and start its traversal at 0x7955. Departure callbacks run synchronously as fresh thread invocations through 0x7A5C. Rust instead began entry traversal from inside opcode 05; FIRST’s departure callback reactivated thread zero at 0x01CE, after which the same opcode handler unconditionally deactivated it.

The Rust scheduler now lets the initial thread-zero opcode 05 finish and pop the loader invocation before dispatching the named entry. If departure dispatch reactivates thread zero, the scheduler runs it as a fresh invocation in the same controller update. Opcode 05 itself only terminates its current invocation. The four directional registrations now use the recovered DOS mapping.

Focused coverage registers all four edge opcodes, verifies that a synthetic entry departure callback runs only after loader return, and checks that the shipped FIRST beamer entry immediately disables action selection, starts animation zero, and moves along stored edge zero from node 1 to node 0. The clean-room specification and analysis tools now state the same node order, direction table, and post-loader callback lifecycle.

Validation passed all 145 Rust library tests, four binary tests, five shipped-data tests, and all 146 repository Python tests. Clippy was clean with warnings denied, and both documentation books rebuilt successfully.

Route movement follows the DOS controller

The next deterministic comparison exposed several related differences in Captain Bible’s route movement. Rust selected one complete route with breadth-first search, restarted the gait phase at every intermediate node, changed direction without the DOS pivot poses, and replaced an active edge immediately when a new destination was requested. Opcode 53 also left the actor idle instead of passing through the route controller’s minimum same-node traversal.

Disassembly of the unpacked executable recovered the controller rather than approximating the visible result. The recursive search at 0x6DA5 and its wrapper at 0x6EBD use iterative deepening in navigation-edge insertion order. Limits zero through 19 admit routes of one through 20 edges, and each edge’s stored first-to-second branch is tested before its second-to-first branch. There is no visited-node exclusion. The initializer at 0x6F46 prepares the selected edge, while the request routine at 0x779D replaces only the retained destination if movement is already active. The state-five update repeats route selection after every arrival, so callback logic can change the graph or destination before the following edge begins.

The three navigation phases deliberately use different call orders. Initial movement at 0x7815..0x783A searches, runs the source-node departure, runs the directional edge departure, and only then initializes the edge. State-five continuation at 0x7566..0x7584 initializes the selected edge before running its directional departure and then the source-node departure. Arrival at 0x775D..0x7772 first makes the interpolation count negative, then runs the directional edge arrival followed by the destination-node arrival. Each matching callback enters 0x7A5C synchronously and runs as a fresh command stream through its first yield or return before the callback scan continues. This permits the first arrival callback to start a new edge and the following callback to retarget that edge without replacing it.

The synchronous call at 0x7A5C saves and restores the outer BIN cursor, but not the shared slot timer. Consequently a same-slot callback that executes opcode 0F returns to the already-running outer interpreter even though the timer is negative. A later outer 0F publishes the outer cursor and adds its delay to the callback’s. Rust now checks delay eligibility only when entering an invocation and treats opcode 0F as that invocation’s explicit yield, matching the nested behavior instead of pausing the outer script early.

The update routine at 0x7469 preserves the gait phase across intermediate nodes. State two resets it to 0200h only when the final idle pose is reached. The setup code at 0x71BB..0x721E selects RUN.ART frame 20 when horizontal reflection reverses and frame 19 when toward/away depth movement reverses. The countdown at 0x7480..0x7497, render override at 0x761F..0x762B, and interpolation gate at 0x76C5..0x76CC hold that pose for 224 controller units without advancing position or gait phase. Horizontal/depth transitions and one-step same-node movement do not use a pivot pose.

Opcode 53 at 0x5440..0x5465 snaps the actor to the named node, clears the retained directional edge, and initializes a same-node controller traversal. It consequently reaches the node-arrival dispatcher but cannot match a directional-edge arrival. A following opcode 54 may replace the retained destination while that minimum traversal is active. Direct same-node and unreachable requests use the same minimum traversal, while opcode 54 is a true no-op when its operand already equals the retained destination. The desired-node byte starts at FFh, survives scene replacement, and is not changed by opcode 53; only a movement request or final-idle resolution replaces it.

Rust now models origin, edge, and replan stages explicitly. It retains the latest destination without interrupting an edge, recomputes the next edge after arrival callbacks, preserves gait phase until final idle, implements both pivot poses and their controller delay, and uses the recovered bounded iterative-deepening route order. Navigation callbacks now use reentrant local invocation cursors so same-slot handlers execute synchronously in DOS order without losing the caller. Opcode 53 and failed or same-node requests now pass through the minimum controller lifecycle and its node-arrival callback behavior, and the retained destination survives scene changes.

Focused regressions cover the 20-edge search limit, phase continuity, exact pivot cases and timing, active retargeting, callback ordering and reentrancy, arrival-time retargeting, desired-node persistence, opcode-53 behavior, and unreachable destinations. Validation passed all 156 Rust library tests, four binary tests, five shipped-data tests, and all 146 repository Python tests. Clippy was clean with warnings denied, both documentation books rebuilt, and a deterministic 16,000-tick release smoke run stopped normally in dome.

SDL presentation follows the DOS controller cadence

The fourth deterministic-comparison issue was presentation cadence rather than VM or combat logic. Rust converted the complete elapsed wall interval to reference units, advanced every logical unit while composing only the final framebuffer, presented that framebuffer once, and then slept for 16 ms. The sleep did not include input, update, conversion, or rendering time. Even the sleep alone represented about 46 controller units at 2,880 units per second. COMBAT4.BIN contains explicit 30- and 40-unit animation sequences, so an authored record could begin and end inside one ordinary Rust presentation batch without ever reaching SDL.

The DOS timing path establishes the intended steady state. Initialization at 0x3600 programs PIT channel zero with divisor 26D7h, and interrupt handler 0xAAE4 increments the counter at 4B66h approximately 120 times per second. Elapsed helper 0x00B6 multiplies that count by 24 and caps the result at 400. The ordinary controller at 0x7ACB reads and resets the delta, invokes the animation updater at 0x3DA8, and invokes the dirty compositor at 0xC5CA before returning. The input poll calls the controller repeatedly, so a normal nonzero update contains one 24-unit interrupt and one presentation.

The animation updater itself deliberately supports catch-up. It adds the complete controller delta, traverses every due record, and submits only the resolved display slot. DOS may therefore omit intermediate frames after a genuine stall that accumulated multiple interrupts. The conformance rule is the normal 120 Hz presentation cadence, not an unconditional promise to show every record after arbitrary host suspension.

The SDL loop now budgets its entire poll, update, event, audio, and render cycle to 1 / 120 second. It sleeps only the unused part of that 8.33 ms budget, rather than adding a fixed delay after the work. Actual elapsed host time still feeds the 2,880-unit reference timer, and overruns still use the existing 400-unit cap, preserving the recovered catch-up behavior.

A focused frontend regression verifies the deadline calculation, including zero remaining delay after an overrun. It then drives the millisecond timer with forty repetitions of 8, 8, and 9 ms. The 120 boundaries total exactly 2,880 units, and every individual delta remains below COMBAT4’s 30-unit interval. The clean-room runtime, portability boundary, and conformance chapters now distinguish real-time visual cadence from non-visual batching and genuine stalls.

Validation passed all 157 Rust library tests, four binary tests, five shipped-data tests, and all 146 repository Python tests. Clippy was clean with warnings denied, the documentation consistency and opcode audits passed, and both documentation books rebuilt successfully. A release SDL smoke run with dummy video and audio advanced normally through LOGO, TITLE, and INTRO.