# 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.