This commit introduces a comprehensive document outlining the various executable-patching attempts aimed at revealing the hidden retail usecode debugger within the CRUSADER.EXE file. The document serves multiple purposes, including preserving negative evidence, recording patch shapes and their rationales, and ensuring that runtime outcomes are linked to specific patch generations. Key sections include: - Ground rules for patching and validation processes. - A table of stable facts regarding the debugger's structure and behavior. - A detailed attempt log documenting each patch's shape, mechanical and runtime results, and verdicts. - Root-cause findings from failed paths, providing insights into the challenges faced during the patching process. - Current live candidates for further testing and exploration. This documentation is intended to streamline future patching efforts and improve the understanding of the underlying mechanics of the debugger.
27 KiB
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 originalon 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 at1478:65ab - object method 1 uses
CALLF [BX+4]and therefore reads the dword at1478: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_20dd1478:671c -> 11e0:112c=Process_Terminate1478:6720 -> 11e0:115c=Process_Fail1478:6724 -> 1020:08cd=nullfn_1020_08cd1478:6728 -> 1420:1162=UsecodeProcess_1420_11621478:672c -> 1420:1278=UsecodeProcess_1420_12781478:6730 -> 1420:118f=UsecodeProcess_1420_118f1478:6734 -> 1020:08d2=nullfn_1020_08d21478:6738 -> 1420:10b6=UsecodeProcess_1420_10b61478: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:046fis the method-0 no-op callback (RETF)1408:0474is the method-1 helper that explicitly returnsDX: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:046funless there is hard evidence that0474can be replaced safely - preserving
0474is 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/659ebefore 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/0x659eis already non-null - it runs only on the explicit break-next path
- it leaves the shared vtable slot
1478:65aband shared method body1408:046funtouched
Interpretation:
- keep the private guard stub at
13e8:2318 - keep the deferred target slot
1478:6597 - move the shared hook from
1408:046fto the break-next branch dispatch inside1408: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:6597as 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:04b5and uses a corrected embedded stub at13e8:232dinstead 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:2352forusecode_debugger_break_state_create13e8:235cfor 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:235cfar-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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0is rewritten from0x65abto private vtable base0x658f - object
+0x75is set to0 - object
+0x74is set to1 - private vtable method 0 at
1478:658fis retargeted to13a0:0086 - private vtable method 1 at
1478:6593is retargeted to retail helper1408: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
0xC970Dpatched/restored0xEA18Fpatched/restored0xEA193patched/restored- legacy cleanup sites held at original bytes
Runtime status:
- startup crash in user runtime
Interpretation:
- the
658f/6593pair 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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0is rewritten from0x65abto private vtable base0x6728 - object
+0x75is set to0 - object
+0x74is set to1 - private vtable method 0 at
1478:6728is retargeted to13a0:0086 - private vtable method 1 at
1478:672cis retargeted to retail helper1408:0474
Mechanical status:
- verified clean on a fresh retail copy
Runtime status:
- startup crash in user runtime
Interpretation:
- the farther pair
6728/672cis 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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0x75is set to0 - object
+0x74is set to1 - the earlier script offered six explicit private-vtable candidates in one script:
- Candidate A: base
1478:6724, method 11478:6728 - Candidate B: base
1478:672c, method 11478:6730 - Candidate C: base
1478:6734, method 11478:6738 - Candidate D: base
1478:6718, method 11478:671c - Candidate E: base
1478:6720, method 11478:6724 - Candidate F: base
1478:6738, method 11478:673c
- Candidate A: base
- each candidate retargets method 0 to
13a0:0086and preserves method 1 on1408:0474 - restore returns the selected pair and the shared
13e8:230dbody 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 201onC:\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..673cband 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:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0x75is set to0 - object
+0x74is set to1 1408:046f/0474are replaced with a 14-byte null-guarded shim:- if
0x659c/0x659e == 0, return immediately - otherwise
CALL FAR [1478:6597]and thenRETF
- if
- Candidate G routes
1478:6597to13a0:020dand zeroes the inherited callback-object pushes in13a0:024a - Candidate H routes
1478:6597to13a0:0086and zeroes the inherited callback-object pushes in13a0:008f
Why this is better than the private-vtable family:
- it preserves the retail debugger-state object layout and the retail
1478:65abvtable 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:0474was also overwritten - the next callback-family patch should preserve the original
0474helper bytes
Interpreter Break-Next Stub Candidates
Current script shape:
13e8:230dstill lazily creates the seg1408 debugger-state object via1408:0000- returned far pointer is stored at
1478:659c/659e - object
+0x75is set to0 - object
+0x74is set to1 - retail
1478:65ab/65afand retail1408:046f/0474are left untouched - only the break-next dispatch bytes at
1408:00d3..00d7are patched, replacing the originalMOV BX, ES:[BX] / CALL FAR [BX]withCALL FAR [1478:6597] 1478:6597no longer points at a UI wrapper; it points at a private guard stub embedded in the patched13e8:230dbody at13e8:2318- that private stub compares the callback object passed on the stack against
1478:659c/659e, clears+0x74/+0x75only on a matching object, and otherwise immediatelyRETFs - the stub uses the second relocated call site already present in
13e8:230dto enter the debugger UI wrapper after the object-identity check passes - Candidate M uses that private call slot for
13a0:020dand zeroes the inherited callback-object pushes in13a0:024a - Candidate N uses that private call slot for
13a0:0086and zeroes the inherited callback-object pushes in13a0: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:046fentirely - 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:0474untouched
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
- Verify whether startup stays clean through gameplay for Candidates M/N.
- If either candidate still fails before gameplay, inspect whether
1478:6597 -> 13e8:2318is being reached from any unexpected non-break-next lane despite the narrower1408:00cfhook. - If gameplay boots but
Ctrl+Qdoes nothing, inspect whether+0x74is being cleared before the first eligible1408:00cfbreak-next dispatch. - If gameplay boots but quits, re-evaluate whether
13a0:0086or13a0:020dstill inherits an unsafe modal/input state even from the interpreter-deferred path. - If the debugger appears partially, capture the first bad UI/control-flow transition instead of changing the patch blind.
- 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.