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 originalon 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:0000is only a constructor. It allocates/initializes the seg1408 debugger object, seeds object+0with0x65ab, and returns the far pointer inDX:AX; it does not store that pointer into1478:659c/659e.- current instruction searches still show reads of
1478:659c/659ein seg13a0 and seg1418, but no recovered retail writer that seeds those globals before the interpreter hook runs. - raw debugger-adjacent data at
1478:65ab/65afstill resolves to the two inert retail callbacks1408:046fand1408:0474; there is no hidden already-live UI-opening target sitting behind the stock vtable. - the interpreter-side pre-call guard at
1418:049e..04b5only checks whether1478:659c/659eis non-null before calling1408:0053. The one-shot breakpoint/step gating lives inside1408: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/659esomewhere. - a vtable-dword-only patch is insufficient because the retail callbacks at
1478:65ab/65afare inert shared stubs, and the previously tested shared-slot rewrites already crashed at startup. - a direct
1418:04b5 -> 13a0:020d/0086retarget is insufficient because once1478:659c/659ebecomes non-null, that callsite would fire on every eligible interpreter pass unless a private one-shot stub preserves the original1408:0053gating 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..232dbody that lazily creates/stores/arms the debugger object, one retarget of1418:04b5into the private stub, and one wrapper-argument sanitization site (13a0:024aor13a0: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 bytesmay be unchanged while thefixup targetchanges
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
0x410body now creates/stores the debugger object if needed, or reuses the live one and arms break-next - the interpreter callback at
1418:04b5no longer enters1408:0053directly; it enters the private stub at13e8:232d - that private stub eventually uses the repointed second far-call slot at
13e8:235cto openusecode_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_unitinstead 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 retail0x410body bytes shown above0x0C9753/13e8:2352: restore fixup target1350:00460x0CFAB5/1418:04b5: restore fixup target1408:00530x0C975D/13e8:235c: restore fixup target1320:15880x0B9C48/13a0:0248: restore6A 00 FF 76 08 FF 76 06if Candidate O was active0x0B9A8D/13a0:008d: restore6A 01 FF 76 08 FF 76 06if 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 at1478:65ab - object method 1 uses
CALLF [BX+4]and therefore reads the dword at1478: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_20dd1478:671c -> 11e0:112c=Process_Terminate1478:6720 -> 11e0:115c=Process_Fail1478:6724 -> 1020:08cd=nullfn_1020_08cd1478:6728 -> 1420:1162=UsecodeProcess_1420_11621478:672c -> 1420:1278=UsecodeProcess_1420_12781478:6730 -> 1420:118f=UsecodeProcess_1420_118f1478:6734 -> 1020:08d2=nullfn_1020_08d21478:6738 -> 1420:10b6=UsecodeProcess_1420_10b61478: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:046fis the method-0 no-op callback (RETF)1408:0474is the method-1 helper that explicitly returnsDX: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:046funless there is hard evidence that0474can be replaced safely - preserving
0474is 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/659ebefore 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/0x659eis already non-null - it runs only on the explicit break-next path
- it leaves the shared vtable slot
1478:65aband shared method body1408:046funtouched
Interpretation:
- keep the private guard stub at
13e8:2318 - keep the deferred target slot
1478:6597 - move the shared hook from
1408:046fto the break-next branch dispatch inside1408: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:6597as 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:04b5and uses a corrected embedded stub at13e8:232dinstead 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:2352forusecode_debugger_break_state_create13e8:235cfor 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:235cfar-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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0is rewritten from0x65abto private vtable base0x658f - object
+0x75is set to0 - object
+0x74is set to1 - private vtable method 0 at
1478:658fis retargeted to13a0:0086 - private vtable method 1 at
1478:6593is retargeted to retail helper1408: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
0xC970Dpatched/restored0xEA18Fpatched/restored0xEA193patched/restored- legacy cleanup sites held at original bytes
Runtime status:
- startup crash in user runtime
Interpretation:
- the
658f/6593pair 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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0is rewritten from0x65abto private vtable base0x6728 - object
+0x75is set to0 - object
+0x74is set to1 - private vtable method 0 at
1478:6728is retargeted to13a0:0086 - private vtable method 1 at
1478:672cis retargeted to retail helper1408:0474
Mechanical status:
- verified clean on a fresh retail copy
Runtime status:
- startup crash in user runtime
Interpretation:
- the farther pair
6728/672cis 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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0x75is set to0 - object
+0x74is set to1 - the earlier script offered six explicit private-vtable candidates in one script:
- Candidate A: base
1478:6724, method 11478:6728 - Candidate B: base
1478:672c, method 11478:6730 - Candidate C: base
1478:6734, method 11478:6738 - Candidate D: base
1478:6718, method 11478:671c - Candidate E: base
1478:6720, method 11478:6724 - Candidate F: base
1478:6738, method 11478:673c
- Candidate A: base
- each candidate retargets method 0 to
13a0:0086and preserves method 1 on1408:0474 - restore returns the selected pair and the shared
13e8:230dbody 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 201onC:\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..673cband 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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0x75is set to0 - object
+0x74is set to1 1408:046f/0474are replaced with a 14-byte null-guarded shim:- if
0x659c/0x659e == 0, return immediately - otherwise
CALL FAR [1478:6597]and thenRETF
- if
- Candidate G routes
1478:6597to13a0:020dand zeroes the inherited callback-object pushes in13a0:024a - Candidate H routes
1478:6597to13a0:0086and zeroes the inherited callback-object pushes in13a0:008f
Why this is better than the private-vtable family:
- it preserves the retail debugger-state object layout and the retail
1478:65abvtable 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:0474was also overwritten - the next callback-family patch should preserve the original
0474helper bytes
Interpreter Break-Next Stub Candidates
Current script shape:
13e8:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0x75is set to0 - object
+0x74is set to1 - retail
1478:65ab/65afand retail1408:046f/0474are left untouched - only the break-next dispatch bytes at
1408:00d3..00d7are patched, replacing the originalMOV BX, ES:[BX] / CALL FAR [BX]withCALL FAR [1478:6597] 1478:6597no longer points at a UI wrapper; it points at a private guard stub embedded in the patched13e8:230dbody at13e8:2318- that private stub compares the callback object passed on the stack against
1478:659c/659e, clears+0x74/+0x75only on a matching object, and otherwise immediatelyRETFs - the stub uses the second relocated call site already present in
13e8:230dto enter the debugger UI wrapper after the object-identity check passes - Candidate M uses that private call slot for
13a0:020dand zeroes the inherited callback-object pushes in13a0:024a - Candidate N uses that private call slot for
13a0:0086and zeroes the inherited callback-object pushes in13a0: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:046fentirely - 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:0474untouched
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
- Verify whether startup stays clean through gameplay for Candidates M/N.
- If either candidate still fails before gameplay, inspect whether
1478:6597 -> 13e8:2318is being reached from any unexpected non-break-next lane despite the narrower1408:00cfhook. - If gameplay boots but
Ctrl+Qdoes nothing, inspect whether+0x74is being cleared before the first eligible1408:00cfbreak-next dispatch. - If gameplay boots but quits, re-evaluate whether
13a0:0086or13a0:020dstill inherits an unsafe modal/input state even from the interpreter-deferred path. - If the debugger appears partially, capture the first bad UI/control-flow transition instead of changing the patch blind.
- 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.