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.