diff --git a/.gitignore b/.gitignore index 09f8e53..15a74f3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ dist/ .tmp_*.txt .tmp_*.py USECODE/EUSECODE_extracted/chunks/** -tools/pyghidra_crusader/__pycache__/** \ No newline at end of file +tools/pyghidra_crusader/__pycache__/** +bin/** \ No newline at end of file diff --git a/Crusader.rep/idata/01/~00000015.db/change.data.gbf b/Crusader.rep/idata/01/~00000015.db/change.data.gbf index 098cc3a..5f31ba0 100644 Binary files a/Crusader.rep/idata/01/~00000015.db/change.data.gbf and b/Crusader.rep/idata/01/~00000015.db/change.data.gbf differ diff --git a/Crusader.rep/idata/01/~00000015.db/change.map.gbf b/Crusader.rep/idata/01/~00000015.db/change.map.gbf index de4f493..aa53ba2 100644 Binary files a/Crusader.rep/idata/01/~00000015.db/change.map.gbf and b/Crusader.rep/idata/01/~00000015.db/change.map.gbf differ diff --git a/Crusader.rep/idata/01/~00000015.db/db.11.gbf b/Crusader.rep/idata/01/~00000015.db/db.11.gbf deleted file mode 100644 index 84644f8..0000000 Binary files a/Crusader.rep/idata/01/~00000015.db/db.11.gbf and /dev/null differ diff --git a/Crusader.rep/idata/01/~00000015.db/db.15.gbf b/Crusader.rep/idata/01/~00000015.db/db.16.gbf similarity index 99% rename from Crusader.rep/idata/01/~00000015.db/db.15.gbf rename to Crusader.rep/idata/01/~00000015.db/db.16.gbf index cca36cc..bd678cf 100644 Binary files a/Crusader.rep/idata/01/~00000015.db/db.15.gbf and b/Crusader.rep/idata/01/~00000015.db/db.16.gbf differ diff --git a/Crusader.rep/idata/01/~00000015.db/db.14.gbf b/Crusader.rep/idata/01/~00000015.db/db.17.gbf similarity index 99% rename from Crusader.rep/idata/01/~00000015.db/db.14.gbf rename to Crusader.rep/idata/01/~00000015.db/db.17.gbf index e2c8acc..fb078a7 100644 Binary files a/Crusader.rep/idata/01/~00000015.db/db.14.gbf and b/Crusader.rep/idata/01/~00000015.db/db.17.gbf differ diff --git a/Crusader.rep/projectState b/Crusader.rep/projectState index 5a8e1fa..6244408 100644 --- a/Crusader.rep/projectState +++ b/Crusader.rep/projectState @@ -3,1877 +3,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Crusader.rep/user/00/~00000008.db/db.10.gbf b/Crusader.rep/user/00/~00000008.db/db.12.gbf similarity index 99% rename from Crusader.rep/user/00/~00000008.db/db.10.gbf rename to Crusader.rep/user/00/~00000008.db/db.12.gbf index 390c5ab..82ff047 100644 Binary files a/Crusader.rep/user/00/~00000008.db/db.10.gbf and b/Crusader.rep/user/00/~00000008.db/db.12.gbf differ diff --git a/Crusader.rep/user/00/~00000008.db/db.9.gbf b/Crusader.rep/user/00/~00000008.db/db.13.gbf similarity index 99% rename from Crusader.rep/user/00/~00000008.db/db.9.gbf rename to Crusader.rep/user/00/~00000008.db/db.13.gbf index b1e858a..69fc489 100644 Binary files a/Crusader.rep/user/00/~00000008.db/db.9.gbf and b/Crusader.rep/user/00/~00000008.db/db.13.gbf differ diff --git a/crusader_decompilation_notes.md b/crusader_decompilation_notes.md index 5c60272..4f5e759 100644 --- a/crusader_decompilation_notes.md +++ b/crusader_decompilation_notes.md @@ -21,6 +21,7 @@ Recent verified batch: [docs/ne-segment1.md](docs/ne-segment1.md) now carries th | [docs/far-call-targets.md](docs/far-call-targets.md) | Top-104 most-called far-call targets (Tiers 1-5, ranks 1-104), supporting functions discovered, analysis gaps and seg043 reconciliation | | [docs/crusader-disasm-reference.md](docs/crusader-disasm-reference.md) | Local auxiliary disassembly corpus at `K:/ghidra/crusader-disasm`: handwritten notes, shape tables, map dumps, opcode lists, intrinsic/function dumps, and the safe reuse rules for porting into `CRUSADER.EXE` | | [docs/ne-hole-filling-priorities.md](docs/ne-hole-filling-priorities.md) | Ranked `CRUSADER.EXE` hole-filling tracker: NE-side unclear lanes, the verified raw-side knowledge that can close them, and the recommended order for old-to-new porting passes | +| [docs/retail-debugger-patch-attempts.md](docs/retail-debugger-patch-attempts.md) | Chronological log of retail `CRUSADER.EXE` debugger-unlock patch attempts, byte-level designs, runtime failures, root-cause findings, and the current live candidate | | [docs/scummvm-crusader-reference.md](docs/scummvm-crusader-reference.md) | ScummVM Ultima8/Pentagram Crusader integration survey: USECODE/event tables, FLEX/resource formats, world/map loaders, HUD/media, and RE follow-up priorities | | [docs/pentagram-crusader-reference.md](docs/pentagram-crusader-reference.md) | Pentagram-source Crusader/U8 reference: direct Crusader USECODE parser and VM evidence, U8 usecode docs, runtime-confidence limits, and cross-checks against the ScummVM note | | [docs/usecode-roundtrip-ir.md](docs/usecode-roundtrip-ir.md) | ScummVM-to-binary USECODE cross-walk, owner-loaded class-layout and header/event-count reconciliation, conservative IR v0 plan, and the generated class-event/body-window outputs that now ground reversible `_BOOT`, `SURCAM*`, and environmental family decompile artifacts plus repeated-family regression checks | diff --git a/docs/retail-debugger-patch-attempts.md b/docs/retail-debugger-patch-attempts.md new file mode 100644 index 0000000..7b9cfc9 --- /dev/null +++ b/docs/retail-debugger-patch-attempts.md @@ -0,0 +1,353 @@ +# 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. \ No newline at end of file diff --git a/patch_crusader_cheat_menu.ps1 b/patch_crusader_cheat_menu.ps1 index 71c40f9..fddf61f 100644 --- a/patch_crusader_cheat_menu.ps1 +++ b/patch_crusader_cheat_menu.ps1 @@ -1,17 +1,211 @@ param( - [ValidateSet('1', '2', '3', '4')] - [string]$Choice + [ValidateSet('1', '2', '3', '4', 'candidate-i', 'candidate-j', 'candidate-m', 'candidate-n', 'candidate-o', 'candidate-p', 'restore', 'exit')] + [string]$Choice, + + [string]$ExePath = $( + if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'CRUSADER.EXE')) { + Join-Path $PSScriptRoot 'CRUSADER.EXE' + } + elseif (Test-Path -LiteralPath 'F:\Apps\Crusader No Remorse\CRUSADER.EXE') { + 'F:\Apps\Crusader No Remorse\CRUSADER.EXE' + } + else { + Join-Path $PSScriptRoot 'CRUSADER.EXE' + } + ) ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$exePath = Join-Path $PSScriptRoot 'CRUSADER.EXE' +$exePath = $ExePath $statePath = Join-Path $PSScriptRoot 'patch_crusader_cheat_menu.state.json' $sites = @{ + CtrlQDebuggerInit = @{ + Label = 'Ctrl+Q debugger-state init body' + Offset = 0xC970D + Original = [byte[]]( + 0xA0, 0x4F, 0x60, 0xB4, 0x00, 0xF7, 0xD8, 0x1B, 0xC0, 0x40, 0xA2, 0x4F, 0x60, 0x80, 0x3E, 0x4F, + 0x60, 0x00, 0x74, 0x47, 0x6A, 0xFF, 0x6A, 0xFF, 0xC4, 0x1E, 0xD0, 0x4C, 0x26, 0x8A, 0x47, 0x05, + 0x50, 0x1E, 0x68, 0xD2, 0x60, 0x6A, 0x00, 0x6A, 0x00, 0x83, 0xEC, 0x06, 0xC7, 0x86, 0x76, 0xFF, + 0x00, 0x00, 0x8B, 0x86, 0x76, 0xFF, 0xF7, 0xD0, 0x89, 0x86, 0x78, 0xFF, 0xC6, 0x86, 0x7A, 0xFF, + 0x00, 0x6A, 0x00, 0x6A, 0x00, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x14, 0x52, 0x50, 0x9A, + 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x08, 0x5F, 0x5E, 0xC9, 0xCB, 0x6A, 0xFF, 0x6A, 0xFF, 0xC4, + 0x1E, 0xD0, 0x4C, 0x26, 0x8A, 0x47, 0x05, 0x50, 0x1E, 0x68, 0xEE, 0x60, 0x6A, 0x00, 0x6A, 0x00, + 0x83, 0xEC, 0x06, 0xC7 + ) + Patched = [byte[]]( + 0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x74, 0x10, 0xC4, 0x1E, 0x9C, 0x65, 0xC6, 0x47, 0x75, + 0x00, 0xC6, 0x47, 0x74, 0x01, 0x5F, 0x5E, 0xC9, 0xCB, 0x6A, 0x00, 0x6A, 0x00, 0xE9, 0x25, 0x00, + 0x55, 0x8B, 0xEC, 0xA1, 0x9C, 0x65, 0x39, 0x46, 0x06, 0x75, 0x16, 0xA1, 0x9E, 0x65, 0x39, 0x46, + 0x08, 0x75, 0x0E, 0xC4, 0x5E, 0x06, 0xC7, 0x47, 0x74, 0x00, 0x00, 0x6A, 0x00, 0x6A, 0x00, 0xEB, + 0x0E, 0x5D, 0xCB, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0xEB, 0x0A, 0x9A, + 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x5D, 0xCB, 0x0B, 0xC2, 0x74, 0x13, 0xA3, 0x9C, 0x65, + 0x89, 0x16, 0x9E, 0x65, 0x89, 0xC3, 0x8E, 0xC2, 0xC6, 0x47, 0x75, 0x00, 0xC6, 0x47, 0x74, 0x01, + 0x5F, 0x5E, 0xC9, 0xCB + ) + LegacyPatched = [byte[]]( + 0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x52, 0x31, 0xC0, 0x50, 0x50, 0xEB, 0x36, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x0B, 0xC2, 0x75, + 0x03, 0xE9, 0x46, 0x06, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E, 0x65, 0xC4, 0x1E, 0x9C, 0x65, 0xC6, + 0x47, 0x75, 0x01, 0xC6, 0x47, 0x74, 0x00, 0xC7, 0x47, 0x76, 0x00, 0x00, 0xC7, 0x47, 0x78, 0x00, + 0x00, 0xE9, 0x26, 0x06, 0xC7 + ) + LegacyPatchedVariants = @( + [byte[]]( + 0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x5E, 0x31, 0xC0, 0x50, 0x50, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, + 0xEB, 0x0D, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x5F, 0x5E, 0xC9, 0xCB, 0x0B, 0xC2, + 0x74, 0xF8, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E, 0x65, 0x6A, 0x00, 0x6A, 0x00, 0xEB, 0xE2, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0xC7 + ), + [byte[]]( + 0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x5E, 0x31, 0xC0, 0x50, 0x50, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0xEB, 0x0D, 0x9A, + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x04, 0x5F, 0x5E, 0xC9, 0xCB, 0x0B, 0xC2, 0x74, 0xF7, 0xA3, + 0x9C, 0x65, 0x89, 0x16, 0x9E, 0x65, 0x6A, 0x00, 0x6A, 0x00, 0xEB, 0xE2, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0xEC, 0x06, 0xC7 + ), + [byte[]]( + 0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x75, 0x55, 0x31, 0xC0, 0x50, 0x50, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, + 0x52, 0x50, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E, + 0x65, 0xC4, 0x1E, 0x9C, 0x65, 0xC7, 0x07, 0xAF, 0x65, 0xC6, 0x47, 0x75, 0x01, 0xC6, 0x47, 0x74, + 0x00, 0x5F, 0x5E, 0xC9, 0xCB, 0x90, 0x90 + ) + ) + LegacyPatchedFixupState = 'Any' + OriginalPatterns = @( + @('A0', '4F', '60', 'B4', '00', 'F7', 'D8', '1B', 'C0', '40', 'A2', '4F', '60', '80', '3E', '4F', '60', '00', '74', '47') + ) + Fixup = @{ + OperandOffset = 0xC9753 + OriginalTargetSeg = 107 + OriginalTargetOffset = 0x0046 + PatchedTargetSeg = 130 + PatchedTargetOffset = 0x0000 + } + } + DebuggerCallback = @{ + Label = 'legacy secondary call cleanup' + Offset = 0xC975D + Original = [byte[]](0xFF, 0xFF, 0x00, 0x00) + Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00) + OriginalPatterns = @( + @('FF', 'FF', '00', '00') + ) + Fixup = @{ + OperandOffset = 0xC975D + OriginalTargetSeg = 101 + OriginalTargetOffset = 0x1588 + PatchedTargetSeg = 101 + PatchedTargetOffset = 0x1588 + } + } + PrivateBreakpointMethod0 = @{ + Label = 'private break callback slot' + Offset = 0xEA328 + Original = [byte[]](0xFF, 0xFF, 0x00, 0x00) + Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00) + OriginalPatterns = @( + @('FF', 'FF', '00', '00') + ) + Fixup = @{ + OperandOffset = 0xEA328 + OriginalTargetSeg = 133 + OriginalTargetOffset = 0x1162 + PatchedTargetSeg = 117 + PatchedTargetOffset = 0x0086 + } + } + PrivateBreakpointMethod1 = @{ + Label = 'private helper callback slot' + Offset = 0xEA32C + Original = [byte[]](0xFF, 0xFF, 0x00, 0x00) + Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00) + OriginalPatterns = @( + @('FF', 'FF', '00', '00') + ) + Fixup = @{ + OperandOffset = 0xEA32C + OriginalTargetSeg = 133 + OriginalTargetOffset = 0x1278 + PatchedTargetSeg = 130 + PatchedTargetOffset = 0x0474 + } + } + LegacyBreakpointCallback = @{ + Label = 'legacy seg1408 break callback target' + Offset = 0xEA1AB + Original = [byte[]](0xFF, 0xFF, 0x00, 0x00) + Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00) + OriginalPatterns = @( + @('FF', 'FF', '00', '00') + ) + Fixup = @{ + OperandOffset = 0xEA1AB + OriginalTargetSeg = 130 + OriginalTargetOffset = 0x046F + PatchedTargetSeg = 117 + PatchedTargetOffset = 0x0086 + } + } + CallbackGuardCode = @{ + Label = 'seg1408 break-next dispatch patch' + Offset = 0xCEAD3 + Original = [byte[]](0x26, 0x8B, 0x1F, 0xFF, 0x1F) + Patched = [byte[]](0xFF, 0x1E, 0x97, 0x65, 0x90) + OriginalPatterns = @( + @('26', '8B', '1F', 'FF', '1F') + ) + } + CallbackTargetSlot = @{ + Label = 'break-next private stub target slot' + Offset = 0xEA197 + Original = [byte[]](0xFF, 0xFF, 0x00, 0x00) + Patched = [byte[]](0xFF, 0xFF, 0x00, 0x00) + OriginalPatterns = @( + @('FF', 'FF', '00', '00') + ) + Fixup = @{ + OperandOffset = 0xEA197 + OriginalTargetSeg = 5 + OriginalTargetOffset = 0x08D2 + PatchedTargetSeg = 131 + PatchedTargetOffset = 0x2318 + } + } + InterpreterBreakCall = @{ + Label = 'interpreter debugger break callsite' + Offset = 0xCFAB5 + Original = [byte[]](0x9A, 0xFF, 0xFF, 0x00, 0x00) + Patched = [byte[]](0x9A, 0xFF, 0xFF, 0x00, 0x00) + OriginalPatterns = @( + @('9A', 'FF', 'FF', '00', '00') + ) + Fixup = @{ + OperandOffset = 0xCFAB6 + OriginalTargetSeg = 130 + OriginalTargetOffset = 0x0053 + PatchedTargetSeg = 131 + PatchedTargetOffset = 0x232D + } + } Hook = @{ - Label = 'Hidden menu direct hook site' + Label = 'Rejected direct cheat hook site' Offset = 0x70D75 Original = [byte[]](0x68, 0x03, 0x01, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x02) Patched = [byte[]](0x68, 0x03, 0x01, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x02) @@ -31,7 +225,7 @@ $sites = @{ } } Wrapper = @{ - Label = 'Current-slot wrapper arg site' + Label = 'Rejected current-slot wrapper arg site' Offset = 0xB9A8D Original = [byte[]](0x6A, 0x01, 0xFF, 0x76, 0x08, 0xFF, 0x76, 0x06) Patched = [byte[]](0x6A, 0x01, 0x6A, 0x00, 0x6A, 0x00, 0x90, 0x90) @@ -66,6 +260,137 @@ $sites = @{ } } +$candidateProfiles = [ordered]@{ + 'candidate-o' = @{ + MenuKey = '1' + Label = 'Candidate O' + ArmMode = 'interpreter-callsite' + UiTargetSeg = 117 + UiTargetOffset = 0x020D + PatchCurrentUnitWrapper = $false + PatchModalWrapper = $true + Summary = 'interpreter callsite retarget -> corrected private modal stub with zeroed modal-wrapper args' + } + 'candidate-p' = @{ + MenuKey = '2' + Label = 'Candidate P' + ArmMode = 'interpreter-callsite' + UiTargetSeg = 117 + UiTargetOffset = 0x0086 + PatchCurrentUnitWrapper = $true + PatchModalWrapper = $false + Summary = 'interpreter callsite retarget -> corrected private current-unit stub with zeroed current-slot args' + } +} + +$script:ctrlQPatchedTemplate = [byte[]]$sites.CtrlQDebuggerInit.Patched.Clone() +$script:configuredProfileKey = 'candidate-o' + +function Find-ByteSequenceOffset { + param( + [byte[]]$Bytes, + [byte[]]$Pattern + ) + + $lastStart = $Bytes.Length - $Pattern.Length + for ($start = 0; $start -le $lastStart; $start++) { + $matched = $true + for ($index = 0; $index -lt $Pattern.Length; $index++) { + if ($Bytes[$start + $index] -ne $Pattern[$index]) { + $matched = $false + break + } + } + + if ($matched) { + return $start + } + } + + throw 'Could not find the private-vtable base immediate inside the Ctrl+Q patch template.' +} + +function New-CtrlQPatchedBytes { + param([string]$ArmMode) + + switch ($ArmMode) { + 'interpreter-callsite' { + return [byte[]]( + 0xA1, 0x9C, 0x65, 0x0B, 0x06, 0x9E, 0x65, 0x74, 0x10, 0xC4, 0x1E, + 0x9C, 0x65, 0xC6, 0x47, 0x75, 0x00, 0xC6, 0x47, 0x74, 0x01, 0x5F, + 0x5E, 0xC9, 0xCB, 0x6A, 0x00, 0x6A, 0x00, 0xE9, 0x25, 0x00, 0x55, + 0x8B, 0xEC, 0xA1, 0x9C, 0x65, 0x39, 0x46, 0x06, 0x75, 0x16, 0xA1, + 0x9E, 0x65, 0x39, 0x46, 0x08, 0x75, 0x0E, 0xC4, 0x5E, 0x06, 0xC7, + 0x47, 0x74, 0x00, 0x00, 0x6A, 0x00, 0x6A, 0x00, 0xEB, 0x0E, 0x5D, + 0xCB, 0x90, 0x90, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, + 0xEB, 0x0A, 0x9A, 0xFF, 0xFF, 0x00, 0x00, 0x83, 0xC4, 0x04, 0x5D, + 0xCB, 0x0B, 0xC2, 0x74, 0x13, 0xA3, 0x9C, 0x65, 0x89, 0x16, 0x9E, + 0x65, 0x89, 0xC3, 0x8E, 0xC2, 0xC6, 0x47, 0x75, 0x00, 0xC6, 0x47, + 0x74, 0x01, 0x5F, 0x5E, 0xC9, 0xCB + ) + } + default { + throw "Unsupported arm mode '$ArmMode'." + } + } +} + +function Get-ConfiguredCandidateProfile { + return $candidateProfiles[$script:configuredProfileKey] +} + +function Set-ConfiguredCandidateProfile { + param([string]$ProfileKey) + + if (-not $candidateProfiles.Contains($ProfileKey)) { + throw "Unknown candidate profile '$ProfileKey'." + } + + $profile = $candidateProfiles[$ProfileKey] + $sites.CtrlQDebuggerInit.Patched = New-CtrlQPatchedBytes -ArmMode ([string]$profile.ArmMode) + $sites.DebuggerCallback.Fixup.PatchedTargetSeg = [int]$profile.UiTargetSeg + $sites.DebuggerCallback.Fixup.PatchedTargetOffset = [int]$profile.UiTargetOffset + $script:configuredProfileKey = $ProfileKey +} + +function Test-CandidateApplied { + param( + [byte[]]$FileBytes, + [string]$ProfileKey + ) + + $previousProfileKey = $script:configuredProfileKey + try { + Set-ConfiguredCandidateProfile -ProfileKey $ProfileKey + return ( + (Get-SiteState -FileBytes $FileBytes -Site $sites.CtrlQDebuggerInit) -eq 'Patched' -and + (Get-SiteState -FileBytes $FileBytes -Site $sites.InterpreterBreakCall) -eq 'Patched' -and + (Get-SiteState -FileBytes $FileBytes -Site $sites.DebuggerCallback) -eq 'Patched' -and + (Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackGuardCode) -eq 'Original' -and + (Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackTargetSlot) -eq 'Original' -and + (Get-SiteState -FileBytes $FileBytes -Site $sites.Wrapper) -eq $(if ($candidateProfiles[$ProfileKey].PatchCurrentUnitWrapper) { 'Patched' } else { 'Original' }) -and + (Get-SiteState -FileBytes $FileBytes -Site $sites.LegacyDeferredWrapper) -eq $(if ($candidateProfiles[$ProfileKey].PatchModalWrapper) { 'Patched' } else { 'Original' }) + ) + } + finally { + Set-ConfiguredCandidateProfile -ProfileKey $previousProfileKey + } +} + +function Get-AppliedCandidateProfileKey { + param([byte[]]$FileBytes) + + foreach ($profileKey in $candidateProfiles.Keys) { + if (Test-CandidateApplied -FileBytes $FileBytes -ProfileKey $profileKey) { + return $profileKey + } + } + + return $null +} + +Set-ConfiguredCandidateProfile -ProfileKey $script:configuredProfileKey + function Format-HexBytes { param([byte[]]$Bytes) @@ -449,6 +774,27 @@ function Get-SiteState { } } + $legacyCandidates = @() + if ($Site.ContainsKey('LegacyPatched')) { + $legacyCandidates += ,([byte[]]$Site.LegacyPatched) + } + if ($Site.ContainsKey('LegacyPatchedVariants')) { + $legacyCandidates += $Site.LegacyPatchedVariants + } + + foreach ($legacyCandidate in $legacyCandidates) { + if (Test-ByteArrayEqual -Left $current -Right $legacyCandidate) { + $legacyFixupState = if ($Site.ContainsKey('LegacyPatchedFixupState')) { [string]$Site.LegacyPatchedFixupState } else { 'Patched' } + if ( + ($legacyFixupState -eq 'Patched' -and $isFixupPatched) -or + ($legacyFixupState -eq 'Original' -and $isFixupOriginal) -or + ($legacyFixupState -eq 'Any' -and ($isFixupPatched -or $isFixupOriginal)) + ) { + return 'LegacyPatched' + } + } + } + return 'Unknown' } @@ -502,18 +848,49 @@ function Set-ByteSlice { function Show-Status { param([byte[]]$FileBytes) + $selectedProfile = Get-ConfiguredCandidateProfile + $appliedProfileKey = Get-AppliedCandidateProfileKey -FileBytes $FileBytes + $appliedProfile = if ($null -ne $appliedProfileKey) { $candidateProfiles[$appliedProfileKey] } else { $null } + + $ctrlQState = Get-SiteState -FileBytes $FileBytes -Site $sites.CtrlQDebuggerInit + $interpreterCallState = Get-SiteState -FileBytes $FileBytes -Site $sites.InterpreterBreakCall + $privateDispatchState = Get-SiteState -FileBytes $FileBytes -Site $sites.DebuggerCallback + $callbackGuardState = Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackGuardCode + $callbackTargetState = Get-SiteState -FileBytes $FileBytes -Site $sites.CallbackTargetSlot $hookState = Get-SiteState -FileBytes $FileBytes -Site $sites.Hook $wrapperState = Get-SiteState -FileBytes $FileBytes -Site $sites.Wrapper + $deferredHookState = Get-SiteState -FileBytes $FileBytes -Site $sites.LegacyDeferredHook + $deferredWrapperState = Get-SiteState -FileBytes $FileBytes -Site $sites.LegacyDeferredWrapper Write-Host '' Write-Host 'CRUSADER.EXE patch status' Write-Host '------------------------' Write-Host ("EXE: {0}" -f $exePath) - Write-Host ("A hook @ 0x{0:X}: {1}" -f $sites.Hook.Offset, $hookState) - Write-Host ("B wrapper @ 0x{0:X}: {1}" -f $sites.Wrapper.Offset, $wrapperState) + Write-Host ("Selected candidate : {0} ({1})" -f $selectedProfile.Label, $selectedProfile.Summary) + if ($null -ne $appliedProfile) { + Write-Host ("Applied candidate : {0} ({1})" -f $appliedProfile.Label, $appliedProfile.Summary) + } + elseif ($ctrlQState -eq 'Original') { + Write-Host 'Applied candidate : Retail/original' + } + else { + Write-Host 'Applied candidate : Unknown' + } + Write-Host ("0x410 init body @ 0x{0:X}: {1}" -f $sites.CtrlQDebuggerInit.Offset, $ctrlQState) + Write-Host ("Interpreter call @ 0x{0:X}: {1}" -f $sites.InterpreterBreakCall.Offset, $interpreterCallState) + Write-Host ("Private UI call @ 0x{0:X}: {1}" -f $sites.DebuggerCallback.Offset, $privateDispatchState) + Write-Host ("Legacy break hook @ 0x{0:X}: {1}" -f $sites.CallbackGuardCode.Offset, $callbackGuardState) + Write-Host ("Legacy target slot @ 0x{0:X}: {1}" -f $sites.CallbackTargetSlot.Offset, $callbackTargetState) + Write-Host ("Current-unit args @ 0x{0:X}: {1}" -f $sites.Wrapper.Offset, $wrapperState) + Write-Host ("Modal wrapper args @ 0x{0:X}: {1}" -f $sites.LegacyDeferredWrapper.Offset, $deferredWrapperState) + Write-Host ("Deferred hook cleanup @ 0x{0:X}: {1}" -f $sites.LegacyDeferredHook.Offset, $deferredHookState) + Write-Host ("Direct hook cleanup @ 0x{0:X}: {1}" -f $sites.Hook.Offset, $hookState) Write-Host '' - Write-Host '1. Apply supported hidden-menu patch (aliases to Experiment B)' - Write-Host '2. Apply Experiment B (retarget + modal arg fix)' + foreach ($profileKey in $candidateProfiles.Keys) { + $profile = $candidateProfiles[$profileKey] + $marker = if ($appliedProfileKey -eq $profileKey) { 'applied' } else { 'ready' } + Write-Host ("{0}. Apply {1} [{2}] {3}" -f $profile.MenuKey, $profile.Label, $marker, $profile.Summary) + } Write-Host '3. Restore original bytes' Write-Host '4. Exit' Write-Host '' @@ -521,95 +898,120 @@ function Show-Status { function Set-DesiredState { param( - [bool]$HookPatched, - [bool]$WrapperPatched, - [string]$Label + [bool]$CtrlQPatched, + [string]$Label, + [string]$ProfileKey ) $fileBytes = [System.IO.File]::ReadAllBytes($exePath) - $stateData = Get-StateData + $appliedProfileKey = Get-AppliedCandidateProfileKey -FileBytes $fileBytes + $profileKeyForStateCheck = if ($null -ne $appliedProfileKey) { $appliedProfileKey } elseif ($CtrlQPatched) { $ProfileKey } else { $script:configuredProfileKey } + $profileKeyForWrite = if ($CtrlQPatched) { $ProfileKey } else { $profileKeyForStateCheck } + + if ([string]::IsNullOrWhiteSpace($profileKeyForStateCheck)) { + throw 'No candidate profile is available for state validation.' + } + + Set-ConfiguredCandidateProfile -ProfileKey $profileKeyForStateCheck + + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.CtrlQDebuggerInit + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.InterpreterBreakCall + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.DebuggerCallback + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.CallbackGuardCode + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.CallbackTargetSlot + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod0 + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod1 + Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.LegacyBreakpointCallback Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.Hook Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.Wrapper Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.LegacyDeferredHook Assert-SiteStateKnown -FileBytes $fileBytes -Site $sites.LegacyDeferredWrapper - $hookCurrent = Get-ByteSlice -Bytes $fileBytes -Offset $sites.Hook.Offset -Count $sites.Hook.Original.Length + Set-ConfiguredCandidateProfile -ProfileKey $profileKeyForWrite + $activeProfile = Get-ConfiguredCandidateProfile + + $ctrlQFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.CtrlQDebuggerInit + $interpreterBreakCallFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.InterpreterBreakCall + $debuggerCallbackFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.DebuggerCallback + $callbackTargetFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.CallbackTargetSlot + $privateMethod0FixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod0 + $privateMethod1FixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.PrivateBreakpointMethod1 + $legacyCallbackFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.LegacyBreakpointCallback $hookFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.Hook - $wrapperCurrent = Get-ByteSlice -Bytes $fileBytes -Offset $sites.Wrapper.Offset -Count $sites.Wrapper.Original.Length $legacyDeferredHookFixupInfo = Get-HookFixupInfo -FileBytes $fileBytes -Site $sites.LegacyDeferredHook - $hookCurrentState = Get-SiteState -FileBytes $fileBytes -Site $sites.Hook - $wrapperCurrentState = Get-SiteState -FileBytes $fileBytes -Site $sites.Wrapper - - $stateChanged = $false - if ($hookCurrentState -eq 'Original') { - $stateChanged = (Save-OriginalBytesIfMissing -StateData $stateData -SiteKey 'Hook' -SiteOffset $sites.Hook.Offset -Bytes $hookCurrent) -or $stateChanged - if (-not $stateData.ContainsKey('HookFixup')) { - $stateData['HookFixup'] = @{ - TargetSeg = $hookFixupInfo.TargetSeg - TargetOffset = $hookFixupInfo.TargetOffset - Reserved = $hookFixupInfo.Reserved - } - $stateChanged = $true - } + $ctrlQBytes = if ($CtrlQPatched) { $sites.CtrlQDebuggerInit.Patched } else { $sites.CtrlQDebuggerInit.Original } + $ctrlQTargetSeg = if ($CtrlQPatched) { [int]$sites.CtrlQDebuggerInit.Fixup.PatchedTargetSeg } else { [int]$sites.CtrlQDebuggerInit.Fixup.OriginalTargetSeg } + $ctrlQTargetOffset = if ($CtrlQPatched) { [int]$sites.CtrlQDebuggerInit.Fixup.PatchedTargetOffset } else { [int]$sites.CtrlQDebuggerInit.Fixup.OriginalTargetOffset } + $interpreterBreakCallBytes = $sites.InterpreterBreakCall.Original + $interpreterBreakCallSeg = [int]$sites.InterpreterBreakCall.Fixup.OriginalTargetSeg + $interpreterBreakCallOffset = [int]$sites.InterpreterBreakCall.Fixup.OriginalTargetOffset + if ($CtrlQPatched) { + $interpreterBreakCallBytes = $sites.InterpreterBreakCall.Patched + $interpreterBreakCallSeg = [int]$sites.InterpreterBreakCall.Fixup.PatchedTargetSeg + $interpreterBreakCallOffset = [int]$sites.InterpreterBreakCall.Fixup.PatchedTargetOffset } - if ($wrapperCurrentState -eq 'Original') { - $stateChanged = (Save-OriginalBytesIfMissing -StateData $stateData -SiteKey 'Wrapper' -SiteOffset $sites.Wrapper.Offset -Bytes $wrapperCurrent) -or $stateChanged + $debuggerCallbackBytes = $sites.DebuggerCallback.Original + $debuggerCallbackSeg = [int]$sites.DebuggerCallback.Fixup.OriginalTargetSeg + $debuggerCallbackOffset = [int]$sites.DebuggerCallback.Fixup.OriginalTargetOffset + if ($CtrlQPatched) { + $debuggerCallbackBytes = $sites.DebuggerCallback.Patched + $debuggerCallbackSeg = [int]$sites.DebuggerCallback.Fixup.PatchedTargetSeg + $debuggerCallbackOffset = [int]$sites.DebuggerCallback.Fixup.PatchedTargetOffset } - if ($stateChanged) { - Save-StateData -StateData $stateData - } - - if ($HookPatched) { - $hookBytes = $sites.Hook.Patched - $hookTargetSeg = [int]$sites.Hook.Fixup.PatchedTargetSeg - $hookTargetOffset = [int]$sites.Hook.Fixup.PatchedTargetOffset - $hookReserved = 0 - } - else { - $hookBytes = Get-SavedOriginalBytes -StateData $stateData -SiteKey 'Hook' -SiteOffset $sites.Hook.Offset - if ($null -eq $hookBytes) { - if ($hookCurrentState -eq 'Original' -or $hookCurrentState -eq 'LegacyBadPatch') { - $hookBytes = $hookCurrent - } - else { - throw 'No saved original bytes are available for the Experiment A hook site. Restore requires either a prior patch run with this script or your full executable backup.' - } - } - - if ($stateData.ContainsKey('HookFixup')) { - $hookTargetSeg = [int]$stateData['HookFixup'].TargetSeg - $hookTargetOffset = [int]$stateData['HookFixup'].TargetOffset - $hookReserved = [int]$stateData['HookFixup'].Reserved - } - else { - $hookTargetSeg = [int]$sites.Hook.Fixup.OriginalTargetSeg - $hookTargetOffset = [int]$sites.Hook.Fixup.OriginalTargetOffset - $hookReserved = 0 - } - } - - if ($WrapperPatched) { - $wrapperBytes = $sites.Wrapper.Patched - } - else { - $wrapperBytes = Get-SavedOriginalBytes -StateData $stateData -SiteKey 'Wrapper' -SiteOffset $sites.Wrapper.Offset - if ($null -eq $wrapperBytes) { - if ($wrapperCurrentState -eq 'Original') { - $wrapperBytes = $wrapperCurrent - } - else { - throw 'No saved original bytes are available for the Experiment B wrapper site. Restore requires either a prior patch run with this script or your full executable backup.' - } - } - } - + $callbackGuardBytes = $sites.CallbackGuardCode.Original + $callbackTargetBytes = $sites.CallbackTargetSlot.Original + $callbackTargetSeg = [int]$sites.CallbackTargetSlot.Fixup.OriginalTargetSeg + $callbackTargetOffset = [int]$sites.CallbackTargetSlot.Fixup.OriginalTargetOffset + $privateMethod0Bytes = $sites.PrivateBreakpointMethod0.Original + $privateMethod0TargetSeg = [int]$sites.PrivateBreakpointMethod0.Fixup.OriginalTargetSeg + $privateMethod0TargetOffset = [int]$sites.PrivateBreakpointMethod0.Fixup.OriginalTargetOffset + $privateMethod1Bytes = $sites.PrivateBreakpointMethod1.Original + $privateMethod1TargetSeg = [int]$sites.PrivateBreakpointMethod1.Fixup.OriginalTargetSeg + $privateMethod1TargetOffset = [int]$sites.PrivateBreakpointMethod1.Fixup.OriginalTargetOffset + $legacyCallbackBytes = $sites.LegacyBreakpointCallback.Original + $legacyCallbackTargetSeg = [int]$sites.LegacyBreakpointCallback.Fixup.OriginalTargetSeg + $legacyCallbackTargetOffset = [int]$sites.LegacyBreakpointCallback.Fixup.OriginalTargetOffset + $hookBytes = $sites.Hook.Original + $hookTargetSeg = [int]$sites.Hook.Fixup.OriginalTargetSeg + $hookTargetOffset = [int]$sites.Hook.Fixup.OriginalTargetOffset + $hookReserved = 0 + $wrapperBytes = if ($CtrlQPatched -and $activeProfile.PatchCurrentUnitWrapper) { $sites.Wrapper.Patched } else { $sites.Wrapper.Original } $legacyDeferredHookBytes = $sites.LegacyDeferredHook.Original $legacyDeferredHookTargetSeg = [int]$sites.LegacyDeferredHook.Fixup.OriginalTargetSeg $legacyDeferredHookTargetOffset = [int]$sites.LegacyDeferredHook.Fixup.OriginalTargetOffset $legacyDeferredHookReserved = 0 - $legacyDeferredWrapperBytes = $sites.LegacyDeferredWrapper.Original + $legacyDeferredWrapperBytes = if ($CtrlQPatched -and $activeProfile.PatchModalWrapper) { $sites.LegacyDeferredWrapper.Patched } else { $sites.LegacyDeferredWrapper.Original } + Set-ByteSlice -Bytes $fileBytes -Offset $sites.CtrlQDebuggerInit.Offset -Value $ctrlQBytes + $fileBytes[$ctrlQFixupInfo.EntryOffset + 4] = [byte]$ctrlQTargetSeg + $fileBytes[$ctrlQFixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($ctrlQFixupInfo.EntryOffset + 6) -Value $ctrlQTargetOffset + Set-ByteSlice -Bytes $fileBytes -Offset $sites.InterpreterBreakCall.Offset -Value $interpreterBreakCallBytes + $fileBytes[$interpreterBreakCallFixupInfo.EntryOffset + 4] = [byte]$interpreterBreakCallSeg + $fileBytes[$interpreterBreakCallFixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($interpreterBreakCallFixupInfo.EntryOffset + 6) -Value $interpreterBreakCallOffset + Set-ByteSlice -Bytes $fileBytes -Offset $sites.DebuggerCallback.Offset -Value $debuggerCallbackBytes + $fileBytes[$debuggerCallbackFixupInfo.EntryOffset + 4] = [byte]$debuggerCallbackSeg + $fileBytes[$debuggerCallbackFixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($debuggerCallbackFixupInfo.EntryOffset + 6) -Value $debuggerCallbackOffset + Set-ByteSlice -Bytes $fileBytes -Offset $sites.CallbackGuardCode.Offset -Value $callbackGuardBytes + Set-ByteSlice -Bytes $fileBytes -Offset $sites.CallbackTargetSlot.Offset -Value $callbackTargetBytes + $fileBytes[$callbackTargetFixupInfo.EntryOffset + 4] = [byte]$callbackTargetSeg + $fileBytes[$callbackTargetFixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($callbackTargetFixupInfo.EntryOffset + 6) -Value $callbackTargetOffset + Set-ByteSlice -Bytes $fileBytes -Offset $sites.PrivateBreakpointMethod0.Offset -Value $privateMethod0Bytes + $fileBytes[$privateMethod0FixupInfo.EntryOffset + 4] = [byte]$privateMethod0TargetSeg + $fileBytes[$privateMethod0FixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($privateMethod0FixupInfo.EntryOffset + 6) -Value $privateMethod0TargetOffset + Set-ByteSlice -Bytes $fileBytes -Offset $sites.PrivateBreakpointMethod1.Offset -Value $privateMethod1Bytes + $fileBytes[$privateMethod1FixupInfo.EntryOffset + 4] = [byte]$privateMethod1TargetSeg + $fileBytes[$privateMethod1FixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($privateMethod1FixupInfo.EntryOffset + 6) -Value $privateMethod1TargetOffset + Set-ByteSlice -Bytes $fileBytes -Offset $sites.LegacyBreakpointCallback.Offset -Value $legacyCallbackBytes + $fileBytes[$legacyCallbackFixupInfo.EntryOffset + 4] = [byte]$legacyCallbackTargetSeg + $fileBytes[$legacyCallbackFixupInfo.EntryOffset + 5] = 0 + Set-U16Le -Bytes $fileBytes -Offset ($legacyCallbackFixupInfo.EntryOffset + 6) -Value $legacyCallbackTargetOffset Set-ByteSlice -Bytes $fileBytes -Offset $sites.Hook.Offset -Value $hookBytes $fileBytes[$hookFixupInfo.EntryOffset + 4] = [byte]$hookTargetSeg $fileBytes[$hookFixupInfo.EntryOffset + 5] = [byte]$hookReserved @@ -624,15 +1026,97 @@ function Set-DesiredState { [System.IO.File]::WriteAllBytes($exePath, $fileBytes) $verifyBytes = [System.IO.File]::ReadAllBytes($exePath) + $ctrlQState = Get-SiteState -FileBytes $verifyBytes -Site $sites.CtrlQDebuggerInit + $interpreterCallState = Get-SiteState -FileBytes $verifyBytes -Site $sites.InterpreterBreakCall + $privateDispatchState = Get-SiteState -FileBytes $verifyBytes -Site $sites.DebuggerCallback + $callbackGuardState = Get-SiteState -FileBytes $verifyBytes -Site $sites.CallbackGuardCode + $callbackTargetState = Get-SiteState -FileBytes $verifyBytes -Site $sites.CallbackTargetSlot $hookState = Get-SiteState -FileBytes $verifyBytes -Site $sites.Hook $wrapperState = Get-SiteState -FileBytes $verifyBytes -Site $sites.Wrapper + $deferredHookState = Get-SiteState -FileBytes $verifyBytes -Site $sites.LegacyDeferredHook + $deferredWrapperState = Get-SiteState -FileBytes $verifyBytes -Site $sites.LegacyDeferredWrapper + $verifiedCtrlQFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.CtrlQDebuggerInit + $verifiedInterpreterBreakCallFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.InterpreterBreakCall + $verifiedDebuggerCallbackFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.DebuggerCallback + $verifiedCallbackTargetFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.CallbackTargetSlot + $verifiedPrivateMethod0FixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.PrivateBreakpointMethod0 + $verifiedPrivateMethod1FixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.PrivateBreakpointMethod1 + $verifiedLegacyCallbackFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.LegacyBreakpointCallback $verifiedHookFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.Hook $verifiedLegacyDeferredHookFixupInfo = Get-HookFixupInfo -FileBytes $verifyBytes -Site $sites.LegacyDeferredHook + $verifiedCtrlQBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.CtrlQDebuggerInit.Offset -Count $ctrlQBytes.Length + $verifiedInterpreterBreakCallBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.InterpreterBreakCall.Offset -Count $interpreterBreakCallBytes.Length + $verifiedDebuggerCallbackBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.DebuggerCallback.Offset -Count $debuggerCallbackBytes.Length + $verifiedCallbackGuardBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.CallbackGuardCode.Offset -Count $callbackGuardBytes.Length + $verifiedCallbackTargetBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.CallbackTargetSlot.Offset -Count $callbackTargetBytes.Length + $verifiedPrivateMethod0Bytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.PrivateBreakpointMethod0.Offset -Count $privateMethod0Bytes.Length + $verifiedPrivateMethod1Bytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.PrivateBreakpointMethod1.Offset -Count $privateMethod1Bytes.Length + $verifiedLegacyCallbackBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.LegacyBreakpointCallback.Offset -Count $legacyCallbackBytes.Length $verifiedHookBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.Hook.Offset -Count $hookBytes.Length $verifiedWrapperBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.Wrapper.Offset -Count $wrapperBytes.Length $verifiedLegacyDeferredHookBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.LegacyDeferredHook.Offset -Count $legacyDeferredHookBytes.Length $verifiedLegacyDeferredWrapperBytes = Get-ByteSlice -Bytes $verifyBytes -Offset $sites.LegacyDeferredWrapper.Offset -Count $legacyDeferredWrapperBytes.Length + if (-not (Test-ByteArrayEqual -Left $verifiedCtrlQBytes -Right $ctrlQBytes)) { + throw 'Ctrl+Q debugger-init body verification failed after write.' + } + + if ($verifiedCtrlQFixupInfo.TargetSeg -ne $ctrlQTargetSeg -or $verifiedCtrlQFixupInfo.TargetOffset -ne $ctrlQTargetOffset) { + throw 'Ctrl+Q debugger-init relocation verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedInterpreterBreakCallBytes -Right $interpreterBreakCallBytes)) { + throw 'Interpreter debugger callsite verification failed after write.' + } + + if ($verifiedInterpreterBreakCallFixupInfo.TargetSeg -ne $interpreterBreakCallSeg -or $verifiedInterpreterBreakCallFixupInfo.TargetOffset -ne $interpreterBreakCallOffset) { + throw 'Interpreter debugger callsite relocation verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedDebuggerCallbackBytes -Right $debuggerCallbackBytes)) { + throw 'Private debugger UI call verification failed after write.' + } + + if ($verifiedDebuggerCallbackFixupInfo.TargetSeg -ne $debuggerCallbackSeg -or $verifiedDebuggerCallbackFixupInfo.TargetOffset -ne $debuggerCallbackOffset) { + throw 'Private debugger UI call relocation verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedCallbackGuardBytes -Right $callbackGuardBytes)) { + throw 'Break-next dispatch patch verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedCallbackTargetBytes -Right $callbackTargetBytes)) { + throw 'Guarded callback target slot bytes verification failed after write.' + } + + if ($verifiedCallbackTargetFixupInfo.TargetSeg -ne $callbackTargetSeg -or $verifiedCallbackTargetFixupInfo.TargetOffset -ne $callbackTargetOffset) { + throw 'Guarded callback target relocation verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedPrivateMethod0Bytes -Right $privateMethod0Bytes)) { + throw 'Private vtable method-0 bytes verification failed after write.' + } + + if ($verifiedPrivateMethod0FixupInfo.TargetSeg -ne $privateMethod0TargetSeg -or $verifiedPrivateMethod0FixupInfo.TargetOffset -ne $privateMethod0TargetOffset) { + throw 'Private vtable method-0 relocation verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedPrivateMethod1Bytes -Right $privateMethod1Bytes)) { + throw 'Private vtable method-1 bytes verification failed after write.' + } + + if ($verifiedPrivateMethod1FixupInfo.TargetSeg -ne $privateMethod1TargetSeg -or $verifiedPrivateMethod1FixupInfo.TargetOffset -ne $privateMethod1TargetOffset) { + throw 'Private vtable method-1 relocation verification failed after write.' + } + + if (-not (Test-ByteArrayEqual -Left $verifiedLegacyCallbackBytes -Right $legacyCallbackBytes)) { + throw 'Legacy callback cleanup bytes verification failed after write.' + } + + if ($verifiedLegacyCallbackFixupInfo.TargetSeg -ne $legacyCallbackTargetSeg -or $verifiedLegacyCallbackFixupInfo.TargetOffset -ne $legacyCallbackTargetOffset) { + throw 'Legacy callback cleanup relocation verification failed after write.' + } + if (-not (Test-ByteArrayEqual -Left $verifiedHookBytes -Right $hookBytes)) { throw 'Hook-site verification failed after write.' } @@ -659,33 +1143,75 @@ function Set-DesiredState { Write-Host '' Write-Host ("Applied: {0}" -f $Label) - Write-Host ("A hook @ 0x{0:X}: {1}" -f $sites.Hook.Offset, $hookState) - Write-Host ("B wrapper @ 0x{0:X}: {1}" -f $sites.Wrapper.Offset, $wrapperState) + Write-Host ("0x410 init body @ 0x{0:X}: {1}" -f $sites.CtrlQDebuggerInit.Offset, $ctrlQState) + Write-Host ("Interpreter call @ 0x{0:X}: {1}" -f $sites.InterpreterBreakCall.Offset, $interpreterCallState) + Write-Host ("Private UI call @ 0x{0:X}: {1}" -f $sites.DebuggerCallback.Offset, $privateDispatchState) + Write-Host ("Legacy break hook @ 0x{0:X}: {1}" -f $sites.CallbackGuardCode.Offset, $callbackGuardState) + Write-Host ("Legacy target slot @ 0x{0:X}: {1}" -f $sites.CallbackTargetSlot.Offset, $callbackTargetState) + Write-Host ("Current-unit args @ 0x{0:X}: {1}" -f $sites.Wrapper.Offset, $wrapperState) + Write-Host ("Modal wrapper args @ 0x{0:X}: {1}" -f $sites.LegacyDeferredWrapper.Offset, $deferredWrapperState) + Write-Host ("Deferred hook cleanup @ 0x{0:X}: {1}" -f $sites.LegacyDeferredHook.Offset, $deferredHookState) + Write-Host ("Direct hook cleanup @ 0x{0:X}: {1}" -f $sites.Hook.Offset, $hookState) Write-Host '' Write-Host 'What this means:' - Write-Host '- Experiment A retargets the existing cheat-success far call into cheat_menu_open_from_current_slot while keeping the original event-dispatch framing.' - Write-Host '- Experiment B preserves the wrapper mode byte `1` but forces the two ambiguous 16-bit constructor parameters to zero instead of inheriting arbitrary caller-frame values.' - Write-Host '- Restore also cleans up the rejected deferred-event patch sites if they were left behind by earlier attempts.' + Write-Host '- Ctrl+Q still goes through the real 0x410 keyboard lane and keeps the original 0x844 cheat gate.' + if ($CtrlQPatched) { + Write-Host ('- The 0x410 body now correctly handles both cases: it arms an existing seg1408 debugger-state object in place, or lazily creates one and stores it at 0x659c/0x659e before arming break-next mode (+0x74=1, +0x75=0).') + Write-Host ("- {0} stops using the unsafe 1478:6597 data slot entirely and retargets the existing interpreter call at 1418:04b5 straight into the corrected private 13e8:232d stub." -f $activeProfile.Label) + if ($activeProfile.PatchCurrentUnitWrapper) { + Write-Host '- The current-unit debugger wrapper at 13a0:0086 has its inherited caller-word pushes zeroed so the callback does not forward the debugger-object pointer as UI arguments.' + } + if ($activeProfile.PatchModalWrapper) { + Write-Host '- The modal debugger wrapper at 13a0:020d has its inherited caller-word pushes zeroed for the same reason.' + } + } + else { + Write-Host '- All debugger patch changes are restored to the retail byte pattern, including the 0x410 body and the interpreter break call at 1418:04b5.' + Write-Host '- Older private-vtable and direct/deferred experiment sites are also written back to retail bytes during restore.' + } Write-Host '' } function Invoke-MenuChoice { param([string]$SelectedChoice) - switch ($SelectedChoice.Trim()) { + switch ($SelectedChoice.Trim().ToLowerInvariant()) { '1' { - Write-Warning 'Experiment A alone is not supported on the cheat-code path. Applying the safer Experiment B patch instead.' - Set-DesiredState -HookPatched $true -WrapperPatched $true -Label 'Experiment B (via menu option 1 alias)' + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)' } '2' { - Set-DesiredState -HookPatched $true -WrapperPatched $true -Label 'Experiment B (A + B)' + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)' + } + 'candidate-i' { + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)' + } + 'candidate-j' { + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)' + } + 'candidate-m' { + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)' + } + 'candidate-n' { + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)' + } + 'candidate-o' { + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-o' -Label 'Ctrl+Q -> interpreter callsite modal stub patch (Candidate O)' + } + 'candidate-p' { + Set-DesiredState -CtrlQPatched $true -ProfileKey 'candidate-p' -Label 'Ctrl+Q -> interpreter callsite current-unit stub patch (Candidate P)' } '3' { - Set-DesiredState -HookPatched $false -WrapperPatched $false -Label 'Restore original bytes' + Set-DesiredState -CtrlQPatched $false -ProfileKey $null -Label 'Restore original bytes' + } + 'restore' { + Set-DesiredState -CtrlQPatched $false -ProfileKey $null -Label 'Restore original bytes' } '4' { return $false } + 'exit' { + return $false + } default { Write-Warning 'Invalid selection.' } @@ -695,7 +1221,7 @@ function Invoke-MenuChoice { } if (-not (Test-Path -LiteralPath $exePath)) { - throw "CRUSADER.EXE was not found next to the script. Put this .ps1 file in the same folder as CRUSADER.EXE." + throw "CRUSADER.EXE was not found at '$exePath'. Pass -ExePath to point at the retail install or place the EXE next to this script." } if ($PSBoundParameters.ContainsKey('Choice')) { @@ -706,7 +1232,7 @@ if ($PSBoundParameters.ContainsKey('Choice')) { :mainloop while ($true) { $currentBytes = [System.IO.File]::ReadAllBytes($exePath) Show-Status -FileBytes $currentBytes - $choice = Read-Host 'Select 1, 2, 3, or 4' + $choice = Read-Host 'Select 1-4' if ([string]::IsNullOrEmpty($choice)) { break mainloop } if (-not (Invoke-MenuChoice -SelectedChoice $choice)) { diff --git a/plan-mid.md b/plan-mid.md index 2beba47..570e209 100644 --- a/plan-mid.md +++ b/plan-mid.md @@ -79,6 +79,38 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan - The next blocker layer is narrower too. Those modal wrappers are not abstract helpers; inside `World_HandleKeyboardInput_13e8_14b4` they already wrap concrete user-facing lanes including exit-to-DOS confirmation (`0x22d`), quick save (`0x13f`), quick load (`0x13e`), restart/main-menu handling (`Game_RestartMaybe`), and the neighboring load/menu gump lanes. Separately, event `0x7e` remains the only other recovered writer of `0x6045` besides `Key_CheckCheatToggle`, so a successful `jassica16` match can still be undone later by that independent runtime path. `Key_CheckCheatToggle` itself is now comment-backed as keydown-only and still requires top-row `1` / `6` scan codes at the tail, leaving keypad digits and other non-matching input routes as a still-live explanation for failed tests. - Cross-game verification against the currently opened `REGRET.EXE` now has a runtime correction too. The F10 branch at `1148:0d0e` still reaches the same modifier helper at `11e0:01a8`, and live testing shows the practical gesture is hold `F10` first and then press `Ctrl`, not `Alt`. The same BIOS-backed helper swap should be verified directly in that target before promoting renames there. The same runtime test also explains the repeated immortality popups: the F10 branch is not debounced, so holding the keys lets repeated F10 keydown events flip immortality on and off multiple times. The real gameplay difference remains the latch code: `1148:34d2` (`Key_CheckSecretCodeSequences`) still contains a `jassica16` table at `1480:2ff0`, but the latch-enabling sequence in No Regret is the second table at `1480:2ffc`, decoded as `loosecannon`, which toggles `1480:0ac0` and mirrors the result into the F10 latch byte `1480:009b`. - Retail hidden-menu patching remains open, but the failed branches are now better separated from the current writable candidate. Verified file/fixup anchors are `0007:0d75` / `0007:0d79` (file `0x70d75` / relocation entry `0x71d68`) and `000c:99dd` / `000c:99e0` (file `0xc99dd`, seg126 chain `0x25e0`). The deferred `0x42f -> 000c:99dd -> 000b:9c0d` design remains explicitly rejected: it visibly entered the hidden UI path, but it halted with the retail `FILE\FLEX.C, line 83` failure and dropped into the quit line, so `0x42f` is the wrong deferred context even though the modal wrapper address itself was valid. The newer direct `0007:0d79 -> 000b:9a86` current-slot retarget with the narrowed `000b:9a8d` arg patch was also runtime-tested and produced no hidden menu, so the writable `/Writable/CRUSADER-PATCHED.EXE` test build is now moved to the next defensible variant instead: restore the direct hook to `000a:5276`, keep the current-slot wrapper unpatched, and retarget the later controller-side `000c:99e0` call to `000b:9c0d` while zeroing only the inherited modal-wrapper words at `000b:9c4a`. +- The next retail test build is narrower still. User runtime feedback on the first `Ctrl+Q` patch is: the mouse pointer appears, then gameplay hangs with only a single right-edge pixel still updating. That makes the remaining failure look more like post-entry control-flow fallout than a bad entry address. The PowerShell patch therefore now also rewrites raw `000c:99e8` / live `13e8:25e8` from `PUSH 0x3e8` to a near jump into the shared epilogue at `13e8:29a7`, so the reused `13e8:25dd` deferred lane exits immediately after the retargeted `13e8:25e0 -> 13a0:020d` call instead of falling through into the original `0x42f` branch tail logic. +- DOSBox-X debugger capture now shows the same hang surviving that tail-skip patch, and the stop point is materially informative: the live runtime state matches seg131 `Interpreter_NextUsecodeOp` at `1418:04c3..051d`, specifically just before the `1408:02f5` call at `1418:051d`. That means the blunt `Ctrl+Q -> 13a0:020d` patch is not merely stuck in the seg109 modal wrapper; it has activated the interpreter-side debugger-state path guarded by non-null `0x659c/0x659e`, and the freeze now looks like a bad or incomplete seg1408 debugger-state lifecycle rather than a simple wrong branch tail. Current best implication: stop iterating on the blunt modal force-open patch and pivot the next patch design toward constructing or safely emulating a real `usecode_debugger_break_state_create` object at `1408:0000` before relying on the seg109 UI lane. +- The next executable patch still follows that pivot, but the boot-time callback rewrite is now explicitly retired. The current PowerShell build repurposes the gated `0x410` body at `13e8:230d` to lazily construct a seg1408 state object through the existing far-call slot at `13e8:2352 -> 1408:0000`, stores the returned far pointer into `0x659c/0x659e`, and then reuses the **second** existing far-call slot in that same body (`13e8:235c`) to jump directly to `usecode_debugger_open_for_current_unit` at `13a0:0086` with zeroed wrapper arguments. This keeps the patch hotkey-local instead of rewriting the shared seg1408 callback table at `1478:65ab`, while the older direct and deferred modal-force-open sites remain restored. +- The callback-table design is now negative evidence rather than the live candidate. Even after fixing an NE-segment indexing mistake (`1478:65ab` had first been retargeted to segment `109` instead of `117` for `13a0:0086`), the global callback rewrite still caused startup failure. The surviving script fixes from that pass remain important: the large `13e8:230d` body must use on-disk `FF FF 00 00` placeholders rather than disassembly-resolved far operands, and its patched byte array must include the final trailing `0xC7` so patch/restore verification matches the retail executable length. With the global callback rewrite removed and the second local call slot retargeted instead, the script now round-trips cleanly on a fresh copied retail EXE (`apply -> patched`, `restore -> original`) and also cleans up the stale old `1478:65ab` callback retarget if that earlier crashing build had already been applied. +- The direct hotkey-to-wrapper retarget is now negative evidence too. The local-call redesign fixed startup and let the game reach gameplay, but pressing `Ctrl+Q` immediately quit through the normal `"No pity. No mercy. No remorse."` shutdown line, which is more consistent with entering the modal UI while the original keypress is still live than with a boot-time relocation problem. The next patch therefore keeps the hotkey-local object creation but stops calling `13a0:0086` on the keypress itself. +- The live candidate is now a per-object callback redirect. The `0x410` body at `13e8:230d` still creates/stores the seg1408 debugger-state object at `0x659c/0x659e`, but the second existing far-call slot in that body (`13e8:235c`) is now retargeted to `1408:0419` (`usecode_debugger_enable_single_step`) instead of directly opening the UI. The created object's first word is rewritten from the shared callback-table offset `0x65ab` to the private relocated slot `0x65af`, and the private dword at `1478:65af` is retargeted from `1408:0474` to `13a0:0086`. That should let the *next* interpreter-side debugger callback open the current-unit UI without inheriting the live `Ctrl+Q` key event, while the original shared `1478:65ab` slot stays restored to the retail no-op. +- The PowerShell patcher now round-trips cleanly for this per-object callback design on a fresh copied retail EXE too: `13e8:230d` body patched/restored, `13e8:235c` step-arm call patched/restored, private callback slot `1478:65af` patched/restored, and legacy shared callback slot `1478:65ab` held at original in both states. +- User runtime on that per-object single-step variant is now also informative negative evidence: the game boots and reaches gameplay, but pressing `Ctrl+Q` produces no visible effect at all, not even the original CD-transfer toast, which implies the hotkey body is being intercepted but the deferred break still is not surfacing. Current best read is that the single-step path at `+0x75` remains gated by the seg1418 nesting counters `+0x76/+0x78` often enough that the callback never fires in the observed test path. +- The live patch candidate therefore now sets **break-next** mode directly instead of single-step mode. The repurposed `13e8:230d` body still constructs/stores the seg1408 debugger-state object and repoints that object to the private callback slot `1478:65af -> 13a0:0086`, but it now writes `+0x75 = 0` and `+0x74 = 1` in the object itself rather than retargeting the second `13e8:235c` call slot to `1408:0419`. That matches the surviving UI-side control path at `13a0:1e5d`, where `+0x74` is the unconditional break-on-next-callback mode while `+0x75` is the nesting-sensitive single-step mode. +- The PowerShell patcher also round-trips cleanly for this break-next design on a fresh copied retail EXE: `13e8:230d` body patched/restored, private callback slot `1478:65af` patched/restored, shared callback slot `1478:65ab` held at original, and the stale second-call-slot cleanup removed from the write path because those bytes now belong to the patched body itself. +- User runtime on that `1478:65af` break-next variant is now negative evidence as well: the game crashes on launch again, so even the supposedly private `65af` slot now looks too globally visible to repurpose. Current best implication is that the object-local `0x65af` first-word rewrite can stay as the arm point, but the actual callback entry must move off the live callback-table dword itself. +- The live candidate is therefore now a **guarded trampoline** at the original seg1408 no-op callback code. The PowerShell patcher keeps the `13e8:230d` break-next object creation/store path, but restores the shared `1478:65ab` slot, stops repointing `1478:65af` to `13a0:0086`, patches `1408:0474` into a tiny guard that returns immediately unless `0x659c/0x659e` is armed, and uses the apparently unused relocated dword at `1478:6597` as the far target slot for `13a0:0086`. This newer `6597`/`1408:0474` build now also round-trips cleanly on a fresh copied retail EXE: `13e8:230d` body patched/restored, guarded callback code at file `0xCEE6F` patched/restored, wrapper target slot at file `0xEA197` patched/restored, and all older direct/deferred experiment sites held at original bytes. +- The root-cause read on the `65af` startup failures is now sharper: `0x65af` is not an alternate vtable base at all. The constructor at `1408:0000` writes `0x65ab` to object offset `+0`, and the dispatch sites prove that object `CALLF [BX]` uses the dword at `65ab -> 1408:046f` while object `CALLF [BX+4]` uses the next dword at `65af -> 1408:0474`. Rewriting the object first word to `0x65af` therefore corrupts the second method lookup (`[BX+4]`) instead of selecting a “private callback table”, which explains the launch-time instability and the other inconsistent runtime fallout from the earlier single-step and break-next builds. +- The live candidate is now the narrower **method-0 deferred callback** design. The PowerShell patcher still keeps the `13e8:230d` lazy object creation/store path and still arms break-next mode by writing `+0x75 = 0` / `+0x74 = 1`, but it explicitly preserves the object's vtable base as `0x65ab`, restores the method-1 helper at `1408:0474`, patches only the method-0 break callback at `1408:046f` to indirect through the spare relocated dword `1478:6597`, and uses that slot as the far target for `13a0:0086`. This corrected `046f`/`6597` build also round-trips cleanly on a fresh copied retail EXE: `13e8:230d` body patched/restored, break callback code at file `0xCEE6F` patched/restored, deferred target slot at file `0xEA197` patched/restored, and all older direct/deferred experiment sites held at original bytes. +- User runtime on that shared-`046f` method-0 build is now negative evidence too: startup still crashes, which makes the shared method body just as globally sensitive as the shared `65ab/65af` vtable slots. Current implication: the deferred path still looks right, but the callback implementation must move to a truly private per-object table instead of any shared seg1408 body or shared vtable dword. +- The live candidate is now a **private two-entry vtable** built from unused relocated dwords with no current data uses. The PowerShell patcher still keeps the `13e8:230d` lazy object creation/store path and still arms break-next mode with `+0x75 = 0` / `+0x74 = 1`, but it now rewrites the created object's vtable base to `0x658f`, retargets private method 0 `1478:658f -> 13a0:0086`, retargets private method 1 `1478:6593 -> 1408:0474`, and leaves the shared callback bodies and shared `65ab/65af` table entries untouched. This private-vtable build also round-trips cleanly on a fresh copied retail EXE: `13e8:230d` body patched/restored, private method 0 slot at file `0xEA18F` patched/restored, private method 1 slot at file `0xEA193` patched/restored, and all older direct/deferred experiment sites held at original bytes. +- User runtime on that first private-vtable placement is now negative evidence too: startup still crashes, which proves the `658f/6593` pair is also startup-visible despite the lack of direct data-use hits. Current best implication is that the private-vtable strategy itself still looks structurally right, but the specific dword pair must move farther away from the debugger-global cluster and any hidden boot-time consumers. +- The full six-candidate private-vtable harness is now retired. User runtime results: + - Candidate A (`1478:6724/6728`) = DOSBox closes on start + - Candidate B (`1478:672c/6730`) = fatal `Load program failed -- error code 201 -- C:\CRUSADER.EXE` + - Candidate C (`1478:6734/6738`) = DOSBox closes on start + - Candidate D (`1478:6718/671c`) = startup crash + - Candidate E (`1478:6720/6724`) = startup crash + - Candidate F (`1478:6738/673c`) = startup crash + Ghidra follow-up now explains why: `1478:6718..673c` is a live function-pointer table containing `UsecodeProcess_*`, `Process_Terminate`, `Process_Fail`, and nearby null handlers, not spare relocated dwords. The script no longer offers those candidates. +- The first guarded shared-callback pair is now negative evidence too. Candidates G/H still crashed on startup, and the best new explanation is structural: that design overwrote both `1408:046f` and the adjacent `1408:0474`, but `0474` is a real helper that returns `DX:AX = 0`, not dead padding. Destroying that zero-return behavior may itself be enough to destabilize startup. +- The method-0-only shared-callback pair is now negative evidence too. User runtime on Patch 1 / Patch 2 showed both startup-crashing, which means preserving `1408:0474` was necessary but not sufficient: the shared `1408:046f` body is still too broad if it jumps straight into debugger UI code. +- The live patch family is now an **interpreter callsite retarget** design. Candidate M/N are retired negative evidence: both startup-crashed, the embedded private stub inside the patched `13e8:230d` body was malformed, and the supposed deferred target slot at `1478:6597` is no longer treated as spare storage. The current PowerShell build still keeps the retail debugger object's real `1478:65ab` vtable base and still arms break-next through the patched `13e8:230d` body, but it now avoids both the shared seg1408 callback bodies and the `1478:6597` data slot entirely. Instead, it patches the existing interpreter `CALLF usecode_debugger_maybe_break_on_current_line` at `1418:04b5` to a corrected private stub at `13e8:232d`, and it also reuses the second retail far-call slot inside `13e8:230d` (`13e8:235c`) as the actual private UI-call target. The `13e8:230d` body itself now correctly handles both cases: reuse and arm an existing debugger-state object at `0x659c/659e`, or lazily create/store one before arming break-next. One implementation bug from the first O/P refactor is now fixed too: the second `13e8:235c` relocation write is part of candidate application and verification, so the live build now really routes to the selected wrapper instead of accidentally leaving that slot on retail `Dispatch_ModalGump`. Current candidates: + - Candidate O = interpreter callsite retarget -> `13a0:020d`, with `13a0:024a` zeroed inherited modal-wrapper words + - Candidate P = interpreter callsite retarget -> `13a0:0086`, with `13a0:008f` zeroed inherited current-unit-wrapper words + Both apply/restore cleanly on a disposable retail copy and are the next runtime tests. +- Full chronology for this patch line now lives in `docs/retail-debugger-patch-attempts.md`, including the failed global callback rewrite, direct wrapper call, single-step `65af` build, break-next `65af` build, guarded `0474` trampoline, shared `046f` method patch, and the current private-vtable candidate. - The hidden-menu orphan model is now materially stronger too. New live renames in seg1408 (`usecode_debugger_break_state_create`, `usecode_debugger_maybe_break_on_current_line`, `usecode_debugger_breakpoint_insert_sorted`, `usecode_debugger_has_breakpoint`, `usecode_debugger_callstack_push_entry`, `usecode_debugger_callstack_pop_entry`, `usecode_debugger_enable_single_step`, `usecode_debugger_clear_step_state`, `usecode_debugger_current_entry_get_unit_name`) line up with the seg109 UI in a way the cheat-only hook never did. The concrete interpreter-side handoff at `1418:04aa..04b5` now calls `usecode_debugger_maybe_break_on_current_line` whenever the far pointer at `0x659c/0x659e` is non-null, and that helper checks `(file,line)` breakpoints before callbacking through the debugger-state object's vtable. Current best read is therefore that the retail orphan happened one layer earlier than the cheat/event experiments: the seg109 current-unit debugger UI likely used to be entered from this seg1408 breakpoint object, but retail no longer appears to instantiate/store that object at `0x659c/0x659e`. That makes the breakpoint callback lane a stronger original-entry candidate than direct event `0x103` retargeting. - The live NE `CRUSADER.EXE` mapping for that hidden-menu lane is now explicit and comment-backed in Ghidra too: direct hook `1130:2b75/2b78`, current-slot wrapper `13a0:0086` with constructor arg site `13a0:008d`, modal wrapper `13a0:020d` with inherited-arg patch subsite `13a0:024a`, listener create/dispatch `13a0:19b1` / `13a0:1df3`, compiled `0x410` CD-transfer-display body `13e8:2303`, deferred controller-side hook `13e8:25dd/25e0`, and the supporting cheat-state data cells at `1020:2833`, `1020:283d`, `1020:0844`, `1020:6045`, `1020:604f`, and `1020:6050`. The `0x410` body is still documented in place rather than renamed because it remains embedded inside the oversized `World_HandleKeyboardInput_13e8_14b4` function object. This improved live handoff and patch reproducibility still does not justify a headline estimate change by itself.