Crusader_Decomp/docs/retail-debugger-patch-attempts.md
Marco 7310c4fe96 Add detailed log for retail debugger patch attempts in CRUSADER.EXE
This commit introduces a comprehensive document outlining the various executable-patching attempts aimed at revealing the hidden retail usecode debugger within the CRUSADER.EXE file. The document serves multiple purposes, including preserving negative evidence, recording patch shapes and their rationales, and ensuring that runtime outcomes are linked to specific patch generations.

Key sections include:
- Ground rules for patching and validation processes.
- A table of stable facts regarding the debugger's structure and behavior.
- A detailed attempt log documenting each patch's shape, mechanical and runtime results, and verdicts.
- Root-cause findings from failed paths, providing insights into the challenges faced during the patching process.
- Current live candidates for further testing and exploration.

This documentation is intended to streamline future patching efforts and improve the understanding of the underlying mechanics of the debugger.
2026-03-25 17:36:16 +01:00

353 lines
No EOL
27 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 |
## 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.