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.
- Increment byte
0x9D; it changes fromFFto00. - Rotate each little-endian 16-bit word at
0x5D, 0x5F, …, 0x6Bleft by one bit, revealing the vector-install code and trap handler. - XOR bytes
0x1A..0x53withFF. - For source positions descending from
0x28Dthrough0xA7, XOR the byte fourteen (0x0E) positions ahead with the source byte. This reveals the rest of the loader, depacker and relocation metadata. Order matters. - Reverse the word order of
0x13C..0x29B. The original does this with aLODSW/PUSH AXloop into its high-memory stack segment. This places the recovered depacker at offsets0x13C..0x29Bin 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:
- Single-byte literals: decrement a 16-bit key, XOR the input byte with its low byte, and emit the result. The initial key is zero.
- Literal-word runs: decode a four-bit count
n, then emit2n + 6words. Each input word is XORed with the current key; the resulting word becomes the new key. These are 6–36 words per run. - Back-references: copy from already emitted bytes. Distance is the encoded value plus one. Overlap is intentional, so repeating patterns require a forward byte-by-byte copy. Back-references do not update the literal key.
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:
- “The Jason Brooke special”
- “Lyndon's little snooze”
- “Who gives true life?”
- “Level X”
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.