DARKER Research archive

Executable unpacking and the first internal map

2026-09-18. This continues—and resolves the startup uncertainty in—the first-pass report.

Result

The supplied X.EXE can now be unpacked statically with Python alone. The recovered initialized image is exactly 65,536 bytes. It matches an independent DOSBox memory capture byte for byte, before runtime relocation. The protected loader's depacker and relocation table are also recovered from the original file, rather than supplied as guessed constants or copied from the memory dump.

python3 tools/unpack_exe.py
python3 tools/extract_exe_tables.py
python3 -m unittest discover -s tests -v

Outputs in analysis/unpacked/:

File Purpose
image.bin The canonical 64 KiB, unrelocated image for static analysis
UNPACKED.EXE A reconstructed DOS MZ executable with a new 96-byte header
depacker.bin Recovered high-memory depacker and relocation metadata; prefix through 0x13B is zero padding for addressing
decoded-loader.bin Loader bytes after the transformations necessary for extraction; not a full runtime snapshot of all transient writes
strings.txt Printable ASCII runs with image offsets; non-ASCII text needs separate decoding
manifest.json Hashes, entry/stack addresses, all relocations and token counts

Canonical image SHA-256:

7599201a01aa24b7e6ad1ae4295d493821cde7c3a43b247c464522626c0380a4

The tool deliberately accepts only the known input SHA-256. Its fixed loader offsets must not silently be applied to another edition. The reconstructed EXE is 65,632 bytes; its header is newly authored, not a recovery of the exact header used before the original packing step.

Why the entry looked nonsensical

The startup actively uses the CPU's trap flag and interrupt 1 as a control-flow mechanism. It installs this five-byte handler at packed load-image offset 0x69:

pop  bp         ; remove the saved return IP from the interrupt frame
push si         ; substitute the other instruction stream's next IP
xchg bp, si     ; remember the interrupted stream's next IP in SI
iret

With TF set, execution traps after individual instructions. The handler alternates the instruction pointer with SI, interleaving two instruction streams. Branches can enter what a linear disassembler considers the middle of another instruction. Several apparent calls, returns and nonsensical branches participate in this mechanism rather than behaving like conventional subroutine boundaries.

This explains the initial Unicorn experiment's failure: it stopped at INT 1 and did not deliver the installed interrupt handler. It was not evidence of a broken executable. In DOSBox, one uninterrupted trace follows the mechanism through to the depacker. Stopping/restarting logging while this TF machinery is active produced a divergent, unsuccessful trace, so that interrupted run is not used as an extraction oracle.

The loader subsequently clears TF and restores the original interrupt-1 vector. “Protection/obfuscation” describes its observed structure; we have not identified a commercial protection product or established the author's intent. The outer header's D1 marker remains unnamed.

Reducing the loader to deterministic transformations

All offsets in this subsection are relative to the packed load image, so add 0x20 to locate them in the original file.

  1. Increment byte 0x9D; it changes from FF to 00.
  2. Rotate each little-endian 16-bit word at 0x5D, 0x5F, …, 0x6B left by one bit, revealing the vector-install code and trap handler.
  3. XOR bytes 0x1A..0x53 with FF.
  4. For source positions descending from 0x28D through 0xA7, XOR the byte fourteen (0x0E) positions ahead with the source byte. This reveals the rest of the loader, depacker and relocation metadata. Order matters.
  5. Reverse the word order of 0x13C..0x29B. The original does this with a LODSW/PUSH AX loop into its high-memory stack segment. This places the recovered depacker at offsets 0x13C..0x29B in that segment.

Other transient self-writes and register/vector changes are unnecessary for a static extractor. The resulting depacker region matches the captured DOSBox bytes exactly.

For the recorded launch, the loaded image segment was 01ED, the original PSP was 01DD, and the relocated depacker segment was 134E—the load segment plus the MZ header's initial SS value, 1161. These absolute segments are environment-dependent, not portable executable addresses.

Compression and byte encoding

The compressed game stream begins at original file offset 0x2BC, occupies 52,236 bytes, and ends exactly at EOF, 0xCEC8. Its decoder is LZ-style, with back-references into previously produced output and additional XOR encoding of literals. We have not assigned it a known packer name.

The bit reader consumes 16-bit little-endian words, most-significant bit first, interleaved with literal and distance bytes. The original uses a sentinel bit and initializes DX to 0x4000; it enters the literal path before the first bit test.

Three types of output are emitted:

A distinguished length token with zero extension terminates the stream. The recovered specimen contains:

Output source Count Output bytes
Byte literals 18,669 18,669
Word runs 838 runs, 8,778 words 17,556
Back-references 7,460 29,311
Total 65,536

The decoder consumes every input byte through the end marker and rejects truncation, out-of-range references, oversized output and the wrong specimen.

Hidden relocation table and recovered entry

The outer DOS header says zero relocations, but the depacker applies 14 itself. Its table begins at relocated depacker offset 0x276, using groups of (segment, count, offsets...). A zero-count group supplies the original stack segment, followed by SP.

All 14 entries in this specimen belong to image segment zero:

00E7 0AD3 3B31 3F53 42D7 4337 B00B BC73 C08C
FDEC FDEE FDF0 FDF2 FFEC

Recovered initial state:

CS:IP = load segment + 0000:44F4
SS:SP = load segment + 2AA0:FFFE
DS=ES = PSP segment

The depacker's final far jump is at its offset 0x156. Before it, decompression finishes at 0x248; these are useful breakpoint locations for independent verification.

The entry at image 0x44F4 scans the DOS environment for the program's pathname. It returns to image offset zero, which copies the pathname over the startup routine's storage and parses the command tail. Self-modification therefore continues inside the unpacked game; removing the packer does not eliminate all mutable instruction bytes.

The reconstructed MZ uses the recovered 14 DOS relocation entries and preserves the original total minimum allocation. This is a 64 KiB initialized image, not a claim that the entire game, work buffers or external resources fit in 64 KiB.

Initial internal map

Addresses below are offsets in unrelocated image.bin, not X.EXE.

Offset / range Evidence-backed role
44F4..452F Initial environment/path scan, then return into offset zero
0000..00AD Command-tail parser and argument dispatch
025D..0290 Thirteen encoded two-letter command keys and handler pointers
00BA..00CC CPU-sensitive copy-routine patch
00FA..0136 EMS signature check and INT 67h calls
013A..0171 XMS detection via INT 2Fh, entry lookup and calls
0318..0390 Save-file opening, reading and validation/alternate dispatch
1680 Keyboard interrupt handler, including reads from port 60h
41D9..41F0 DOS file-call wrapper; first observed open uses INT 21h at 41E3
944A..9C49 1,024 signed 16-bit sine values
9D81 onward Credits in English, French and German
A24C Copy sequence patched from word to doubleword transfers
E4xx..EAxx Multiple-language save/menu text

The startup reaches EMS/XMS and BIOS/DOS interfaces directly. The recorded run also configures a roughly 500 Hz PIT timer, enters BIOS video mode 13h and then reprograms VGA timing to 320×240. These are observed initialization details, not a complete renderer or audio-driver analysis.

No compiler, assembler or linker was identified in this executable pass. The later archive investigation identifies Sound Images Generation 2 audio drivers in resources 00/33–37. The tight register-level code, data interleaving and runtime instruction patches are consistent with substantial assembly work, but do not identify MASM, TASM, or exclude a high-level language elsewhere. Nor does SGDT alone imply a protected-mode DOS extender.

Discoveries worth following

A CPU-specific optimization

At 00BC the program executes SGDT, increments byte five of the result, and conditionally installs five bytes from 0258 at A24C. Intel's 386 manual documents the upper-byte difference used to distinguish the 286 form from the 386 form of this instruction (SGDT reference).

The before/after copy sequences are:

; Original bytes: D1 E9 F3 A5 B0 A5
shr cx, 1
rep movsw
mov al, 0A5h

; Captured after the CPU-dependent patch: C1 E9 02 F3 66 A5
shr cx, 2
rep movsd

The patched sequence is present in the captured runtime image at the first save-file open. This establishes a word/doubleword optimization path; it does not establish that the complete game is playable on a 286.

Trigonometry with a one-unit asymmetry

analysis/tables/sine.csv exports the exact 1,024 entries. At index i the values match trunc(32767 * sin(2*pi*i/1024)), except at index 256:

quarter-turn: stored +32766, reference +32767
three-quarter-turn: stored -32767

The table spans one complete turn and has many code references. The cause of the exceptional positive peak is unknown; do not silently “correct” it when building a faithful implementation.

Command parsing does not need full option names

The parser folds letters to alphabet indices and dispatches using the first two letters; it consumes further alphabetic letters without including them in the key. Thus the absence of plaintext Lang and Mus in strings searches was not merely due to packing.

The keys are AF BF BR CL EY IN LA LI LO MS MU RA SE. Their handler offsets are exported in analysis/tables/tables.json. LA accepts 0–2; MU accepts 0–5. The supplied batch file establishes the longer names Lang and Mus, but names and meanings for the other keys are not inferred from two letters alone.

Follow-up: command-line options now reconciles all 13 prefixes with manual pages 20–22 and original-parser probes, including the EY display selector and discrepancies in the documented fire-remapping arguments.

Embedded menu-like records

Resolved in the hidden-command investigation: these are exact command phrases accepted after *3 on the game-selection screen, using a hidden input mode that shares the save-name editor.

Records around 04E1 contain:

Each record stores text length plus one, the text, and a little-endian handler pointer (0537, 053E, 0543, 0549). A final byte 01 ends the table. The initial “Level XI” reading included byte 49 from the final pointer as an ASCII I; the actual phrase is seven characters, “Level X”.

“STAR THREE” and “What do you want?” belong to the hidden prompt. The newer report traces activation, exact matching, code patches, and the associated Z/X gameplay shortcuts, with original-machine-code execution checks.

An unusual save-loading branch

The normal startup requests 6,600 bytes from DARKER.SAV. If the returned size does not match, an alternate path at 0349 checks a 44FFh word and the actual length in the next word. If those checks pass, it rotates bytes and dispatches through a far return to the loaded buffer at SS:0FB4. This is code-capable loading, not just an ordinary save-record parser. It might support an alternate bootstrap or development facility; its purpose and input format remain open. The normal save file and this alternate path have not been decoded here.

Validation and its limits

Independent DOSBox captures and their addresses are recorded in analysis/startup/provenance.json. The supplied static decoder does not read those captures. Tests compare its output with them, and additionally compare the recovered depacker and simulated DOS relocation of the reconstructed MZ.

Both packed and reconstructed executables were run as X.EXE Lang=0 Mus=1 in separate copies of the installation. At their first open of C:\DARKER.SAV, all recorded CPU registers and flags match, at 01ED:41E3. Both also progressed to 320×240 VGA initialization. Later headless debugging sessions ended unexpectedly; a full playthrough of the reconstructed EXE has not been validated. The binary extraction result is independently supported by the exact image/depacker/relocation comparisons.

To regenerate capture evidence with a DOSBox 0.74-3 heavy-debug binary:

python3 tools/capture_unpacking.py --dosbox-debug /path/to/dosbox-debug

This optional capture tool requires pexpect. The static decoder and its tests use the standard library only. The capture tool mounts a temporary copy of the installation. On Debian the debugger was obtained by downloading and extracting the dosbox-debug package into /tmp, without a system installation.

Next targets

The immediate useful next pass is to name reachable routines around DOS file handling, resource indexing and the menu/text interpreter. The 64 KiB image is now suitable for a real-mode disassembler, with 0000:44F4 as the original entry and the recovered relocations supplied by UNPACKED.EXE. Mutable code and overlapping instruction streams still require attention.

The subsequent resource and city investigation follows this loader to decode all 164 archive resources and recover the city grids and geometry-bank layout.

Follow-up: the normal save layout and checksum are now verified against native reader/writer execution. The alternate bootstrap-like branch remains unexplained and is not executed by the save editor.