# Executable unpacking and the first internal map

2026-09-18. This continues—and resolves the startup uncertainty in—the
[first-pass report](executable-first-pass.md).

## 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.

```sh
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:

```text
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`:

```asm
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:

- **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 emit `2n + 6` words.
  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:

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

Recovered initial state:

```text
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](resources-and-city.md) 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](https://people.freebsd.org/~jhb/386htm/SGDT.htm)).

The before/after copy sequences are:

```asm
; 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:

```text
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](command-line-options.md) 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](hidden-commands.md):** 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:

```sh
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](resources-and-city.md) 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](save-file-format.md) are now verified against native reader/writer execution. The alternate bootstrap-like branch remains unexplained and is not executed by the save editor.
