Devlog #012 - Catching the Party Writer in the Act
Published April 10, 2026
The last few sessions have been some of the most technically dense of the project so far. The headline is straightforward: we now know the exact machine instruction that adds a character to the active party. The path to get there was not.
The hypothesis that was wrong
game_main_loop scans the hot-slot array (five slots at 0x9C00, one per party member, stride 0x80) and checks the status byte at +0x69 in each slot. When that byte is 0x03, it calls
rtlink_load_char. The assumption going into this sprint was that catching a slot byte flip to
0x03 would lead directly to party_add_member.
Multiple guided DOSBox-X sessions later, the hypothesis was dead.
Over five or six live passes (world map, city navigation, tavern menus, character-sheet opens, save-state loads), the hot-slot status bytes were always 01 01 01 01 00 in steady state. No
slot ever reached 0x03. The static model of the loader path was correct, but the specific
gameplay paths being tested never triggered it. Either 0x03 is a rarer edge case, or the write
happens so transiently it’s invisible to the workflow we were using.
Chasing it further with the same tools would have been diminishing returns. The strategy had to change.
Spice86 joins the toolchain
Devlog 011 mentioned that a second emulator backend was under evaluation. That evaluation concluded: Spice86 is now a first-class runtime backend for this project.
Spice86 is the same emulator at the heart of the planned Phase 3 rewrite, the one that will eventually run Darklands’s own assembly alongside C# override functions. But it turns out to be immediately useful for Phase 2 as well, because it exposes something DOSBox-X does not: memory write breakpoints.
With DOSBox-X, the workflow was: set an execution breakpoint at a suspected function address, navigate the game to a point where that function should run, wait for a hit. That works for code you already know about. It fails when you are trying to find code you have never seen, whose address you do not know, but whose effect you can observe.
A memory write breakpoint is different. You tell the emulator: “pause execution the moment anything writes to this address.” You do not need to know where the write comes from. You just need to know what address gets written.
We knew exactly what address gets written: DS:0x9C69, the hot-slot status byte for slot 0.
Linear address 0x251353 in this run. One probe, and we would catch whatever code writes there,
wherever it lived.
Caught
The Create a New World -> character editor -> Add to the Party path was the most controlled
test: a clean game state, a deliberate action, a predictable result.
On the first Add to the Party, Spice86 halted immediately.
The hot-slot bytes before the action were 00 00 00 00 00. After the halt, slot 0 was 01.
The write was direct. There was no intermediate 0x03 state; the transition was 00 → 01 in
one instruction, not 00 → 03 → loader → 01. The deferred-load hypothesis was simply wrong for
this path.
The halted instruction:
15DF:0D59 mov bx, [bp-0x0e]
15DF:0D5C shl bx, 7
15DF:0D5F mov byte ptr [bx + 0x9c69], 0x01Slot index from a frame local, shifted left by 7 (which is × 0x80, the hot-slot stride),
added to the base of the status byte array. Clean. Exactly what the data model predicted. The
containing overlay segment was 15DF.
The overlay structure
15DF is a runtime overlay, not mapped in Ghidra’s static image. But halted on the writer,
Spice86 could dump it: the full region 15DF:0000-0EFF, readable as hex from live memory.
That dump revealed the surrounding structure. Working backward from the writer at 15DF:0D5F:
15DF:0329: the far-entry gate. This is a boolean wrapper at the tail of an existing far
frame. It compares AX with 0x011B, converts the result into a 0/1 stack argument for the
function below, and jumps back toward 0x005B on failure. On the matching path it does PUSH CS / CALL near 0x0348. It is a dispatcher, not the body of party_add_member itself.
15DF:0348: the inner worker. The near-call target. The bytes at this address show a
normal function prologue and immediate setup work. This is the best current candidate for the
actual add-to-party implementation.
15DF:0D59–0D5F: the writer/finalization block. Below the worker. The slot-status write
happens here, followed by a sequence of far-calls to per-slot callback routines at 0B3D:2049,
017D:1588, 017D:1716, and 081E:1DE2.
One path that looked plausible, 15DF:0B48, turned out to be a decode mistake. The call
instruction at 15DF:033D is E8 08 00, a near call with a signed 16-bit displacement of +8.
Target: 15DF:0348. Not 0B48. The arithmetic matters when you are reading raw bytes.
What changed
Before this sprint: we had a named function placeholder (party_add_member) pointing nowhere
confirmed, and a hypothesis about a deferred-load path that turned out to be wrong.
After this sprint:
- The
0x03deferred-load hypothesis is retired for theAdd to the Partypath - The exact writer instruction is
15DF:0D5F: mov byte ptr [bx + 0x9c69], 0x01 - The owning overlay context entry is
0B3D:06FD - The call chain is: gate at
15DF:0329→ inner worker at15DF:0348→ writer at15DF:0D5F - The overlay bytes
15DF:0000-0EFFare in hand, making future static reconstruction possible without needing the overlay to appear in Ghidra’s static image
The next step is to recover the complete containing routine rooted at 15DF:0348 (function boundaries, full control flow, any further downstream calls) so the implementation
can be translated cleanly in Phase 3.
A note on the tooling shift
The move to Spice86 as the active runtime backend for memory-probe work is worth naming explicitly.
DOSBox-X will remain useful for execution-breakpoint sessions, especially where the game’s graphical navigation matters and operator involvement is high. But for questions that come down to “what writes this memory address and from where,” Spice86’s write-probe capability is now the right tool.
That also means the breakpoint vocabulary is different. Addresses confirmed in DOSBox-X sessions do not automatically transfer to Spice86; runtime segment assignments can differ between environments. Going forward, runtime findings from each backend will be tagged accordingly and mapped across only when a live session confirms the equivalence.
The short version
- Chased hot-slot state
0x03through five DOSBox-X sessions; never appeared on the tested paths - Spice86 added to the toolchain; MEMORY_WRITE breakpoints on
DS:0x9C69immediately trapped the party writer - Write is direct
00 → 01, not via deferred-load state03; the old hypothesis was wrong - Exact writer:
15DF:0D5F: mov byte ptr [bx + 0x9c69], 0x01 - Overlay dump
15DF:0000-0EFFrecovered from live memory; bytes in hand for static reconstruction - Call chain confirmed: gate (
15DF:0329) → inner worker (15DF:0348) → writer (15DF:0D5F) - Next: recover the full routine around
15DF:0348and nameparty_add_memberdefinitively