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-i386andqemu-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:
| File | Bytes | SHA-256 |
|---|---|---|
CB/CB.EXE | 64,299 | 2b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0 |
CB/DD1.DAT | 1,866,068 | a395fcf9f19d655a6440b5b8ab213983eb7d34a99810b763a9c95360f98f9562 |
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.
| Field | Value |
|---|---|
| Packed MZ entry | 0F79:0010 |
| EXEPACK header file offset | 0xF990 |
| Compressed load bytes | 63,376 (0xF790) |
| Complete EXEPACK block | 411 bytes (0x019B) |
| Decompressor and message | 277 bytes (0x0115) |
| Reconstructed load bytes | 75,264 (0x12600) |
| Real entry | 0000:CB5C |
| Initial stack | 1A40:1388 |
| Reconstructed minimum allocation | 0x091A paragraphs |
| Reconstructed relocation count | 43 |
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:
| Item | Value |
|---|---|
| PSP segment | 0617 |
| Load/CS segment | 0627 (physical 0x06270) |
Title-screen CS:IP | 0627:C614 |
Title-screen DS=ES=SS | 14E1 |
| Relative data-segment base | 0xEBA0 |
| Physical data-segment base | 0x14E10 |
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:
- If
argv[0]has a drive-qualified path,0x8A09changes to that drive and directory. maininitializes resource-name buffers includingLOGO,seg, andDDGAMES, then parses all remaining arguments.0x3363verifies VGA, readsSOUND.5, loads the configuredSOUND.1throughSOUND.4drivers, opensDD1.DAT, and initializes subsystems.0x7F58reads the save index, and0xB818loadsRUN.ARTas resource slot 253.0x7D2Bcreates a new session and initializes the menu/game state.mainrepeatedly 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:
| Offset | Replacement |
|---|---|
0xE957 | Replace mov ax,[bp+4] with mov ax,seed, so the existing seed routine ignores the clock argument and stores the requested 16-bit state. |
0xE966 | Replace the 38-byte Microsoft-compatible generator with the portable 16-bit generator, padded with NOPs in the original extent. |
0x5905 | Replace opcode 8E’s unchecked coordinate calculation and memory read with one call to the portable generator and an eight-bit reduction. |
0x5971 | Apply 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:
| Switch | Static behavior |
|---|---|
-t | Sets the no-mature-topics flag. |
-bX | Maps K to 0, N to 1, R to 2, and L or T to 3. |
-c | Sets the no-combat flag. |
-idirectory | Copies the suffix after -i and appends a backslash for configuration and sound-driver paths. |
-sXfilename | Initializes data, applies translation X, and calls the export routine with the filename after X. |
-gXX | Computes 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:
| Bit | Value | Output |
|---|---|---|
| 0 | 1 | Lie/verse number (#00 template) |
| 1 | 2 | CYBER LIE: |
| 2 | 4 | PARAPHRASE: / lock text |
| 3 | 8 | CONVERSATION WITH VICTIM: |
| 4 | 16 | Communications-room material, represented by several record tags |
| 5 | 32 | VERSE: |
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:
| Offset | Size | Use |
|---|---|---|
0 | 2 | Bible-translation lock, applied only when no command-line lock was supplied; 0070h means unlocked. |
2 | 1 | Mature-topic marker; only DBh permits mature topics, otherwise the no-mature flag is forced. |
3 | 1 | Value 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 file | Original name | Identification |
|---|---|---|
SOUND.1 | soundrv.com | DIGPAK Sound Blaster 16 digital driver, Audio Solution 3.40 |
SOUND.2 | midpak.adv | Miles Design Sound Blaster Pro FM music driver |
SOUND.3 | tmidpak.com | MIDPAK resident music package, Audio Solution 3.0 |
SOUND.4 | midpak.ad | MIDPAK 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:
| Kind | Count | Meaning |
|---|---|---|
| Function | 140 | Application, driver API, decoder, rendering, and Microsoft C routines |
| BIN handler | 134 | Every distinct implementation address in the 145-entry scene-opcode dispatcher |
| Data | 9 | Strings 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:
| Level | Requirement | Current entries |
|---|---|---|
| Verified | Static semantics plus independent agreement with QEMU state, traced I/O, supplied saves, or exhaustive resource decoding | 163 |
| High | Instruction behavior, callers, data layout, and cross-resource use uniquely support the name | 118 |
| Medium | Best current interpretation, but a material semantic ambiguity remains | 2 |
“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:
| Subsystem | Entries | Principal evidence |
|---|---|---|
| Bytecode | 141 | Complete opcode layouts, switch targets, and decoded BIN corpus |
| Runtime | 16 | Microsoft C startup banner, standard implementations, and call sites |
| Graphics | 14 | ART/PAL validation and QEMU framebuffer comparison |
| Saves | 10 | Exact supplied SV0/SV1–SV9/SVQ structures and copy directions |
| Text | 10 | All translation banks and byte-identical QEMU export |
| Audio | 41 | ABT/XMI validation, live PCM, INT 66h traces, and published driver ABI |
| State | 9 | Script corpus, saved words, flag masks, and faith behavior |
| Archive | 7 | Exact extraction of all 369 DD1 members |
| Input | 7 | Action tables, keyboard/mouse callers, and BIOS interfaces |
| Startup | 6 | Entry flow, DOS trace, configuration, and resource loads |
| Animation | 5 | Recovered runtime records and combat sequence corpus |
| Dialogue | 5 | Live choice table and study-Bible suspension sequence |
| Maps | 5 | All 21 maps, saved mutations, and room dispatch |
| Hardware | 3 | VGA/mouse BIOS checks and traced configuration open |
| Scene display | 3 | Live ten-byte record table and framebuffer path |
| Files | 1 | Traced 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:
| Register | Value |
|---|---|
CS:IP | 0627:C668 |
DS=ES=SS | 14E1 |
FS | 0617 (PSP) |
GS | 04C7 |
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.
| Artifact | SHA-256 |
|---|---|
dos-calls.log | f8013fb529444c409a6309a5bbc57336d674382f4e20dcde9185a4d67658e3c9 |
startup-physical-1m.bin | 7fee3fdda30db225711d0db84d1f292efb9b087c4a91deb2e035025cd31bf71e |
startup-screen.png | 85a46bbf6345d5cd88393596706ded3daadbbe0ecb9853cdd0bcecf610077c79 |
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 call | Activity |
|---|---|
| 13 | Change directory to C:\CBDOME. |
| 22 | Open and read the four-byte SOUND.5 installation-lock file. |
| 29 | Load SOUND.1. |
| 43 | Load SOUND.2. |
| 68 | Load SOUND.3. |
| 90 | Load SOUND.4. |
| 105 | Probe DD1.DAT, then reopen it as the persistent main-data handle. |
| 121 | Read DDGAMES.SV0, the 243-byte save-slot index. |
| 135 / 142 | Load DDLC twice during early resource initialization. |
| 181 | Reopen 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:
| File | Bytes | Allocation (BX) | Paragraph bytes |
|---|---|---|---|
SOUND.1 | 4,824 | 012E | 4,832 |
SOUND.2 | 16,263 | 03F9 | 16,272 |
SOUND.3 | 13,312 | 0340 | 13,312 |
SOUND.4 | 3,622 | 00E3 | 3,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:
| System | Observed evidence |
|---|---|
| Startup | Main title, landscape title transition, and difficulty selector. |
| Story | Opening narration and multi-step commander conversation. |
| Exploration | Captain Bible on the exterior platform and inside a hall. |
| Study | F1 Bible interface reporting no loaded verses. |
| Navigation | F2 map interface with the current node graph. |
| State | F3 faith overlay reporting 100 percent. |
| Menus/saves | Gameplay options, normal-save slot list, and name entry. |
| Combat | COMBAT1 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
| File | Bytes | SHA-256 | Current identification |
|---|---|---|---|
CB.EXE | 64,299 | 2b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0 | EXEPACK-compressed DOS MZ program |
CB.ICO | 766 | 81488f25bc2bfcdfbbb20091c2d9c3c48fb8be2ad5305dbc00c561f179a1780b | 32×32, 16-color Windows icon |
CB.PIF | 2,885 | 09818ec07f74adaf49ce5fb1ac31ea7be3fb5c157d04816217647db3d615013a | Windows Program Information File |
DD1.DAT | 1,866,068 | a395fcf9f19d655a6440b5b8ab213983eb7d34a99810b763a9c95360f98f9562 | Main indexed resource container |
DDLA | 10,065 | b667b10536f2a7e9cb8b7d92afb8ef764fd2a4872703cb575129b05cf5616572 | Tagged companion text bank A |
DDLB | 8,253 | 44a5ea34fed950e8265c1eb9eaa7e151941d98e934a39324d52de9d544dc364a | Tagged companion text bank B |
DDLC | 4,020 | bc2ab554cf0dfdd999c7ac6e357551d693f900dc3f0bc67c8248dff99216e560 | Tagged companion text bank C |
DDLD | 14,973 | bf4176fee554a9613a0423c5b5a2df07976ae1171950ba992296071da0220006 | Tagged companion text bank D |
DDLE | 10,489 | 5a7de313baf42f50a47e06774aedc4e98969fc969673ea740b8cd27a77d3ea47 | Tagged companion text bank E |
DDLF | 10,257 | b4ab910df4ff59433ffcdca44f450cd91e50c10b540f0d810826e4e6e8610ffc | Tagged companion text bank F |
DDLG | 9,993 | ab9e8ab9cb8c1bd8d1944dcc8383d045f9ddd7ee8670549d430a037c5299a4b6 | Tagged companion text bank G |
DDLR | 696 | 067cfcfc63f07545e29d26a1d1773d2bd596d397663fd9716014ed4a48b28cc4 | Tagged victim-conversation text bank R |
MANUAL.TXT | 36,384 | 5f2f583be150e5e6c73a5a760e1847b3986e8635e86175b4f82d8c9f70368a42 | Plain-text game manual |
SETSOUND.BAT | 597 | 1d863472ef14a164966a93180bcb785ce32c0ce17a0321b6caf0892930fa18a1 | Sound configuration/install batch file |
SOUND.1 | 4,824 | 286ca4901bc2d3c18982b398ea5cef77920e4e542a834c6113902768c5d0e080 | DIGPAK Sound Blaster 16 COM driver |
SOUND.2 | 16,263 | 491896220f4d7284bcd213180bcb785ce32c0ce17a0321b6caf0892930fa18a1 | Miles Design Sound Blaster Pro FM driver |
SOUND.3 | 13,312 | 69796e9ceb0340bcb03799b49dfdbb4d604c3b22d8a2014e757faef07921427a | MIDPAK COM package |
SOUND.4 | 3,622 | 3350419d0cec0c6c197d91e72340abb133f4de7cf00bff493c1f7ab9fb2ccef8 | MIDPAK timbre data |
SOUND.5 | 4 | 67abdd721024f0ff4e0b3f4c2fc13bc5bad42d0b7851d456d88d203d15aaa450 | Installation locks: 01 00 00 00 |
Supplied saves
| File | Bytes | SHA-256 |
|---|---|---|
DDGAMES.SV0 | 243 | 6f460832b488527a5cdc06d5860c10ff68509c9063ce0e88376757634f81ae43 |
DDGAMES.SV1 | 2,752 | 26a409ea52ec2f21363408d7ae8bf886bca70c318ec751b39b01e947776c9e54 |
DDGAMES.SV2 | 2,752 | 666cb666bec4290812cc8d2142755d58593e108f9d18daf47771f695de73237d |
DDGAMES.SV3 | 2,752 | 33d2f0ea672255e30db017eb5b74f14a521bd0000eaf497d5c8179ba92a19cd8 |
DDGAMES.SV4 | 2,752 | 2102f392e43881001fc6b61097143ff09d9f869d1f7cdd7fc37b8102cdbe35d3 |
DDGAMES.SV5 | 2,752 | 238d409176200f50dd009cb739f7f6393491f3b0b92e7ee998a549ea4cea947d |
DDGAMES.SV6 | 2,752 | 55c20b1824cbd7ea203dae82286a1999d1bd733b2e3f67a6a80fc44dcdd3cdee |
DDGAMES.SV7 | 2,752 | b339db79febc4e8fec6880325a0b3c5fc64abdd63ab813cde033071aa70aedde |
DDGAMES.SV8 | 2,752 | 55c20b1824cbd7ea203dae82286a1999d1bd733b2e3f67a6a80fc44dcdd3cdee |
DDGAMES.SV9 | 2,752 | 6139575d1cf94a76d64a730d7bf06dd9da7c2f1b5a66e5f386ef05afff04a0f3 |
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 offset | Size | Meaning |
|---|---|---|
0x00 | 8 | Zero-padded ASCII base name. |
0x08 | 1 | Storage marker: 0 is raw and 1 is compressed. |
0x09 | 3 | Zero-padded ASCII extension, without a dot. |
0x0C | 4 | Absolute payload offset, little endian. |
0x10 | 4 | Expanded size. |
0x14 | 4 | Stored 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:
| Extension | Members |
|---|---|
ART | 143 |
BIN | 62 |
ABT | 41 |
PAL | 37 |
| no extension | 33 |
XMI | 32 |
MAP | 21 |
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:
| Index | Marker | Payload offset | Stored | Expanded | Name |
|---|---|---|---|---|---|
| 0 | 1 | 0x229A | 576 | 1,041 | BOSS2.ART |
| 1 | 1 | 0x24DA | 431 | 640 | LOGO.BIN |
| 82 | 1 | 0x6F69C | 16,138 | 53,213 | RUN.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:
- Dictionary codes 0 through 255 are literal bytes. Each has a prefix of
-1and a suffix equal to the byte value. - 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 to0x101. - 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. - 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:
| Member | Bytes | Extracted SHA-256 |
|---|---|---|
LOGO.BIN | 640 | 8580d3ff93c6e775aa71334c50762ffde2b1f42a320ee362f5608bd8cbc51424 |
RUN.ART | 53,213 | c4b00d2e31e9dec81cc419dc577086b143a546a4a0b618dbe5600df4e5fd4ac0 |
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 offset | Current name | Evidence |
|---|---|---|
0x97D0 | archive_load_member | Looks up a record, then dispatches marker 0 to the raw reader or marker 1 to the decoder. |
0x99AB | archive_lookup_member | Uppercases and splits a requested name, scans 24-byte records, seeks to the payload, and checks GC. |
0x9BEF | archive_read_raw_member | Copies the declared expanded length to a caller buffer in far-memory chunks. |
0x9C5F | archive_refill_input | Refills the decoder’s archive input buffer. |
0x9CA4 | archive_decode_member | Initializes literals, reads bit planes and codes, and builds the dictionary. |
0x9D98 | archive_expand_code | Recursively 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_biosat0xA017invokes video BIOS function1012hwith 256 entries, start index 0, and a far pointer to the triplets.vga_write_palette_rangeat0xA032waits for vertical retrace through port03DAh, writes a starting index to03C8h, then sends three unmodified bytes per entry to03C9h.
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 offset | Size | Type | Meaning |
|---|---|---|---|
0x00 | 2 | signed little-endian | Horizontal origin or anchor offset. |
0x02 | 2 | signed little-endian | Vertical origin or anchor offset. |
0x04 | 2 | unsigned little-endian | Width in pixels. |
0x06 | 2 | unsigned little-endian | Height in pixels. |
0x08 | 4 | unsigned little-endian | Absolute 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:
| Property | Value |
|---|---|
| Total frame descriptors | 1,178 |
| Total indexed pixels | 4,850,699 |
| Largest frame table | 63 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:
| Frame | X | Y | Width | Height | Pixel offset |
|---|---|---|---|---|---|
| 0 | 88 | 54 | 124 | 74 | 0x48 |
| 1 | 94 | 80 | 112 | 107 | 0x2420 |
| 2 | 147 | 9 | 138 | 165 | 0x52F0 |
| 3 | 207 | 15 | 101 | 51 | 0xABE2 |
| 4 | -3 | -3 | 7 | 9 | 0xC001 |
| 5 | 22 | 138 | 275 | 30 | 0xC040 |
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:
| Overlay | Differing pixels | Difference bounding box |
|---|---|---|
| Animated floppy/save icon | 333 | X 297–316, Y 1–17 |
| Mouse cursor at screen center | 19 | X 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.
Full-screen gallery
Exactly 11 frames have origin (0, 0) and dimensions 320×200. The scene
programs select the following palettes before loading them:
| Archive index | ART frame | Scene-selected palette |
|---|---|---|
| 006 | INTRO.ART frame 0 | TITLE.PAL |
| 063 | PRAY.ART frame 0 | PRAY.PAL |
| 073 | OVER.ART frame 0 | 1.PAL |
| 090 | LAW1.ART frame 0 | LAW.PAL |
| 093 | KABLAM1.ART frame 0 | KABLAM.PAL |
| 097 | SPEAKER.ART frame 0 | 1.PAL |
| 100 | HOLE.ART frame 0 | HOLE.PAL |
| 122 | DOME.ART frame 0 | DOME.PAL |
| 130 | DENY1.ART frame 0 | DENY.PAL |
| 133 | CULTA.ART frame 0 | 1.PAL |
| 165 | BOSS.ART frame 0 | BOSS.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 offset | Current name | Evidence |
|---|---|---|
0x9FF7 | vga_set_dac_entry | Writes an index to 03C8h and one RGB triplet to 03C9h. |
0xA017 | vga_load_palette_bios | Loads 256 triplets with BIOS video function 1012h. |
0xA032 | vga_write_palette_range | Writes a caller-selected range during vertical retrace. |
0xA0C9 | blit_rect_to_vga | Copies an opaque rectangle with a 320-byte destination stride. |
0xA106 | blit_rect_transparent_zero | Copies rows while skipping pixel value 0. |
0xA136 | blit_rect_opaque | Copies rows without a color key. |
0xAE42 | Font initialization | Loads STUFF.ART frame 0 and builds the glyph-offset table. |
0xAF36 | Proportional glyph renderer | Copies seven atlas rows using the executable width table. |
0xBE7E | Styled text-object renderer | Remaps atlas values 0, 1, and 2 through a palette-index triplet. |
0xB620 | update_palette_effect | Applies bounded component offsets and submits changed palette ranges. |
0xB99C | draw_art_frame_opaque | Indexes 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 offset | Runtime example | Interpretation |
|---|---|---|
0x00F6 | 0x004B | Current file-relative bytecode offset. |
0x00F8 | 0x4C13 | Segment containing the current bytecode cursor. |
0x00FA | 0x0000 | Offset of the loaded resource’s far-memory base. |
0x00FC | 0x4C13 | Segment 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 offset | Current name | Operation |
|---|---|---|
0x3A1E | bin_read_u8 | Read one byte and advance the far cursor. |
0x3A30 | bin_read_u16 | Read one little-endian word. |
0x3A64 | bin_read_cstring_offset | Return 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:
| Marker | Encoding |
|---|---|
B | Unsigned byte. |
H | Unsigned little-endian 16-bit word. |
z | Inline NUL-terminated CP437 string. |
p | String pointer encoded inline as a NUL-terminated string, or as FF plus an explicit 16-bit resource-relative offset. |
9 | Opaque nine-byte animation record skipped by opcode 0x07. |
s | An 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:
- the exact 145-word dispatch table at load offset
0x59AB; - every reachable path through the interpreter’s 1,817 Rizin instructions;
- calls to the byte, word, and pointer-string readers, plus the four inline
string loops and opcode
0x07’s direct nine-byte cursor advance; - the handler aliases and per-symbol evidence in
analysis/cb.rzandanalysis/symbol-map.tsv; and - 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:
| Opcode | Operands | Current name | Evidence |
|---|---|---|---|
0x01 | z | load_art | Passes the name to load_art_resource, which appends .ART. |
0x05 | none | return_minus_one | Terminates interpretation with return value -1. |
0x02 | BHHH | create_scene_thread | Initializes a thread slot and appends a type-0x02 display record. |
0x03 | BBHHB | add_native_scale_display_object | Unused. Appends a directly rendered object with implicit scale 0x0100. |
0x04 / 0x43 | BBHHHB | add_scaled_display_object | Appends a directly rendered object with frame, ART slot, X, Y, scale, and flags. |
0x06 | H | begin_animation_sequence | Creates animation state and appends a type-0x06 display record. |
0x07 | 9 | animation_step | Advances over a fixed nine-byte step retained for later animation updates; it does not require a preceding sequence. |
0x08 | BB | start_animation | Reads animation, mode, then starts the selected slot. |
0x5F | BBB | start_linked_animation | Reads animation, linked animation, mode, then starts the selected slot. |
0x09 | B | stop_animation | Stops an animation and releases its render slot. |
0x0D | zz | change_scene | Selects a new scene and secondary segment name. |
0x0F | H | adjust_thread_delay | Subtracts the operand from the current scheduler slot’s delay word at offset +0x0C. |
0x13 | H | remove_dialogue_choice | Unused. Removes the first six-byte choice record with the matching target. |
0x14 | p | show_adversary_dialogue | Uses the adversary presentation channel; all ten uses are in FACE.BIN. |
0x1E | HH | copy_variable | Copies one signed script-variable word to another. |
0x1F | HH | set_variable | Stores an immediate in a script variable. |
0x20 | HH | jump_if_zero | Selects an absolute target when a variable is zero. |
0x21 | HH | jump_if_nonzero | Selects an absolute target when a variable is nonzero. |
0x22 | HHH | jump_if_variables_equal | Compares two variables and jumps when they are equal. |
0x23 | HHH | jump_if_variable_equals | Compares a variable with an immediate and jumps when they are equal. |
0x24 | HHH | jump_if_variables_not_equal | Compares two variables and jumps when they differ. |
0x25 | HHH | jump_if_variable_not_equal | Compares a variable with an immediate and jumps when they differ. |
0x26 | HHH | jump_if_variable_greater_than_variable | Signed-compares two variables and jumps when the first is greater. |
0x27 | HHH | jump_if_variable_greater_than | Signed-compares a variable with an immediate and jumps when the variable is greater. |
0x28 | HHH | jump_if_variable_less_than_variable | Signed-compares two variables and jumps when the first is less. |
0x29 | HHH | jump_if_variable_less_than | Signed-compares a variable with an immediate and jumps when the variable is less. |
0x2A | HH | add_variable | Adds one script variable to another. |
0x2B | HH | add_to_variable | Adds an immediate to a script variable. |
0x2C | HH | subtract_variable | Subtracts one script variable from another. |
0x2D | HH | subtract_from_variable | Subtracts an immediate from a script variable. |
0x2E | HH | multiply_variables | Unused. Signed-multiplies a script variable by another variable. |
0x2F | HH | multiply_variable | Signed-multiplies a script variable by an immediate. |
0x30 | HH | divide_variables | Unused. Signed-divides a script variable by another variable. |
0x31 | HH | divide_variable | Signed-divides a script variable by an immediate. |
0x32 | H | increment_variable | Increments a numbered variable. |
0x33 | H | decrement_variable | Decrements a numbered variable. |
0x34 | H | call | Saves a return offset and jumps to an absolute target. |
0x35 | none | return | Resumes the saved bytecode return offset. |
0x36 | B | set_text_record_state | Sets persistent descriptor byte +4 selected by record identifier. |
0x37 | B | clear_text_record_state | Unused. Clears persistent descriptor byte +4 selected by record identifier. |
0x38 | BH | jump_if_text_record_set | Selects a target when a record state is set. |
0x39 | BH | jump_if_text_record_clear | Unused. Selects a target when a record state is clear. |
0x3A | HHHp | add_action_target | Reads target, x, y, label and appends one selectable screen action. |
0x3B | B | enable_action_target | Sets one action record’s active byte. |
0x3C | B | disable_action_target | Clears one action record’s active byte. |
0x3D | H | jump | Replaces the cursor with an absolute file offset. |
0x3E | BH | start_scene_thread_at | Freshly activates a BIN scheduler slot at an absolute target, clears stale scheduling state, and runs it before the caller continues. |
0x3F | B | wait_for_animation | Continues for animation state 0, 5, or 6; otherwise suspends and retries the command. |
0x41 | none | enable_action_selection | Enables screen-action input for the current scene. |
0x42 | none | disable_action_selection | Disables screen-action input for the current scene. |
0x44 | Hp | add_dialogue_choice | Appends an absolute target and far text pointer to the choice table. |
0x45 | none | clear_dialogue_choices | Clears the choice count and dialogue state. |
0x46 | none | present_dialogue_choices | Suspends the thread until the selected choice supplies a new BIN target. |
0x48 | p | show_character_dialogue | Presents the character/boss/victim dialogue channel. |
0x49 | none | request_study_bible | Requests the modal study interface and suspends the current thread. |
0x4D | z | load_palette | Calls load_palette_resource, which appends .PAL. |
0x4E | p | show_captain_bible_dialogue | Presents Captain Bible’s dialogue channel, also reused for some captions. |
0x52 | B | play_music | Builds MUS### or IBM### and loads an XMI member. |
0x55 | none | snapshot_state | Copies the live state into a retained buffer. |
0x57 | BH | prepare_sound_effect | Stops/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. |
0x58 | none | start_prepared_sound_effect | Stops any active instance and starts or restarts the retained preformatted sample. |
0x59 | none | wait_for_sound_effect | With 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. |
0x60 | none | nop | Continues directly to the next command. |
0x61 | B | stop_scene_thread | Clears one BIN scheduler slot’s active byte. |
0x65 | BB | clear_display_object_frames | Unused. Reads first, count and clears frame byte +7 for records [first, first + count). |
0x66 | BBBB | advance_display_object_frames | Unused. Reads first, count, minimum, maximum; increments selected frames and resets a result below minimum or above maximum to minimum. |
0x6C | HHHH | rotate_palette_range | Advances a script-variable phase by a signed step, wraps it within an inclusive palette-index range, and rotates that range’s mapping. |
0x6D | z | load_palette | Uses the same palette-loading path as 0x4D. |
0x70 | none | unload_last_art | Releases the most recently loaded art slot. |
0x72 | none | yield_scene_thread | Stores -1 in the current scheduler slot’s delay word and returns the following BIN offset; the next controller update can resume there. |
0x73 | BH | jump_if_state_flag_clear | Selects a target when a boolean state flag is clear. |
0x74 | BH | jump_if_state_flag_set | Selects a target when a boolean state flag is set. |
0x75 | B | clear_state_flag | Clears one identifier in the 128-bit state bank. |
0x76 | B | set_state_flag | Sets one identifier in the 128-bit state bank. |
0x77 | none | process_current_map_cell | Calls the current-cell handler, which consults the cell and its neighbors. |
0x78 | B | load_map | Combines a level letter with the current E/N/D difficulty code and loads a .MAP member. |
0x7A | HH | patch_bin_byte_from_variable | Writes the low byte of a script variable to an absolute offset in the current BIN resource. |
0x7B | H | set_current_map_cell_kind | Preserves the cell’s high nibble and ORs it with the variable’s low byte; shipped callers supply low-nibble kind values. |
0x7C | H | set_current_map_cell_parameter_a | Writes the current cell’s second byte from a script variable. |
0x7D | BH | configure_study_prompt | Selects a companion-text component and record selector for the next study screen. |
0x7E | none | blackout_palette | Starts an immediate black palette effect before a scene transition. |
0x7F | H | set_current_map_cell_parameter_b | Writes the current cell’s third byte from a script variable. |
0x80 | BH | jump_if_animation_active | Selects a target when an animation state byte is nonzero. |
0x81 | H | reduce_faith | Subtracts a difficulty-scaled immediate from faith unless no-combat mode is active. |
0x82 | HH | set_variable_random_modulo | Advances the shared DOS runtime generator and stores its 15-bit result modulo the immediate in a script variable. |
0x85 | B | hide_display_object | Sets the high hidden bit in a display record’s ART-slot byte. |
0x86 | B | show_display_object | Clears the high hidden bit in a display record’s ART-slot byte. |
0x87 | none | normalize_map_cells | Applies recovered location-kind and parameter transitions across the grid. |
0x88 | none | clear_text_record_states | Clears persistent byte +4 in all 66 text descriptors. |
0x89 | none | mark_current_map_cell_explored | Sets the current X bit in the current Y exploration row. |
0x8E | none | sync_current_cell_flags_23_to_27 | The 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. |
0x8F | HH | and_variables | Unused. ANDs a destination with another variable. |
0x90 | HH | and_variable | Unused. 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.
| Opcode | Operands | Recovered name | Operation |
|---|---|---|---|
0x0A | none | wait_for_scene_thread_movement | Yields the current BIN thread until primary movement state is 0 or 2. |
0x0B | BB | add_navigation_edge | Appends an undirected two-node edge in the insertion order used by the bounded iterative-deepening route finder. |
0x0C | BBp | add_scene_entry | Associates an entry/segment string with the initial/current node byte followed by the destination node byte used after change_scene. |
0x0E, 0x4A, 0x4B, 0x56 | none | nop | Unused. All four jump-table entries point at the interpreter’s continue loop. |
0x10 | BHHp | configure_scene_thread_action | Gives a scene thread selector X/Y coordinates and an action-label pointer; newly created scene-thread selectors start enabled. |
0x11 | BHs | add_navigation_arrival_handler | Maps a destination node to a BIN target and optional explicit thread slot. |
0x12 | BHs | add_navigation_departure_handler | Maps a source node to a BIN target and optional explicit thread slot. |
0x15 | B | select_study_record | Selects the text descriptor expanded by study placeholders and clears both success continuations. |
0x16 | HHH | set_palette_adjustment_range_from_variable | Fills an inclusive signed palette-brightness adjustment range with one script-variable value and schedules an update. |
0x17 | BHs | add_forward_edge_departure_handler | Adds a callback for starting traversal in an edge’s stored first-to-second order. |
0x18 | BHs | add_reverse_edge_departure_handler | Unused. Adds a callback for starting traversal from the stored second node to the first. |
0x19 | BHs | add_reverse_edge_arrival_handler | Adds a callback for completing traversal from the stored second node to the first. |
0x1A | BHs | add_forward_edge_arrival_handler | Adds a callback for completing traversal in stored first-to-second order. |
0x1B | H | prime_primary_scene_thread_timer | Unused. 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. |
0x1C | B | enable_scene_thread_action | Enables one scene thread as an input selector. |
0x1D | B | disable_scene_thread_action | Disables one scene thread as an input selector. |
0x40 | B | set_scene_thread_motion_state | Writes 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. |
0x47 | B | set_modal_menu_selection | Unused. Seeds the selection consumed and reset by the modal text-menu path. |
0x4C | B | fill_screen | Fills the complete 320-by-200 framebuffer with one palette index. |
0x4F | BB | configure_study_navigation_success | Unused. Selects a study record and the navigation node entered after success. |
0x50 | none | clear_study_record_selection | Unused. Clears both active study-record selector words. |
0x51 | BHB | configure_study_thread_success | Selects a study record plus the BIN target and thread slot started after success. |
0x53 | B | set_scene_thread_origin | Initializes the primary navigation object’s nodes and X/Y/scale without replacing its retained destination, then starts a minimum same-node controller traversal. |
0x54 | B | move_scene_thread_to | Retains a desired navigation node; starts movement while idle or replaces only the destination while an edge remains active. |
0x5A | H | jump_if_digital_audio_fallback | Jumps 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. |
0x5B | B | set_scene_thread_direction | Selects one of four movement orientations and its sprite/render offset. |
0x5C | BBB | configure_captain_bible_dialogue | Writes Captain Bible text X, text Y, and wrap width. |
0x5D | BBB | configure_character_dialogue | Writes character text X, text Y, and wrap width. |
0x5E | H | set_deferred_scene_thread_target | Unused. Sets a target that the main loop later starts in scheduler slot 2. |
0x62 | H | store_mouse_x | Stores the current mouse X coordinate in the selected script variable. |
0x63 | H | store_mouse_y | Stores the current mouse Y coordinate in the selected script variable. |
0x64 | H | jump_if_confirm_pressed | Consumes the Enter-or-click latch and jumps to an absolute target when set. |
0x67 | none | request_restore_saved_game | Leaves the scene loop through mode 2, which restores retained save buffers. |
0x68 | H | adjust_variable_1280_once | Applies 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. |
0x69 | HH | load_bin_word | Loads a little-endian word at an immediate current-BIN offset into a variable. |
0x6A | HH | patch_bin_word_from_variable | Writes a variable word to an immediate current-BIN offset. |
0x6B | B | load_text_bank | Replaces the active companion-text bank and clears all 66 descriptors. |
0x6E | B | start_primary_scene_thread_overlay | Unused. Loads and starts a resource-driven transient overlay for scene thread zero. |
0x6F | none | wait_for_primary_scene_thread_overlay | Unused. Yields while that transient overlay remains active. |
0x71 | HH | load_bin_word_indirect | Uses one variable as the BIN offset, loads a word, and stores it in another variable. |
0x79 | none | clear_navigation_handlers | Clears only scene-entry, edge-transition, node-arrival, and node-departure callback counts. It neither clears the navigation-edge table nor cancels active movement. |
0x83 | HBH | copy_text_record_component_to_bin | Selects a text record through a variable and copies one component to an immediate BIN offset. |
0x84 | HH | load_bin_byte | Sign-extends a byte at an immediate current-BIN offset into a variable. |
0x8A | BH | jump_if_animation_finished | Jumps when the selected animation is in state 0, 5, or 6. |
0x8B | none | consume_random_text_record | Unused. 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. |
0x8C | H | jump_if_no_combat | Jumps when the installation’s SOUND.5 no-combat flag is set. |
0x8D | H | jump_if_file_missing | Appends 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. |
0x91 | HH | set_variable_current_cell_byte_modulo | The 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 bytes | Logical value |
|---|---|---|
(6,5), (7,5) | DS:E0 word | Number of scene-loaded ART slots, excluding permanently loaded RUN.ART. |
(8,5), (9,5) | DS:E2 word | Display-record count. |
(4,6), (5,6) | DS:EE word | Current pointer-selector index, or -1. |
(6,6), (7,6) | DS:F0 word | Previously rendered pointer-selector index, or -1. |
(8,6), (9,6) | DS:F2 word | Current selector kind: scene thread 0, action target 1. |
(10,6), (11,6) | DS:F4 word | Previously rendered selector kind, initially -1. |
(12,6), (13,6) | DS:F6 word | Current BIN cursor immediately after the opcode byte. |
(14,6), (15,6) | DS:F8 word | Current BIN cursor segment. |
(0,7), (1,7) | DS:FA word | Normalized current-BIN base offset, normally zero. |
(2,7), (3,7) | DS:FC word | Current-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:
| Resource | Expanded bytes | Commands |
|---|---|---|
LOGO.BIN | 640 | 114 |
TITLE.BIN | 436 | 80 |
INTRO.BIN | 184 | 39 |
MENU.BIN | 2,004 | 99 |
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.BINhas commands at0x0000..0x1D54, followed by a 256-byte structured data trailer at0x1D55..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.BINhas command regions0x0000..0x0336,0x0C96..0x1754, and0x1768..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 offset | Current name |
|---|---|
0x034F | load_map_resource |
0x0457 | normalize_map_cells |
0x075F | show_map_screen |
0x0C6C | process_current_map_cell |
0x1B6C | start_palette_blackout |
0x1C88 | show_study_bible |
0x2556 | select_from_text_menu |
0x2933 | show_dialogue_message |
0x3AD2 | reset_scene_display_records |
0x3AFF | render_scene_display_records |
0x3B9B | resolve_animation_transform |
0x3D08 | render_animation_slot |
0x3DA8 | update_animation_slots |
0x3F59 | start_animation_slot |
0x3FDF | stop_animation_slot |
0x3A1E | bin_read_u8 |
0x3A30 | bin_read_u16 |
0x3A64 | bin_read_cstring_offset |
0x4001 | load_palette_resource |
0x4091 | play_music_resource |
0x446F | render_study_prompt |
0x451B | execute_bin_commands |
0x6631 | initialize_scene |
0x6939 | cycle_action_selector |
0x6A23 | update_action_selector_overlay |
0x7997 | update_scene_threads |
0x7A5C | start_scene_thread |
0x834E | handle_study_bible_request |
0x8558 | find_action_target_by_key |
0xB5A8 | rotate_palette_range |
0xB948 | release_render_slot |
0xBCAC | render_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:
| Offset | Size | Meaning |
|---|---|---|
+0 | 2 | Signed X coordinate. |
+2 | 2 | Signed Y coordinate. |
+4 | 2 | 8.8 scale; 0x0100 is native size. |
+6 | 1 | Loaded ART slot. Bit 7 is also the hidden marker. |
+7 | 1 | One-based ART frame number; zero suppresses drawing. |
+8 | 1 | Render flags; bits 0 and 1 flip the two axes. |
+9 | 1 | Display-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:
| Type | Producer | Role |
|---|---|---|
0x02 | Opcode 0x02 | Connects one of the 16-byte scene-command thread slots to the display/update list. |
0x03 | Opcode 0x03 | Direct ART-frame object with an implicit native scale of 0x0100. |
0x43 | Opcodes 0x04 and 0x43 | Direct ART-frame object with an explicit scale. |
0x06 | Opcode 0x06 | Connects 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
0x87D3–0x8842 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:
| Opcode | Operands | Effect |
|---|---|---|
0x02 | thread, x, y, scale | Initializes a 16-byte command-thread slot and appends a type-0x02 display record. |
0x03 | frame, art, x, y, flags | Appends a native-scale type-0x03 display object; unused by shipped scripts. |
0x04 / 0x43 | frame, art, x, y, scale, flags | Appends a scaled type-0x43 display object. |
0x06 | delay | Begins an animation sequence and appends a type-0x06 display record. |
0x07 | nine-byte record | Supplies an animation step retained within the BIN stream. |
0x65 | first, count | Sets frame byte +7 to zero for a consecutive display-record range; unused by shipped scripts. |
0x66 | first, count, minimum, maximum | Increments each selected frame and resets values outside the inclusive range to minimum; unused by shipped scripts. |
0x85 | index | Sets ART-slot bit 7, hiding a direct display object. |
0x86 | index | Clears 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
0x02invokes the associated thread/update slot; - types
0x03and0x43pass the recovered ten-byte fields to0xBCAC; - 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
0x3B72–0x3B76; animation rendering passes its saved display index plus
one at 0x3D64–0x3D6B; scene-thread movement does the same at
0x763D–0x7642. 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:
| Index | X | Y | Scale | Flags | Frame | ART slot | Type |
|---|---|---|---|---|---|---|---|
| 7 | 303 | 0 | 0x0100 | 1 | 4 | 1 | 0x43 |
| 8 | 3 | 0 | 0x0100 | 0 | 4 | 1 | 0x43 |
| 9 | 3 | 0 | 0x0100 | 0 | 1 | 1 | 0x43 |
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-
0x0216-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 offset | Current name |
|---|---|
0x3AD2 | reset_scene_display_records |
0x3AFF | render_scene_display_records |
0x451B | execute_bin_commands |
0x6631 | initialize_scene |
0xB948 | release_render_slot |
0xBCAC | render_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:
| Program | Manual identity | Bytes | Commands | Animation sequences | Steps | Actions | Enemy ART bases |
|---|---|---|---|---|---|---|---|
COMBAT1.BIN | Macho | 3,300 | 625 | 33 | 182 | 4 | BIG, BIG2, BIG3, BIG4 |
COMBAT2.BIN | Armored | 7,230 | 973 | 30 | 594 | 4 | HELMET |
COMBAT3.BIN | Mantis | 4,381 | 782 | 38 | 260 | 4 | MANTIS, MANTIS2, MANTIS3 |
COMBAT4.BIN | Snake | 7,434 | 1,073 | 29 | 581 | 4 | SNAKE, SNAKE2 |
COMBAT5.BIN | Spider | 6,501 | 919 | 34 | 523 | 4 | CRAB |
COMBAT6.BIN | Leech | 2,314 | 351 | 15 | 163 | 3 | GUARD, GUARD2 |
COMBAT7.BIN | Zapper | 4,289 | 720 | 35 | 293 | 4 | ZAP, ZAP2, SPRK |
| Total | 35,449 | 5,443 | 214 | 2,596 | 27 |
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 offset | Size | Meaning |
|---|---|---|
+0 | 1 | One-based ART frame. |
+1 | 1 | Loaded ART slot. |
+2 | 2 | Signed X coordinate. |
+4 | 2 | Signed Y coordinate. |
+6 | 2 | 8.8 scale; 0x0100 is native size. |
+8 | 1 | Render 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 offset | Size | Meaning |
|---|---|---|
+0 | 2 | BIN offset of the first step. |
+2 | 2 | BIN offset of the current step. |
+4 | 2 | Sequence interval/countdown input. |
+6 | 2 | Linked or parent animation index. |
+8 | 1 | Animation mode/state. |
+9 | 1 | Associated render/display slot. |
+10 | 2 | Internal 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:
| Mode | Start | Direction | Boundary result |
|---|---|---|---|
| 1 | First | Forward | Stop and release after the final step. |
| 2 | Last | Backward | Stop and release before the first step. |
| 3 | First | Forward | Wrap to first. |
| 4 | Last | Backward | Wrap to last. |
| 5 | First | None | Retain first in terminal state 5. |
| 6 | Last | None | Retain last in terminal state 6. |
| 7 | First | Forward | Retain last and change to mode 8. |
| 8 | First | Backward | Retain first and change to mode 7. |
| 9 | First | Forward | Retain last and change to terminal state 6. |
| 10 | Last | Backward | Retain 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 offset | Size | Meaning |
|---|---|---|
+0 | 2 | Absolute target offset in the current BIN program. |
+2 | 2 | Screen X coordinate. |
+4 | 2 | Screen Y coordinate. |
+6 | 2 | Offset of a selector string in the current BIN. |
+8 | 1 | Active flag. |
+9 | 1 | Reserved/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:
| Selector | Frame label | Definitions in seven combat programs |
|---|---|---|
.11 | ATTACK | 7 |
.12 | DEFEND | 6 |
.13 | RETREAT | 7 |
.14 | COMBAT | 7 |
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:
| Source | Target | X | Y | Action |
|---|---|---|---|---|
0x0C06 | 0x0EC8 | 151 | 61 | ATTACK |
0x0C11 | 0x0EAB | 136 | 153 | DEFEND |
0x0C1C | 0x1053 | 15 | 167 | RETREAT |
0x0C27 | 0x0FA7 | 157 | 62 | COMBAT |
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:
| Program | Base faith-loss immediates | Victory map mutation | Retreat target and exit entry |
|---|---|---|---|
COMBAT1 | 533, 2,011 | kind 0xB | 0x0C38 -> 0x0C9D |
COMBAT2 | 107, 102, 502 | kind 0xB | 0x1BAC -> 0x1BF6 |
COMBAT3 | 1,037, 531, 2,011, 1,703 | kind 0xB | 0x10C3 -> 0x10D6 |
COMBAT4 | 596, 1,005 | kind 0xB | 0x1C9D -> 0x1CC3 |
COMBAT5 | 213, 2,009 | kind 0xB | 0x18F7 -> 0x1902 |
COMBAT6 | none | kind 0xA; copy parameter B to A | 0x087B -> 0x087E |
COMBAT7 | 233, 207 | kind 0xB; restore faith | 0x1053 -> 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-
0x02interactive/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.
| Opcode | Operands | Current name | Corpus evidence |
|---|---|---|---|
0x14 | p | show_adversary_dialogue | Ten uses, all in FACE.BIN, spoken by the Tower of Deception. |
0x48 | p | show_character_dialogue | 306 uses in 12 resources for the boss, victims, and allies. |
0x4E | p | show_captain_bible_dialogue | 281 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 / offset | Size | Meaning |
|---|---|---|
DS:B428 | 2 | Number of active choices. |
DS:B116 + 6*n + 0 | 2 | Absolute target offset in the current BIN resource. |
DS:B116 + 6*n + 2 | 2 | Text pointer offset. |
DS:B116 + 6*n + 4 | 2 | Text 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:
clear_dialogue_choices(0x45, handler0x51B7) sets the count and dialogue state to zero.add_dialogue_choice(0x44, handler0x51C6) stores the absolute target and far string pointer, then increments the count.present_dialogue_choices(0x46, handler0x5257) enters state 1 and suspends the current scene thread.select_from_text_menuat0x2556draws the entries and returns the target word belonging to the chosen row.poll_input_eventwrites that target toDS: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:
| Choice | Source | Target | Text |
|---|---|---|---|
| 0 | 0x045C | 0x0644 | So what do I do when I get inside? |
| 1 | 0x0482 | 0x07E8 | Can I expect any resistance? |
| 2 | 0x04A2 | 0x0751 | What about the people inside? |
| 3 | 0x04C3 | 0x0519 | Should I expect any problems with my computer bible? |
| 4 | 0x04FB | 0x095C | OK! 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 value | Companion component shown |
|---|---|
0x00, 0x09 | No prompt. |
0x2A (*) | Victim-conversation text. |
0x64 | P paraphrase text. |
| other nonzero | L 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 offset | Current name |
|---|---|
0x1C88 | show_study_bible |
0x2556 | select_from_text_menu |
0x2933 | show_dialogue_message |
0x446F | render_study_prompt |
0x4672 | Configure-study-prompt handler. |
0x51B7 | Clear dialogue-choice handler. |
0x51C6 | Add dialogue-choice handler. |
0x51FF | Remove dialogue-choice handler. |
0x5257 | Present dialogue-choice handler. |
0x52A3 | Shared dialogue-message handler. |
0x5351 | Study-Bible request handler. |
0x5357 | Suspend-scene-thread handler. |
0x7BED | poll_input_event |
0x834E | handle_study_bible_request |
0xAE42 | Load the STUFF.ART font and build glyph offsets. |
0xAF36 | Draw proportional seven-row glyphs. |
0xBE7E | Draw 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
| Index | Byte offset | DS address | Current meaning | Evidence |
|---|---|---|---|---|
| 0 | 0x00 | 727A | Difficulty: 0 Easy, 1 Normal, 2 Difficult | Difficulty input writes these values; map loading indexes END; faith damage branches on the same values. |
| 11 | 0x16 | 7290 | Current map X | Used in every 3*x cell calculation and exploration-bit update. |
| 12 | 0x18 | 7292 | Current map Y | Used in every 48*y cell calculation and as the exploration-row index. |
| 16 | 0x20 | 729A | Current map level letter | load_map_resource compares and caches its level argument here. |
| 17 | 0x22 | 729C | Current cell parameter A | process_current_map_cell copies cell byte +1 here. |
| 18 | 0x24 | 729E | Current cell parameter B | process_current_map_cell copies cell byte +2 here. |
| 21 | 0x2A | 72A4 | Faith in hundredths of a percent | Initialized 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:
| Index | DS address | Map-processing role |
|---|---|---|
| 13 | 7294 | Connected-cell low kind, or zero-mask room class 0–4. |
| 14 | 7296 | Room entrance code 0–2, or a neighboring-cell kind during hall processing. |
| 23 | 72A8 | Parameter B of a Trap room immediately right of the hall. |
| 24 | 72AA | Parameter B of a Trap room immediately left of the hall. |
| 25 | 72AC | Parameter 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.
| Opcode | Operands | Effect |
|---|---|---|
0x1E | source, destination | Copy a variable. |
0x1F | value, destination | Store an immediate value. |
0x20 / 0x21 | var, target | Jump if zero / nonzero. |
0x22 / 0x24 | left, right, target | Jump if two variables are equal / unequal. |
0x23 / 0x25 | var, value, target | Jump if a variable equals / does not equal an immediate. |
0x26 / 0x28 | left, right, target | Signed jump if left is greater than / less than right. |
0x27 / 0x29 | var, value, target | Signed jump if a variable is greater than / less than an immediate. |
0x2A / 0x2B | source-or-value, destination | Add a variable / immediate to the destination. |
0x2C / 0x2D | source-or-value, destination | Subtract a variable / immediate from the destination. |
0x2E / 0x2F | source-or-value, destination | Signed multiply the destination by a variable / immediate. |
0x30 / 0x31 | source-or-value, destination | Signed divide the destination by a variable / immediate. |
0x32 / 0x33 | var | Increment / decrement. |
0x8F / 0x90 | source-or-value, destination | Bitwise-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.
| Opcode | Operands | Effect |
|---|---|---|
0x73 | flag, target | Jump if the flag is clear. |
0x74 | flag, target | Jump if the flag is set. |
0x75 | flag | Clear the flag. |
0x76 | flag | Set 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:
| Flag | Capability |
|---|---|
0x30 | Sword |
0x31 | Shield |
0x32 | No Trap |
0x33 | Candle |
0x34 | Flight |
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:
| Flag | Meaning |
|---|---|
0x37 | Automatic Combat option is enabled. |
0x38 | An 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:
- Continue
- Load Game
- Save Game
- New Game
- the translation string indexed by word
007C - Music On or Music Off from word
0048 - Sound Effects On or Sound Effects Off from word
004A - Automatic Combat On or Off from flag
37 - 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:
| Flag | Scene / victim identifier |
|---|---|
0x3A | JELO |
0x3B | FEAR |
0x3C | CULT |
0x3D | LAW |
0x3E | RICH |
0x3F | DENY |
0x40 | NAGE |
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:
| Flag | Meaning |
|---|---|
0x54 | The one-time Annoy Cyber verse-loss event has occurred. |
The late-game programs also give exact meanings to variables 53 through 65:
| Index | Byte offset | Current meaning |
|---|---|---|
| 53 | 0x6A | Unibot turn/rotation offset. |
| 54 | 0x6C | Current Unibot node. |
| 55 | 0x6E | Heading: north 0, east 1, south 2, west 3. |
| 56–62 | 0x70..0x7C | Pylons 1–7 rescued/destroyed. |
| 63 | 0x7E | Next node selected by forward movement. |
| 64 | 0x80 | Active pylon number; 100 means none. |
| 65 | 0x82 | Tower 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:
| Flag | Conversation meaning |
|---|---|
0x14 | The player selected the expected text descriptor. |
0x15 | The 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:
| Opcode | Operands | Effect |
|---|---|---|
0x36 | selector | Set the matching record’s state byte and select it. |
0x37 | selector | Clear the matching record’s state byte. |
0x38 | selector, target | Jump if the matching state byte is set. |
0x39 | selector, target | Jump if the matching state byte is clear. |
0x88 | none | Clear 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 offset | Current name |
|---|---|
0x1191 | initialize_script_state |
0x1B86 | enter_game_over_scene |
0x3979 | reduce_faith |
0x43F5 | test_state_flag |
0x4413 | set_state_flag |
0x4433 | clear_state_flag |
0x5B24 | get_text_record_state |
0x5B76 | set_text_record_state |
0x5BBF | clear_text_record_state |
0x7B12 | handle_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:
0x10is up,0x20is down,0x40is left, and0x80is 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
0x7Bpreserves 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 only0x00,0x05,0x0A,0x0B, and0x0C, 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 kinds | Class | Scene | Map letter |
|---|---|---|---|
0x1–0x3 | Victim | Level-specific JELO, FEAR, CULT, LAW, RICH, DENY, or NAGE | V |
0x4–0x6 | Trap | ROOM1 | T |
0x7–0x9 | Prayer | ROOM2 | P |
0xA–0xC | Communications | ROOM3 | C |
0xD–0xF | Jump Tunnel | ROOM4 | J |
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 class | Room position from hall | Entrance side |
|---|---|---|
First (1, 4, 7, A, D) | Right / east | West |
Second (2, 5, 8, B, E) | Left / west | East |
Third (3, 6, 9, C, F) | Above / north | South |
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:
| Kind | Hall feature | Evidence |
|---|---|---|
0x0 | Empty connected hallway | No feature branch or parameters. |
0x1 | Macho Cyber | Selects COMBAT1, whose enemy resources are BIG*. |
0x2 | Armored Cyber | Selects COMBAT2, whose enemy resource is HELMET. |
0x3 | Mantis Cyber | Selects COMBAT3 and MANTIS*. |
0x4 | Snake Cyber | Selects COMBAT4 and SNAKE*. |
0x5 | Spider Cyber | Selects COMBAT5 and its internally named CRAB art. |
0x6 | Leech-covered Scripture station | Selects COMBAT6 and GUARD*; victory restores a station. |
0x7 | Zapper Cyber | Selects COMBAT7 and ZAP*; passing beneath it damages faith. |
0x9 | Hidden Spider trigger | A successful trigger replaces it with kind 0x5. |
0xA | Scripture station | Enables Get Verse using parameter A. |
0xB | Cleared encounter | Written by ordinary combat victories. |
0xE | Level exit | Every 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:
| Selector | Action |
|---|---|
.u, .d, .l, .r | Move Up, Down, Left, or Right |
.c | Confront Cyber |
.x | Unlock |
.v | Get 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:
| Opcode | Operands | Effect |
|---|---|---|
0x77 | none | Process the current cell, consulting adjacent cells and current state. |
0x78 | B | If the level changed, load its difficulty map, apply no-mature rewriting, and clear exploration rows. |
0x7B | H | Preserve the current high nibble and OR in a script variable’s low byte. Shipped values are all valid low-nibble kinds. |
0x7C | H | Set parameter A from a script variable. |
0x7F | H | Set parameter B from a script variable. |
0x87 | none | Normalize location cells after loading or state changes. |
0x89 | none | Mark 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:
| Coordinate | Field | Resource | Saved |
|---|---|---|---|
(2,0) | parameter B | 0x38 | 0x00 |
(0,1) | parameter A | 0x37 | 0x00 |
(1,1) | packed byte | 0xA2 | 0xAB |
(2,1) | packed byte | 0xE5 | 0xEB |
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 offset | Current name or role |
|---|---|
0x034F | load_map_resource |
0x0457 | normalize_map_cells |
0x075F | show_map_screen |
0x0C6C | process_current_map_cell |
0xED7A | 16-byte kind-to-entrance-code lookup table |
DS:5B16 | Live 768-byte grid |
DS:7290 / DS:7292 | Current X / Y coordinates |
DS:72C4 | Sixteen explored-row bitmaps |
DS:76EC | Checkpoint 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 flag | Crew flag |
|---|---|
0x3A | 0x42 |
0x3B | 0x43 |
0x3C | 0x44 |
0x3D | 0x45 |
0x3E | 0x46 |
0x3F | 0x47 |
0x40 | 0x48 |
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:
| Offset | Words | Recovered purpose |
|---|---|---|
0x1D55 | 64 | 16 nodes × four next-node indexes |
0x1DD5 | 16 | Node type: road 0, pylon 1, Tower 2 |
0x1DF5 | 16 | Per-node transition/render value |
0x1E15 | 32 | 16 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.
Navigation graph
The road network has 16 nodes. N, E, S, and W are destinations; a dash
is blocked.
| Node | Type | Map (x,y) | Transition | N | E | S | W |
|---|---|---|---|---|---|---|---|
| 0 | road/start | (270,170) | 0 | 1 | - | - | - |
| 1 | road | (270,165) | 0 | 14 | 4 | 0 | 2 |
| 2 | road | (265,165) | 0 | 9 | 1 | 3 | - |
| 3 | pylon 1 | (265,170) | 800 | 2 | - | - | - |
| 4 | road | (275,165) | 0 | 6 | 5 | - | 1 |
| 5 | pylon 2 | (280,165) | 480 | - | - | - | 4 |
| 6 | road | (275,160) | 0 | 15 | 7 | 4 | - |
| 7 | pylon 3 | (280,160) | 480 | - | - | - | 6 |
| 8 | pylon 4 | (275,150) | 160 | - | - | 15 | - |
| 9 | road | (265,160) | 0 | 12 | - | 2 | 10 |
| 10 | pylon 5 | (260,160) | -160 | - | 9 | - | - |
| 11 | pylon 6 | (260,155) | -160 | - | 12 | - | - |
| 12 | road | (265,155) | 0 | - | 13 | 9 | 11 |
| 13 | pylon 7 | (270,155) | 480 | - | - | - | 12 |
| 14 | Tower | (270,160) | 160 | - | - | 1 | - |
| 15 | road | (275,155) | 0 | 8 | - | 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
| Variable | Meaning |
|---|---|
| 53 | Turn/rotation offset used by the Unibot animation |
| 54 | Current Unibot node |
| 55 | Current heading (0=N, 1=E, 2=S, 3=W) |
| 56–62 | Pylons 1–7 rescued/destroyed |
| 63 | Next node selected by forward movement |
| 64 | Active pylon number; 100 means no unresolved pylon |
| 65 | Tower 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:
| State | FACE / CP3 behavior | Next state or scene |
|---|---|---|
| 0 | Tower threatens and cajoles; crew calls Captain Bible back. | state 1, FACE |
| 1 | Tower presses hopelessness and obedience; study prompt 0x20. | correct: state 2; wrong: state 9 |
| 2 | Captain rejects control and accepts the risk. | KABLAM |
| 9 | Captain 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:
| Extension | Members | Expanded bytes | Purpose |
|---|---|---|---|
ABT | 41 | 205,513 | Compressed unsigned eight-bit mono sound effects. |
XMI | 32 | 34,618 | IFF/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:
| Offset | Size | Interpretation |
|---|---|---|
0x00 | 2 | Decoded sample count, little endian. |
0x02 | 2 | Playback rate in hertz, little endian. |
0x04 | 1 | Samples emitted by each delta block. |
0x05 | 1 | Codec identifier. |
0x06 | 2 | Auxiliary field not used by the decoder. |
0x08 | 1 | Initial 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 byte | Encoding |
|---|---|
| Bit 7 set | Absolute sample. The output byte is the control byte shifted left once. |
| Bit 7 clear, bit 6 set | Repeat the previous sample by the low six-bit count. |
| Bits 7 and 6 clear | Adaptive 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 type | Count |
|---|---|
| Absolute sample | 71,094 |
| One-bit delta block | 2,125 |
| Two-bit delta block | 1,185 |
| Four-bit delta block | 6,533 |
| Run-length command | 1,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
0x80before 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 00ends 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 offset | Current name | Evidence |
|---|---|---|
0x4091 | play_music_resource | Builds MUS###.XMI or IBM###.XMI, loads it, and starts the music driver. |
0x4155 | release_sound_effect_buffer | Releases the retained decoded PCM allocation. |
0x417F | play_sound_effect_resource | Stops/releases the preceding sample, builds D###.ABT, decodes it, and asks DIGPAK to preformat the retained PCM state without starting it. |
0x4235 | start_prepared_sound_effect | Stops any active instance, resets completion state, and submits the retained preformatted sample for playback. |
0x92D0 | abt_get_sample_rate | Returns the little-endian word at ABT offset 2. |
0x92E0 | decode_abt | Reads the header and dispatches absolute, run, and delta commands. |
0x93BE | abt_decode_1bit_delta_block | Expands eight one-bit delta codes per byte. |
0x94CB | abt_decode_2bit_delta_block | Expands four two-bit delta codes per byte. |
0x956E | abt_decode_4bit_delta_block | Expands 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 file | Bytes | Installer source | Role |
|---|---|---|---|
SOUND.1 | 4,824 | soundrv.com | DIGPAK Sound Blaster 16 resident driver, version 3.40. |
SOUND.2 | 16,263 | midpak.adv | Miles/Creative Sound Blaster Pro FM MIDPAK hardware driver. |
SOUND.3 | 13,312 | tmidpak.com | MIDPAK loader and resident service, version 3.0 family. |
SOUND.4 | 3,622 | midpak.ad | Audio Interface Library global OPL timbre library. |
SOUND.5 | 4 | installer output | Game 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 offset | AX | Recovered wrapper | Contract |
|---|---|---|---|
0x8F08 | 0688 | digpak_play_8bit_sound | Play the DS:SI sound structure. |
0x8F28 | 0689 | digpak_report_status | Return 0 idle or 1 playing in AX. |
0x8F2E | 068A | digpak_preformat_sound | Convert the DS:SI structure for the output device. |
0x8F41 | 068B | digpak_play_preformatted_sound | Play the preformatted DS:SI structure. |
0x8F54 | 0694 | digpak_play_preformatted_data | Newer preformatted-data entry point. |
0x8F67 | 068C | digpak_report_capabilities | Return capability mask, fixed rate, and ID pointer. |
0x8F6D | 068C | digpak_get_driver_id | Copy the NUL-terminated ID returned in BX:CX. |
0x8F95 | 068D | digpak_report_current_sample_address | Report approximate playback position. |
0x8F9B | 068E | digpak_set_callback_address | Set callback BX:DX and callback DS. |
0x8FAD | 068F | digpak_stop_current_sound | Terminate current digital playback. |
0x8FB3 | 0690 | digpak_set_audio_hardware | Set IRQ, base address, and device value. |
0x8FC8 | 0691 | digpak_report_callback_address | Return callback and original caller DS. |
0x8FCE | 0689 | digpak_wait_until_idle | Poll status until AX becomes zero. |
0x8FD8 | 0695 | digpak_post_audio_pending | Start or queue a sound structure. |
0x8FEB | 0696 | digpak_get_audio_pending_status | Report playing/pending combination. |
0x8FF1 | 0697 | digpak_set_stereo_panning | Set 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 offset | AX | Recovered wrapper | Contract |
|---|---|---|---|
0x8CFC | 0701 | midpak_get_digitized_capabilities | Probe the companion digital driver. |
0x8C7A | 0702 | midpak_play_sequence | Play sequence BX. |
0x8C89 | 0703 | midpak_segue_sequence | Segue to BX using activation CX. |
0x8C9B | 0704 | midpak_register_xmidi | Register data at CX:BX, length DI:SI. |
0x8CB7 | 0705 | midpak_stop_midi | Stop MIDI playback. |
0x8CBD | 0706 | midpak_remap_channel | Obsolete sequence/channel remap. |
0x8D02 | 0707 | midpak_report_trigger_count | Return trigger count and ID. |
0x8D08 | 0708 | midpak_reset_trigger_count | Reset the trigger counter. |
0x8D0E | 0709 | midpak_sleep | Obsolete MIDI sleep. |
0x8D14 | 070A | midpak_awake | Obsolete MIDI wake. |
0x8D1A | 070B | midpak_resume | Resume playback. |
0x8D20 | 070C | midpak_get_sequence_status | Return stopped, playing, or done. |
0x8D3E | 070D | midpak_register_xmidi_file | Register the filename at CX:BX. |
0x8D26 | 070E | midpak_get_relative_volume | Return relative volume. |
0x8D2C | 070F | midpak_set_relative_volume | Set 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 offset | Size | Meaning |
|---|---|---|
+0 | 1 | MIDI patch number. |
+1 | 1 | Bank; 0x7F denotes percussion. |
+2 | 4 | Absolute 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 offset | Size | Meaning |
|---|---|---|
+0 | 2 | Record length including this word: 14. |
+2 | 1 | Signed melodic transpose, or percussion note. |
+3 | 5 | Modulator OPL registers: AVEKM, KSL/TL, attack/decay, sustain/release, waveform. |
+8 | 1 | Feedback/connection register. |
+9 | 5 | Carrier 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:
| Prefix | Translation | Command-line letter | Runtime index |
|---|---|---|---|
K | King James Version | K | 0 |
N | New International Version | N | 1 |
R | Revised Standard Version | R | 2 |
T | The Living Bible | T | 3 |
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:
| Bank | Verse records | Companion bytes | Terminal offset |
|---|---|---|---|
A | 47 | 10,065 | 0x2751 |
B | 40 | 8,253 | 0x203D |
C | 46 | 4,020 | 0x0FB4 |
D | 46 | 14,973 | 0x3A7D |
E | 42 | 10,489 | 0x28F9 |
F | 46 | 10,257 | 0x2811 |
G | 44 | 9,993 | 0x2709 |
R | 8 | 696 | 0x02B8 |
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:
| Offset | Size | Meaning |
|---|---|---|
0x00 | 1 | Nonzero selector byte used by scene logic. |
0x01 | 2 | Little-endian offset into the companion DDL file. |
0x03 | variable | NUL-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.
| Tag | Count in all eight files | Game export heading |
|---|---|---|
L | 277 | CYBER LIE: |
P | 123 | PARAPHRASE: |
W | 210 | WRONG GUESS: |
C | 67 | CORRECT GUESS: |
E | 68 | EXPLANATION OF CORRECT GUESS: |
* | 58 | CONVERSATION WITH VICTIM: |
M | 255 | Numeric 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 offset | Current name | Role |
|---|---|---|
0x5AD6 | find_text_record_by_selector | Linearly find the runtime descriptor with a requested selector. |
0x5CE2 | copy_text_record_component | Return the verse or a selected tag occurrence from its companion span. |
0x5EE7 | write_wrapped_export_text | Wrap study text at 70 columns and write it to the export stream. |
0x5F92 | export_game_text | Apply the -gXX mask and emit the complete study file. |
0x629C | load_text_bank | Load 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.
| Use | Suffix | Example with default prefix |
|---|---|---|
| Slot-label index | .SV0 | DDGAMES.SV0 |
| Normal slots | .SV1 through .SV9 | DDGAMES.SV4 |
| Quick save | .SVQ | DDGAMES.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 offset | Size | DS offset | Interpretation |
|---|---|---|---|
0x000 | 200 | 7BF2 | Checkpoint copy of 100 script-variable words |
0x0C8 | 200 | 727A | Live 100-word script state |
0x190 | 66 | 3A66 | Checkpoint copy of descriptor state bytes |
0x1D2 | 660 | B194 | 66 live ten-byte text descriptors |
0x466 | 20 | 6EA6 | Checkpoint resource-name C-string buffer |
0x47A | 20 | B83E | Live resource-name C-string buffer |
0x48E | 20 | 8938 | Checkpoint extension C-string buffer |
0x4A2 | 20 | AEFE | Live extension C-string buffer |
0x4B6 | 2 | 007C | Bible translation index |
0x4B8 | 2 | 0048 | Music enabled flag |
0x4BA | 2 | 004A | Sound-effects enabled flag |
0x4BC | 2 | 9FB0 | Checkpoint text-bank character |
0x4BE | 2 | 0080 | Live text-bank character |
0x4C0 | 768 | 5B16 | Live 16×16×3-byte world map |
0x7C0 | 768 | 76EC | Checkpoint copy of the world map |
| Total | 2,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 offset | Size | Meaning |
|---|---|---|
+0 | 2 | Far-pointer offset |
+2 | 2 | Far-pointer segment |
+4 | 1 | Persistent gameplay state byte |
+5 | 1 | Text-record selector |
+6 | 2 | Offset in the companion DDL stream |
+8 | 2 | Span 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:
SV6andSV8are 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
SV3andSV4, which each have 112 nonzero bytes. Those two grids matchCE.MAPexcept 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, andseg. 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
Cin every file. - Each primary block pair differs at byte 56;
SV9additionally 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 offset | Current name | Role |
|---|---|---|
0x2B6F | choose_save_slot | Build the nine-label menu and change .SV0 to the chosen slot suffix. |
0x7D8E | copy_live_state_to_save_buffers | Refresh checkpoint fields from live state. |
0x7E41 | copy_save_buffers_to_live_state | Restore checkpoint fields and reload text. |
0x7F01 | write_save_index | Write nine 27-byte label buffers. |
0x7F58 | read_save_index | Read the index or initialize (EMPTY) labels. |
0x7FD7 | write_save_state | Write the index and the 15 state blocks. |
0x815A | initialize_empty_save_slot | Replace an empty label with the Game 1 through Game 9 default. |
0x81A5 | save_selected_slot | Initialize the selected label, then write the state. |
0x81AC | read_save_state | Read 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
0x06is consumed but unused by the game decoder. Its values are known across all resources; its producer-side meaning is not. - The opcode-
0x0216-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
POWERre-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:
DDLAthroughDDLGandDDLRare tagged companion text streams joined to extensionless verse indexes insideDD1.DAT.POWER.BINis the in-combat study interface, not a game-over resource.CP2.BINhas a 256-byte 16-node Unibot graph trailer beginning at0x1D55.- Flags
0x42..0x48are rescued crew present for the Unibot mission, and variables 56 through 62 are the seven energy-pylon results. - All 34 game-side interrupt-
66hsites are assigned DIGPAK/MIDPAK services, andSOUND.4is 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
0x8Dconstructs 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 extensionlessDDL*files, ten initialDDGAMES.SV*save files,SOUND.1throughSOUND.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.DATis approximately 1.8 MB, the sound-driver directory is approximately 672 KB, andCB.EXEis approximately 64 KB.- The host
fileutility recognizesCB.EXEas an MS-DOS executable andCB.PIFas a Windows Program Information File. - The manual says the normal DOS launch is
CBfrom the installed directory and claims a conventional-memory requirement of approximately 500 KiB. SETSOUND.BATruns separate digital-audio and MIDI configuration programs, then copies the selected driver artifacts toSOUND.1throughSOUND.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, andurl.txt; the ignoredCB/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:
.gitignorealready excludes/CB/and/build/, which is suitable for keeping original game files, downloaded media, VM disks, and book output out of version control.url.txtpoints 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
.downloadpath 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.
shellcheckis not installed, so that optional check was skipped.- mdBook generated
build/docs-book/index.htmlsuccessfully. - 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.EXEis 64,299 bytes with SHA-2562b7726ae9cf56e0067533e4bd1c5c76685f1d9855a7d90835850388db7b07ee0.- Its timestamp is 1996-12-24 23:32 in the host’s
+0700zone. - The file is a 16-bit MZ executable with a 512-byte header, no outer
relocations, and entry
0F79:0010at file offset0xF9A0. - 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,
R6000run-time errors, andPacked 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:
maindirectly implements-t,-bX,-c,-idirectory,-sXfilename,-gXX, and the non-option per-player save prefix.0x3363checks VGA, readsSOUND.5, conditionally loadsSOUND.1throughSOUND.4, opensDD1.DAT, and initializes hardware/data subsystems.0x5F92is the ASCII export routine. Its bit tests match the six documented-gXXcategories.0x7BEDmerges keyboard, mouse motion, mouse-button, and UI-hit events.0x8E0Adetects the mouse throughint 33h;0x8D79updates buttons and clamps coordinates to 320×200.0x7F58reads a 243-byte.SV0index consisting of nine 27-byte labels.0x7FD7and0x81ACwrite and read a fixed 2,752-byte state in 15 blocks. Every supplied.SV1through.SV9file has that exact size.- The quick-save path changes the mutable suffix from
0toQ, identifying.SVQas 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.shstill 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.BINbegins at physical address0x4C130, the linear address of4C13:0000. - The base pointer bytes
00 00 13 4Coccur at physical0x14F0A, which is the data-segment state atDS:00FA. - Bytes at
DS:00F6..00FDare4B 00 13 4C 00 00 13 4C. They encode current cursor4C13:004Band resource base4C13:0000. INTRO.BIN[0x4B]is opcode0x42, at a valid command boundary immediately after thereturn_minus_onecommand ending at file offset0x4B.
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 initial0xFFinstead introduces an explicit 16-bit offset.0x451B, the main interpreter. It installsbase + argumentas the cursor, reads an opcode, subtracts one, checks the range through0x91, and uses a 145-entry near-pointer table at0x59AB.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 to0x451B.
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 lowercasez. - The conditional instruction layout was temporarily written as
Bhs; the lowercasehwas not a valid word marker. Corrected opcodes0x11,0x12, and0x17through0x1AtoBHs. - 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.BINregion test assumed its second code block continued to EOF. It stopped at another invalid zero block at0x1754; inspection found a 20-byte reserved region followed by a third command entry point at0x1768.
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.BINpalette, 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.BINcode 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, orK, and their payloads visibly start with verse references followed by|-delimited prose. - All 21
MAPmembers are 768 bytes. - The 32
XMImembers start withFORM,XDIR, andINFOIFF identifiers. There are 16MUS###and 16IBM###resources. - The 41
ABTmembers 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
0x57reads a byte and word and calls0x417F. 0x417Fwrites the effect number intoD000.ABT, stops and releases the previous effect, loads the archive member, reads its first word as the decoded allocation size, calls far routine0x92E0, constructs the DIGPAK playback state, and preformats it through the interrupt66hwrapper at0x8F2Ewithout starting playback.0x4235stops an active instance, resets completion state, and starts the retained preformatted effect.0x92D0returns the word at resource offset 2.0x92E0is the complete ABT decoder, with packed-delta helpers at0x93BE,0x94CB, and0x956E.
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.
2026-07-15: Annotated full-screen artwork gallery
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
Reproducible gallery tool
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.
Gallery verification
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.txtbuild/analysis/game-main-save-pass.txtbuild/analysis/save-field-xrefs.txtbuild/analysis/save-name-control-flow.txtbuild/analysis/save-menu-flow.txtbuild/analysis/save-main-loop.txtbuild/analysis/save-snapshot-callers.txtbuild/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:
0x77processes the current map cell and consults adjacent cells.0x78 Bloads the selected level and current difficulty map.0x7B Hreplaces the current cell’s low kind nibble while preserving its high nibble.0x7C Hand0x7F Hwrite the cell’s second and third bytes.0x87normalizes loaded or changed map cells.0x88clears persistent byte+4in all 66 text descriptors.0x89setsexplored_rows[y] |= 1 << xin the 16-word array atDS: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 (
0easy,1normal,2difficult); - 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:
0x30sword,0x31shield,0x32no-trap protection;0x33candle and0x34flight.
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
0x02creates a command-thread record of type0x02; - opcode
0x03creates a direct record of type0x03with scale0x0100; - opcodes
0x04and0x43create direct records of type0x43with an explicit scale; - opcode
0x06starts animation state and creates a type-0x06record.
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, and0x09start, start-linked, and stop animations;0x3Fwaits for an active animation and0x80branches on its state;0x41and0x42enable and disable selectable-action input;0x3Estarts a true BIN scheduler slot at an absolute target and0x61stops a slot;0x59waits for digital playback, using a simulated timer without a digital driver;0x60is a no-op which shares the main interpreter continuation;0x82stores 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:
| Program | ATTACK | DEFEND | RETREAT | COMBAT |
|---|---|---|---|---|
COMBAT1 | 0x0BF3 | 0x0BCF | 0x0C38 | 0x0B30 |
COMBAT2 | 0x186F | 0x184F | 0x1BAC | 0x1B07 |
COMBAT3 | 0x0BB6 | 0x0B90 | 0x10C3 | 0x1031 |
COMBAT4 | 0x17D5 | 0x17AF | 0x1C9D | 0x1C18 |
COMBAT5 | 0x15D2 | 0x15B2 | 0x18F7 | 0x1839 |
COMBAT6 | 0x07E8 | none | 0x087B | 0x0827 |
COMBAT7 | 0x0EC8 | 0x0EAB | 0x1053 | 0x0FA7 |
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; helper0xB5A8advances 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: calls0x1B6Cwith 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 flags0x23through0x27.
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
0xBfor programs 1–5 and 7 and0xAfor program 6; - the presence of both set and clear operations for flag
0x38except 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:
| Kinds | Class | Dispatched scene and resource evidence |
|---|---|---|
1–3 | Victim | Per-level JELO through NAGE scene |
4–6 | Trap | ROOM1; TRAP, TRAP2, and TRAP3 |
7–9 | Prayer | ROOM2; PRAY |
A–C | Communications | ROOM3; COMM, COMM2, and FACE1 |
D–F | Jump Tunnel | ROOM4; 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,
1–7, 9, A, C–F. 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
DDLCand the otherDDL*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:
ROOM1andROOM2need variable 16 to contain a valid map-level letter before their authoredCHALstring patch; setting it toCproduced the expected transition.ROOM3’s random face-selection loop assumes at least one of flags42..48is 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:
0xE957replaces the seed argument load with the requested immediate state;0xE966replaces the old 38-byte generator and pads the unused tail;0x5905removes opcode8E’s unchecked address calculation and calls the new generator once; and0x5971does the same for opcode91.
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.
Navigation-handler reset preserves paths
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.