Crusader_Decomp/docs/retail-debugger-patch-attempts.md
2026-04-05 09:55:21 +02:00

33 KiB

Retail Debugger Patch Attempts

This document is the running log for all executable-patching attempts to surface the hidden retail usecode debugger in CRUSADER.EXE.

Purpose:

  • preserve negative evidence
  • record byte-level patch shapes and the reasoning behind them
  • avoid re-testing structurally broken ideas
  • keep runtime outcomes tied to the exact patch generation that produced them

Ground Rules

  • Active live target is retail CRUSADER.EXE.
  • Mechanical validation means the PowerShell patcher can apply -> verify patched -> restore -> verify original on a fresh retail copy.
  • Runtime validation means the user tested the patch in DOSBox against the real game.
  • A mechanically clean patch is not considered viable until runtime behavior is also safe.

Stable Facts

Fact Evidence
13a0:0086 is usecode_debugger_open_for_current_unit Live Ghidra analysis and surrounding UI/control-flow evidence
1408:0000 constructs the seg1408 debugger break-state object Constructor writes vtable at object +0, clears fields, returns far pointer
Global debugger-state pointer lives at 1478:659c/659e Interpreter and seg13a0 UI both read it directly
Object flags +0x74 and +0x75 are break-next and single-step controls 13a0:1e5d and 13a0:1e37
Constructor writes 0x65ab to object +0 1408:0024..0028
1478:65ab is method 0 and 1478:65af is method 1 of the same vtable CALLF [BX] and CALLF [BX+4] dispatch paths

Minimum Viable Patch Floor (2026-04-03)

Fresh live-Ghidra re-checks tighten the lower bound on what an executable-side debugger-unlock patch must do.

What the live binary still proves:

  • 1408:0000 is only a constructor. It allocates/initializes the seg1408 debugger object, seeds object +0 with 0x65ab, and returns the far pointer in DX:AX; it does not store that pointer into 1478:659c/659e.
  • current instruction searches still show reads of 1478:659c/659e in seg13a0 and seg1418, but no recovered retail writer that seeds those globals before the interpreter hook runs.
  • raw debugger-adjacent data at 1478:65ab/65af still resolves to the two inert retail callbacks 1408:046f and 1408:0474; there is no hidden already-live UI-opening target sitting behind the stock vtable.
  • the interpreter-side pre-call guard at 1418:049e..04b5 only checks whether 1478:659c/659e is non-null before calling 1408:0053. The one-shot breakpoint/step gating lives inside 1408:0053, not at the callsite itself.
  • both UI wrappers still inherit caller words at 13a0:008f / 13a0:024a, so any deferred entry that reuses those wrappers from a non-native caller still needs wrapper-argument sanitization.

That rules out the common "there must be one tiny direct jump" theories:

  • a constructor-only retarget is insufficient because the returned far pointer still has to be stored into 1478:659c/659e somewhere.
  • a vtable-dword-only patch is insufficient because the retail callbacks at 1478:65ab/65af are inert shared stubs, and the previously tested shared-slot rewrites already crashed at startup.
  • a direct 1418:04b5 -> 13a0:020d/0086 retarget is insufficient because once 1478:659c/659e becomes non-null, that callsite would fire on every eligible interpreter pass unless a private one-shot stub preserves the original 1408:0053 gating semantics.

Current best conclusion:

  • the smallest structurally defensible patch family is still the current interpreter-callsite-retarget design (Candidates O/P): one embedded 13e8:230d..232d body that lazily creates/stores/arms the debugger object, one retarget of 1418:04b5 into the private stub, and one wrapper-argument sanitization site (13a0:024a or 13a0:008f).
  • anything smaller than that is currently missing at least one required behavior: object creation, global pointer seeding, one-shot deferred gating, or safe wrapper arguments.

Practical Candidate Byte Maps

These are the current live-candidate edits in practical patching terms.

Important NE note:

  • for internal far calls, the on-disk opcode bytes stay as placeholder FF FF 00 00
  • the real old/new target change is in the NE fixup entry, not in the immediate bytes themselves
  • so for those sites below, raw bytes may be unchanged while the fixup target changes

Common edits shared by Candidate O and Candidate P

File offset Live address What changes Old value New value
0x0C970D 13e8:230d Replace the retail 0x410 body with the corrected private bootstrap/stub body A0 4F 60 B4 00 F7 D8 1B C0 40 A2 4F 60 80 3E 4F 60 00 74 47 6A FF 6A FF C4 1E D0 4C 26 8A 47 05 50 1E 68 D2 60 6A 00 6A 00 83 EC 06 C7 86 76 FF 00 00 8B 86 76 FF F7 D0 89 86 78 FF C6 86 7A FF 00 6A 00 6A 00 9A FF FF 00 00 83 C4 14 52 50 9A FF FF 00 00 83 C4 08 5F 5E C9 CB 6A FF 6A FF C4 1E D0 4C 26 8A 47 05 50 1E 68 EE 60 6A 00 6A 00 83 EC 06 C7 A1 9C 65 0B 06 9E 65 74 10 C4 1E 9C 65 C6 47 75 00 C6 47 74 01 5F 5E C9 CB 6A 00 6A 00 E9 25 00 55 8B EC A1 9C 65 39 46 06 75 16 A1 9E 65 39 46 08 75 0E C4 5E 06 C7 47 74 00 00 6A 00 6A 00 EB 0E 5D CB 90 90 9A FF FF 00 00 83 C4 04 EB 0A 9A FF FF 00 00 83 C4 04 5D CB 0B C2 74 13 A3 9C 65 89 16 9E 65 89 C3 8E C2 C6 47 75 00 C6 47 74 01 5F 5E C9 CB
0x0C9753 13e8:2352 First reused far-call fixup inside the patched 0x410 body fixup target 1350:0046 fixup target 1408:0000
0x0CFAB5 1418:04b5 Interpreter debugger callsite retarget raw bytes 9A FF FF 00 00; fixup target 1408:0053 raw bytes 9A FF FF 00 00; fixup target 13e8:232d

Candidate O only

File offset Live address What changes Old value New value
0x0C975D 13e8:235c Second reused far-call fixup inside the patched 0x410 body raw bytes FF FF 00 00; fixup target 1320:1588 raw bytes FF FF 00 00; fixup target 13a0:020d
0x0B9C48 13a0:0248 Zero inherited modal-wrapper caller words while preserving the leading local default PUSH 0 6A 00 FF 76 08 FF 76 06 6A 00 6A 00 6A 00 90 90

Practical meaning:

  • the patched 0x410 body now creates/stores the debugger object if needed, or reuses the live one and arms break-next
  • the interpreter callback at 1418:04b5 no longer enters 1408:0053 directly; it enters the private stub at 13e8:232d
  • that private stub eventually uses the repointed second far-call slot at 13e8:235c to open usecode_debugger_open_modal

Candidate P only

File offset Live address What changes Old value New value
0x0C975D 13e8:235c Second reused far-call fixup inside the patched 0x410 body raw bytes FF FF 00 00; fixup target 1320:1588 raw bytes FF FF 00 00; fixup target 13a0:0086
0x0B9A8D 13a0:008d Zero inherited current-unit-wrapper caller words while preserving the leading mode byte PUSH 1 6A 01 FF 76 08 FF 76 06 6A 01 6A 00 6A 00 90 90

Practical meaning:

  • same bootstrap and interpreter-callsite retarget as Candidate O
  • the only behavioral difference is the final UI target: this one routes to usecode_debugger_open_for_current_unit instead of the generic modal wrapper

Restore values for the current live-candidate family

If reverting O/P back to retail, these are the practical targets:

  • 0x0C970D / 13e8:230d: restore the original retail 0x410 body bytes shown above
  • 0x0C9753 / 13e8:2352: restore fixup target 1350:0046
  • 0x0CFAB5 / 1418:04b5: restore fixup target 1408:0053
  • 0x0C975D / 13e8:235c: restore fixup target 1320:1588
  • 0x0B9C48 / 13a0:0248: restore 6A 00 FF 76 08 FF 76 06 if Candidate O was active
  • 0x0B9A8D / 13a0:008d: restore 6A 01 FF 76 08 FF 76 06 if Candidate P was active

Attempt Log

ID Patch shape Mechanical result Runtime result Verdict
A1 Global callback-table rewrite: retarget shared 1478:65ab to 13a0:0086 after hotkey-created object state Initially broken, later fixed mechanically after correcting NE target segment and byte-array length Startup crash Retired. Shared callback slot is globally visible at boot.
A2 Direct local call from 13e8:230d into 13a0:0086 after object creation Clean round trip after fixing on-disk FF FF 00 00 placeholder assumptions Game booted, Ctrl+Q immediately quit with No pity. No mercy. No remorse. Retired. Direct wrapper call on live keypress is unsafe.
A3 Per-object deferred callback: rewrite object +0 from 0x65ab to 0x65af, retarget 1478:65af to 13a0:0086, enable single-step through 1408:0419 Clean round trip Game booted, Ctrl+Q produced no visible effect and suppressed the original CD-transfer toast Retired. Single-step path was too gated and the table rewrite assumption was later proven wrong.
A4 Per-object break-next: keep 1478:65af -> 13a0:0086, write +0x75 = 0, +0x74 = 1 directly Clean round trip Startup crash Retired. 65af patch remained globally unsafe.
A5 Guarded trampoline at 1408:0474, indirect through spare relocated dword 1478:6597, still rewriting object +0 to 0x65af Clean round trip Startup crash Retired. Guarded jump did not fix the deeper vtable-structure mistake.
A6 Shared method-0 callback patch: preserve 65ab, restore 0474, patch 1408:046f to indirect through 1478:6597 -> 13a0:0086 Clean round trip Startup crash Retired. Patching shared method code is still globally visible.
A7 Private two-entry vtable: rewrite object +0 to unused 1478:658f, set private method 0 1478:658f -> 13a0:0086, set private method 1 1478:6593 -> 1408:0474, arm break-next in object Clean round trip Startup crash Retired. Those dwords are consumed during startup despite no current data-use hits.
A8 Private two-entry vtable moved farther out: rewrite object +0 to unused 1478:6728, set private method 0 1478:6728 -> 13a0:0086, set private method 1 1478:672c -> 1408:0474, arm break-next in object Clean round trip Startup crash Retired. The 6728/672c pair is also startup-visible.
A9 Multi-candidate harness, Candidate A: rewrite object +0 to 1478:6724, set private method 0 1478:6724 -> 13a0:0086, set private method 1 1478:6728 -> 1408:0474, arm break-next in object Clean round trip DOSBox closes on start Retired. Startup-visible.
A10 Multi-candidate harness, Candidate B: rewrite object +0 to 1478:672c, set private method 0 1478:672c -> 13a0:0086, set private method 1 1478:6730 -> 1408:0474, arm break-next in object Clean round trip Fatal error 286.2180: Load program failed -- error code 201 -- C:\CRUSADER.EXE Retired. This pair appears to trip an earlier loader/launcher path than the plain startup-crash cases.
A11 Multi-candidate harness, Candidate C: rewrite object +0 to 1478:6734, set private method 0 1478:6734 -> 13a0:0086, set private method 1 1478:6738 -> 1408:0474, arm break-next in object Clean round trip DOSBox closes on start Retired. Startup-visible.
A12 Multi-candidate harness, Candidate D: rewrite object +0 to 1478:6718, set private method 0 1478:6718 -> 13a0:0086, set private method 1 1478:671c -> 1408:0474, arm break-next in object Clean round trip Startup crash Retired. Startup-visible.
A13 Multi-candidate harness, Candidate E: rewrite object +0 to 1478:6720, set private method 0 1478:6720 -> 13a0:0086, set private method 1 1478:6724 -> 1408:0474, arm break-next in object Clean round trip Startup crash Retired. Startup-visible.
A14 Multi-candidate harness, Candidate F: rewrite object +0 to 1478:6738, set private method 0 1478:6738 -> 13a0:0086, set private method 1 1478:673c -> 1408:0474, arm break-next in object Clean round trip Startup crash Retired. Startup-visible.
A15 Guarded callback shim, Candidate G: keep retail vtable base 1478:65ab, patch 1408:046f/0474 into a 0x659c/0x659e null-guarded trampoline, route deferred target slot 1478:6597 -> 13a0:020d, and zero inherited caller-word pushes in 13a0:024a Clean round trip Startup crash Retired. Overwriting 1408:0474 also changed the shared zero-return helper semantics.
A16 Guarded callback shim, Candidate H: keep retail vtable base 1478:65ab, patch 1408:046f/0474 into a 0x659c/0x659e null-guarded trampoline, route deferred target slot 1478:6597 -> 13a0:0086, and zero inherited caller-word pushes in 13a0:008f Clean round trip Startup crash Retired. Same 1408:0474 helper corruption risk as Candidate G.
A17 Method-0-only callback patch, Candidate I: keep retail vtable base 1478:65ab, patch only 1408:046f -> CALL FAR [1478:6597]; RETF, preserve 1408:0474 as-is, route deferred target slot 1478:6597 -> 13a0:020d, and zero inherited caller-word pushes in 13a0:024a Clean round trip Startup crash Retired. 1408:046f itself is still too shared when it jumps straight to a UI wrapper.
A18 Method-0-only callback patch, Candidate J: keep retail vtable base 1478:65ab, patch only 1408:046f -> CALL FAR [1478:6597]; RETF, preserve 1408:0474 as-is, route deferred target slot 1478:6597 -> 13a0:0086, and zero inherited caller-word pushes in 13a0:008f Clean round trip Startup crash Retired. Same shared-046f failure pattern as Candidate I.
A19 Private callback stub patch, Candidate K: keep retail vtable base 1478:65ab, patch only 1408:046f -> CALL FAR [1478:6597]; RETF, repoint 1478:6597 to a private guard stub at 13e8:2318, have that stub verify the callback object against 1478:659c/659e, then call 13a0:020d through the second relocated call slot in the patched 13e8:230d body, and zero inherited caller-word pushes in 13a0:024a Clean round trip Superseded before runtime Retired in favor of the narrower 1408:00cf break-next branch hook. The broad shared 1408:046f body stayed the main structural risk.
A20 Private callback stub patch, Candidate L: keep retail vtable base 1478:65ab, patch only 1408:046f -> CALL FAR [1478:6597]; RETF, repoint 1478:6597 to a private guard stub at 13e8:2318, have that stub verify the callback object against 1478:659c/659e, then call 13a0:0086 through the second relocated call slot in the patched 13e8:230d body, and zero inherited caller-word pushes in 13a0:008f Clean round trip Superseded before runtime Retired in favor of the narrower 1408:00cf break-next branch hook. Same shared-1408:046f structural risk as Candidate K.
A21 Interpreter break-next stub patch, Candidate M: keep retail vtable base 1478:65ab, leave 1408:046f/0474 untouched, patch only the 1408:00cf break-next branch to indirect through 1478:6597 -> 13e8:2318, and route the private stub to 13a0:020d with zeroed inherited modal-wrapper words in 13a0:024a Clean round trip on disposable retail copy Startup crash Retired. The narrower hook was better, but the embedded 13e8 stub body was malformed and 1478:6597 is no longer credible as spare storage.
A22 Interpreter break-next stub patch, Candidate N: keep retail vtable base 1478:65ab, leave 1408:046f/0474 untouched, patch only the 1408:00cf break-next branch to indirect through 1478:6597 -> 13e8:2318, and route the private stub to 13a0:0086 with zeroed inherited current-unit-wrapper words in 13a0:008f Clean round trip on disposable retail copy Startup crash Retired. Same root cause as Candidate M: unsafe 1478:6597 dependency plus a broken private-stub control-flow layout.
A23 Interpreter callsite retarget patch, Candidate O: keep retail vtable base 1478:65ab, leave 1408:046f/0474 and 1408:00cf untouched, patch the existing interpreter CALLF 1408:0053 at 1418:04b5 to 13e8:232d, and use a corrected embedded private stub/body in 13e8:230d that either arms the existing 0x659c/659e debugger object or lazily creates/stores one before routing to 13a0:020d with zeroed inherited modal-wrapper words in 13a0:024a Clean round trip on disposable retail copy Pending user runtime test Live candidate. Removes both the shared seg1408 hook and the unsafe 1478:6597 deferred-target assumption.
A24 Interpreter callsite retarget patch, Candidate P: keep retail vtable base 1478:65ab, leave 1408:046f/0474 and 1408:00cf untouched, patch the existing interpreter CALLF 1408:0053 at 1418:04b5 to 13e8:232d, and use the same corrected embedded private stub/body in 13e8:230d before routing to 13a0:0086 with zeroed inherited current-unit-wrapper words in 13a0:008f Clean round trip on disposable retail copy Pending user runtime test Live candidate. Same narrower interpreter-callsite design as Candidate O, but routed to the current-unit wrapper.

Root-Cause Findings From Failed Paths

1. On-disk NE fixup placeholders matter

The retail EXE stores internal far-call operands as FF FF 00 00 placeholders on disk. Early patch generations incorrectly expected disassembly-resolved targets in the raw bytes, which caused false mismatch failures and one off-by-one patched-body bug.

2. 65af is not a second vtable base

This is the most important structural correction from the recent passes.

1408:0000 writes 0x65ab to object +0. Later dispatch proves:

  • object method 0 uses CALLF [BX] and therefore reads the dword at 1478:65ab
  • object method 1 uses CALLF [BX+4] and therefore reads the dword at 1478:65af

Rewriting object +0 to 0x65af does not select a private callback table. It shifts the base one entry forward and corrupts the method-1 lookup.

3. Shared callback bodies are also too global

Even after preserving 0x65ab as the object vtable base, patching the shared method-0 code at 1408:046f still caused startup failure. That makes shared callback code just as dangerous as shared callback-table dwords for this workflow.

4. Deferred entry is still the right direction

The quit-on-Ctrl+Q result from the direct wrapper-call build is negative evidence against opening the debugger directly on the hotkey path. The safer model remains:

  • create or reuse the debugger-state object
  • arm break-next or step state in the object
  • let a later interpreter-side callback enter the UI after the original keypress path is gone

5. 1478:6718..673c is not spare storage

The full 1478:6718..673c band that fed Candidates D/E/F is now positively identified as a live function-pointer table, not spare relocated dword storage.

Recovered entries include:

  • 1478:6718 -> 1420:20dd = UsecodeProcess_1420_20dd
  • 1478:671c -> 11e0:112c = Process_Terminate
  • 1478:6720 -> 11e0:115c = Process_Fail
  • 1478:6724 -> 1020:08cd = nullfn_1020_08cd
  • 1478:6728 -> 1420:1162 = UsecodeProcess_1420_1162
  • 1478:672c -> 1420:1278 = UsecodeProcess_1420_1278
  • 1478:6730 -> 1420:118f = UsecodeProcess_1420_118f
  • 1478:6734 -> 1020:08d2 = nullfn_1020_08d2
  • 1478:6738 -> 1420:10b6 = UsecodeProcess_1420_10b6
  • 1478:673c -> 1420:00cd = UsecodeProcess_1420_00cd

Interpretation:

  • the entire band was a bad candidate pool even though direct data-use scans returned no hits
  • the startup crashes were expected once the patch began overwriting live process-dispatch entries
  • the private-vtable family should be considered closed unless a genuinely unused far-pointer region is proven some other way

6. 1408:0474 is not safe to overwrite casually

The failed guarded-shim Candidates G/H exposed a second structural issue in the callback family.

1408:046f and 1408:0474 are adjacent, but they are not interchangeable dead bytes:

  • 1408:046f is the method-0 no-op callback (RETF)
  • 1408:0474 is the method-1 helper that explicitly returns DX:AX = 0

Overwriting the whole 046f/0474 cluster with one shared shim preserved the far-return shape but destroyed the zero-return behavior of 0474. That is a plausible startup-failure source even when the callback target itself is otherwise reasonable.

Interpretation:

  • future callback-family patches should redirect only 1408:046f unless there is hard evidence that 0474 can be replaced safely
  • preserving 0474 is a more defensible next step than searching for more spare dword tables

7. Shared 1408:046f is also too broad when it jumps straight into UI code

Candidates I/J preserved 1408:0474, but they still crashed on startup.

The stronger remaining explanation is that 1408:046f is reached by more than just the debugger object, so a plain CALL FAR [1478:6597]; RETF that lands directly in 13a0:020d or 13a0:0086 is still globally unsafe even when the wrapper arguments are sanitized.

Interpretation:

  • the callback-family lane still looks structurally right
  • but the shared seg1408 method must land in a private guard stub first
  • that stub needs to verify the callback object matches 1478:659c/659e before calling any debugger UI wrapper

8. 1408:00cf is a narrower deferred hook than 1408:046f

The next structural refinement is that the break-next branch inside usecode_debugger_maybe_break_on_current_line is a better compiled hook than the shared vtable method body.

At 1408:00c5..00dc the function does:

  • LES BX,[BP+6]
  • test object flag +0x74
  • only when that flag is armed, push the debugger object and dispatch method 0

That means a patch at 1408:00cf/00d3 is narrower than a patch at 1408:046f:

  • it runs only after 0x659c/0x659e is already non-null
  • it runs only on the explicit break-next path
  • it leaves the shared vtable slot 1478:65ab and shared method body 1408:046f untouched

Interpretation:

  • keep the private guard stub at 13e8:2318
  • keep the deferred target slot 1478:6597
  • move the shared hook from 1408:046f to the break-next branch dispatch inside 1408:0053

9. 1478:6597 is not safe to treat as spare storage

The M/N crash follow-up closed another false assumption.

Raw bytes at 1478:6580..65b4 show that the 6597 dword sits inside live debugger-adjacent data, not inside a clean unused relocation island. The same region contains active-looking far-pointer values and the nearby DEBUGGER.C string.

Interpretation:

  • no direct xrefs is not enough to prove this dword is safe
  • any design that depends on 1478:6597 as a private deferred callback slot should now be treated as retired negative evidence
  • the next safer hook has to reuse an already-live code lane rather than inventing a new global data slot

10. The embedded 13e8 private stub must be structurally correct byte-for-byte

The M/N family also exposed a second root cause: the private stub embedded into the patched 13e8:230d body was malformed.

The failure was not just conceptual. The byte template itself had bad control-flow:

  • the top-of-body branch layout did not land on the intended create path cleanly
  • one private-stub jump target landed inside relocated call-immediate bytes instead of on a real instruction boundary
  • the stub/object-return path and the hotkey-handler return path were incorrectly entangled

Interpretation:

  • patch generation for this lane must be treated like handwritten machine code, not just a rough semantic sketch
  • any future candidate has to verify the exact patched-body length and branch targets as part of mechanical validation
  • this is why the newer O/P family retargets the existing interpreter callsite at 1418:04b5 and uses a corrected embedded stub at 13e8:232d instead of reusing the old M/N body

11. The second 13e8 far-call relocation has to be written too

The first O/P runtime build exposed a script bug rather than a valid candidate result.

The patched 13e8:230d body reuses two existing retail far-call slots:

  • 13e8:2352 for usecode_debugger_break_state_create
  • 13e8:235c for the private debugger UI call

The initial O/P refactor correctly retargeted the interpreter call at 1418:04b5, but it forgot to write the second relocation entry at 13e8:235c. That left the embedded private UI call pointed at the retail Dispatch_ModalGump target instead of the selected debugger wrapper.

Interpretation:

  • the user's first O/P No pity. No mercy. No remorse. runtime result was from an invalid build, not from the documented O/P candidate semantics
  • candidate detection must verify both the interpreter hook and the reused 13e8:235c far-call target
  • subsequent O/P testing is still required after the relocation-write fix

Current Live Candidates

Private Vtable Build, First Placement

Patch shape:

  • 13e8:230d still lazily creates the seg1408 debugger-state object via 1408:0000
  • returned far pointer is stored at 1478:659c/659e
  • object +0 is rewritten from 0x65ab to private vtable base 0x658f
  • object +0x75 is set to 0
  • object +0x74 is set to 1
  • private vtable method 0 at 1478:658f is retargeted to 13a0:0086
  • private vtable method 1 at 1478:6593 is retargeted to retail helper 1408:0474

Why this is better:

  • no shared callback-table slot is rewritten
  • no shared callback code body is rewritten
  • the only global data edits are to unused relocated dwords with no current data uses
  • the object gets a complete two-entry table instead of a half-broken base rewrite

Mechanical status:

  • verified clean on a fresh retail copy
  • 0xC970D patched/restored
  • 0xEA18F patched/restored
  • 0xEA193 patched/restored
  • legacy cleanup sites held at original bytes

Runtime status:

  • startup crash in user runtime

Interpretation:

  • the 658f/6593 pair is not safe to reuse even though direct data-use scans reported no hits
  • this is strong evidence that some startup-time indirect table walk or loader-side consumer reaches that cluster

Private Vtable Build, Moved Single Placement

Patch shape:

  • 13e8:230d still lazily creates the seg1408 debugger-state object via 1408:0000
  • returned far pointer is stored at 1478:659c/659e
  • object +0 is rewritten from 0x65ab to private vtable base 0x6728
  • object +0x75 is set to 0
  • object +0x74 is set to 1
  • private vtable method 0 at 1478:6728 is retargeted to 13a0:0086
  • private vtable method 1 at 1478:672c is retargeted to retail helper 1408:0474

Mechanical status:

  • verified clean on a fresh retail copy

Runtime status:

  • startup crash in user runtime

Interpretation:

  • the farther pair 6728/672c is also unsafe to reuse as a complete private table
  • the next iteration should stop shipping one candidate at a time and instead expose multiple disjoint pairs for fast runtime testing

Multi-Candidate Harness

Current script shape:

  • 13e8:230d still lazily creates the seg1408 debugger-state object via 1408:0000
  • returned far pointer is stored at 1478:659c/659e
  • object +0x75 is set to 0
  • object +0x74 is set to 1
  • the earlier script offered six explicit private-vtable candidates in one script:
    • Candidate A: base 1478:6724, method 1 1478:6728
    • Candidate B: base 1478:672c, method 1 1478:6730
    • Candidate C: base 1478:6734, method 1 1478:6738
    • Candidate D: base 1478:6718, method 1 1478:671c
    • Candidate E: base 1478:6720, method 1 1478:6724
    • Candidate F: base 1478:6738, method 1 1478:673c
  • each candidate retargets method 0 to 13a0:0086 and preserves method 1 on 1408:0474
  • restore returns the selected pair and the shared 13e8:230d body to the retail byte pattern

Why this is better than the single-candidate loop:

  • it reduces user turnaround by letting runtime tests move through multiple dword pairs without editing the script again
  • the three pairs are disjoint, so apply/restore and status detection remain mechanically simple
  • the harness preserves the same deferred-entry model while isolating the remaining unknown to which relocated dword pair is actually boot-safe

Mechanical status:

  • Candidate A apply/restore verified clean on a fresh retail copy
  • Candidate B apply/restore verified clean on a fresh retail copy
  • Candidate C apply/restore verified clean on a fresh retail copy
  • Candidate D apply/restore verified clean on a fresh retail copy
  • Candidate E apply/restore verified clean on a fresh retail copy
  • Candidate F apply/restore verified clean on a fresh retail copy

Runtime status:

  • Candidate A: DOSBox closes on start
  • Candidate B: fatal Load program failed -- error code 201 on C:\CRUSADER.EXE
  • Candidate C: DOSBox closes on start
  • Candidate D: DOSBox closes on start
  • Candidate E: DOSBox closes on start
  • Candidate F: DOSBox closes on start

Interpretation:

  • the entire 1478:6718..673c band is now negative evidence and should be treated as a live process-function table
  • Candidate B is distinct from the plain startup-crash paths because it fails in the loader/program-launch lane rather than only after the game begins booting
  • the next useful step is to stop testing that table and move to a callback-family patch that preserves the retail debugger object's real vtable

Guarded Callback Candidates

Current script shape:

  • 13e8:230d still lazily creates the seg1408 debugger-state object via 1408:0000
  • returned far pointer is stored at 1478:659c/659e
  • object +0x75 is set to 0
  • object +0x74 is set to 1
  • 1408:046f/0474 are replaced with a 14-byte null-guarded shim:
    • if 0x659c/0x659e == 0, return immediately
    • otherwise CALL FAR [1478:6597] and then RETF
  • Candidate G routes 1478:6597 to 13a0:020d and zeroes the inherited callback-object pushes in 13a0:024a
  • Candidate H routes 1478:6597 to 13a0:0086 and zeroes the inherited callback-object pushes in 13a0:008f

Why this is better than the private-vtable family:

  • it preserves the retail debugger-state object layout and the retail 1478:65ab vtable base
  • it stops overwriting unrelated process-function tables in 1478:6718..673c
  • it fixes the earlier calling-convention mismatch by preventing the debugger callback from forwarding the debugger-object far pointer as UI wrapper arguments
  • the null guard means startup should stay clean until the hotkey path has actually instantiated the debugger object

Mechanical status:

  • Candidate G apply/restore verified clean on a fresh retail copy
  • Candidate H apply/restore verified clean on a fresh retail copy

Runtime status:

  • Candidate G: startup crash
  • Candidate H: startup crash

Interpretation:

  • the calling-convention fix alone was not sufficient when 1408:0474 was also overwritten
  • the next callback-family patch should preserve the original 0474 helper bytes

Interpreter Break-Next Stub Candidates

Current script shape:

  • 13e8:230d still lazily creates the seg1408 debugger-state object via 1408:0000
  • returned far pointer is stored at 1478:659c/659e
  • object +0x75 is set to 0
  • object +0x74 is set to 1
  • retail 1478:65ab/65af and retail 1408:046f/0474 are left untouched
  • only the break-next dispatch bytes at 1408:00d3..00d7 are patched, replacing the original MOV BX, ES:[BX] / CALL FAR [BX] with CALL FAR [1478:6597]
  • 1478:6597 no longer points at a UI wrapper; it points at a private guard stub embedded in the patched 13e8:230d body at 13e8:2318
  • that private stub compares the callback object passed on the stack against 1478:659c/659e, clears +0x74/+0x75 only on a matching object, and otherwise immediately RETFs
  • the stub uses the second relocated call site already present in 13e8:230d to enter the debugger UI wrapper after the object-identity check passes
  • Candidate M uses that private call slot for 13a0:020d and zeroes the inherited callback-object pushes in 13a0:024a
  • Candidate N uses that private call slot for 13a0:0086 and zeroes the inherited callback-object pushes in 13a0:008f

Why this is better than K/L:

  • it keeps the private guard stub and callback/UI argument sanitization work from the earlier private-stub design
  • it stops patching the shared seg1408 method-0 body at 1408:046f entirely
  • it hooks only the armed break-next branch inside 1408:0053, after the global debugger-state pointer is already live
  • it leaves the shared vtable slots and helper body 1408:0474 untouched

Mechanical status:

  • Candidate M apply/restore verified clean on a disposable retail copy
  • Candidate N apply/restore verified clean on a disposable retail copy

Runtime status:

  • Candidates M/N pending user test

Next Checks If The Interpreter Break-Next Stub Build Still Fails

  1. Verify whether startup stays clean through gameplay for Candidates M/N.
  2. If either candidate still fails before gameplay, inspect whether 1478:6597 -> 13e8:2318 is being reached from any unexpected non-break-next lane despite the narrower 1408:00cf hook.
  3. If gameplay boots but Ctrl+Q does nothing, inspect whether +0x74 is being cleared before the first eligible 1408:00cf break-next dispatch.
  4. If gameplay boots but quits, re-evaluate whether 13a0:0086 or 13a0:020d still inherits an unsafe modal/input state even from the interpreter-deferred path.
  5. If the debugger appears partially, capture the first bad UI/control-flow transition instead of changing the patch blind.
  6. If this lane fails completely, pivot from breakpoint-entry patching to a purpose-built event or task queue handoff that invokes the debugger outside both the keyboard handler and the shared seg1408 break helper.