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

423 lines
No EOL
33 KiB
Markdown

# 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 `RETF`s
- 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.