# Usecode Debugger Break-State Layout ## Purpose This note captures the current class-lift-relevant evidence for the dormant seg1408 debugger-state object. The retail binary still appears to leave this family orphaned at runtime, but the object model itself is strong enough to justify explicit class authoring in Ghidra. Current working family name: - `UsecodeDebuggerBreakState` ## Current Best Class-Level Read `UsecodeDebuggerBreakState` is a retained debugger object that owns: - a small breakpoint table - current interpreted-line state - single-step / break-armed flags - a callstack entry stack - a small vtable-backed break/notify surface used by the interpreter callback lane The compiled interpreter still calls into this object when the global debugger-state pointer is non-null, even though the retail binary no longer seems to instantiate it during normal play. ## Strongest Evidence Anchors ### Constructor #### `1408:0000` `Create` Current verified behavior: - allocates `0x2f2` bytes when `this == null` - writes retail vtable offset `0x65ab` at object `+0x00` - fills the breakpoint-entry region starting at `+0x04` with `0xffff` - clears `+0x02`, `+0x75`, and `+0x7a` - returns the object far pointer in `DX:AX` This is a real constructor-style path, not just a helper wrapper. ### Break gate #### `1408:0053` `MaybeBreakOnCurrentLine` Current verified behavior: - stores the incoming interpreted line minus one at `+0x72` - resolves the current unit-name pointer through `CurrentEntryGetUnitName` - checks file+line breakpoints through `HasBreakpoint` - dispatches through the object vtable when a break condition is met This is the strongest proof that the hidden debugger lane is object-based and that the interpreter-side callback still expects a live debugger object. ### Breakpoint table helpers #### `1408:00dd` `BreakpointInsertSorted` Current verified behavior: - enforces a maximum of ten breakpoint entries - scans the `0x0b`-byte breakpoint-entry table rooted at `+0x04` - compares unit-name strings via the common string helper - inserts a new `(unit_name, line_number)` pair into the sorted table #### `1408:01a5` `BreakpointRemove` Current verified behavior: - scans the same `0x0b`-byte breakpoint-entry table for an exact `(unit_name, line_number)` match - compares the stored inline name bytes first, then the stored line word at entry `+0x09` - compacts the remaining entries downward when a match is found - decrements `breakpoint_count` #### `1408:029e` `HasBreakpoint` Current verified behavior: - scans the same breakpoint-entry table - compares the requested line number against entry `+0x09` - compares the requested unit-name pair against the stored name bytes - returns a boolean-style `uint` in `AX` ### Callstack helpers #### `1408:02f5` `CallstackPushFrame` Current verified behavior: - computes the current callstack-entry base as `this + 0x7c + callstack_depth * 0x15` - copies an inline unit-name buffer into entry `+0x00` - enforces a maximum visible unit-name length of eight characters plus terminator - stores three trailing far-pointer/state dwords at `+0x09`, `+0x0d`, and `+0x11` - increments `callstack_depth` #### `1408:03b0` `CallstackPushEntry` Current verified behavior: - uses `+0x7a` as the current callstack depth - acts as a thinner wrapper over `CallstackPushFrame` when only the inline unit-name payload matters - increments the depth and asserts when it reaches `0x1e` #### `1408:03f7` `CallstackPopEntry` Current verified behavior: - decrements `+0x7a` - asserts if the depth underflows below zero ### Step-state helpers #### `1408:0419` `EnableSingleStep` - clears `+0x76/+0x78` - sets `+0x75 = 1` #### `1408:0432` `ClearStepState` - clears `+0x74` - clears `+0x75` ### Current-entry name accessor #### `1408:0444` `CurrentEntryGetUnitName` Current verified behavior: - returns null when `callstack_depth <= 0` - otherwise returns a far pointer to the current callstack entry's inline unit-name buffer ## Recovered Entry Schemas The live debugger-state model is now strong enough to split the old table blobs into concrete fixed-size entry records. ### `UsecodeDebuggerBreakpointEntry` (`0x0b` bytes) | Offset | Current name | Confidence | Current meaning | |---|---|---|---| | `+0x00..+0x08` | `unit_name_inline[9]` | High | Inline unit-name buffer, consistent with eight visible characters plus terminator. | | `+0x09` | `line_number` | High | Breakpoint line number compared by `BreakpointInsertSorted`, `BreakpointRemove`, and `HasBreakpoint`. | ### `UsecodeDebuggerCallstackEntry` (`0x15` bytes) | Offset | Current name | Confidence | Current meaning | |---|---|---|---| | `+0x00..+0x08` | `unit_name_inline[9]` | High | Inline unit-name buffer for the active frame. | | `+0x09` | `source_stream_cursor_farptr` | High | Far pointer to the current debugger/source descriptor stream cursor. `Debugump_13a0_0291` passes this lane into `13a0:045c`, which reads descriptor bytes and inline strings directly from it. This field name is now promoted into the live datatype in `CRUSADER.EXE`, not just the note set. | | `+0x0d` | `current_frame_payload_farptr` | High | Far pointer to the current frame payload/evaluation context at `frame_base + 0x04`. `13a0:045c` dereferences this lane while formatting debugger dump/watch text. | | `+0x11` | `aux_farptr` | Low | Trailing auxiliary far pointer slot; still zero in the only verified `CallstackPushFrame` caller and still lacks a confirmed current-entry consumer. | ## Current Working Layout The live datatype `/Remorse/UsecodeDebuggerBreakState` now exists in-session with the currently safest anchors: | Offset | Current name | Confidence | Current meaning | |---|---|---|---| | `+0x00` | `vtable_offset` | High | Retail debugger-state vtable offset `0x65ab`. | | `+0x02` | `breakpoint_count` | High | Count of `0x0b`-byte breakpoint entries. | | `+0x04..+0x71` | `breakpoint_entries[10]` | High | Ten inline `UsecodeDebuggerBreakpointEntry` records. | | `+0x72` | `current_line` | High | Current interpreted line minus one. | | `+0x74` | `break_armed` | Medium | Break/armed flag cleared by `ClearStepState`. | | `+0x75` | `single_step_enabled` | High | Single-step flag set by `EnableSingleStep`. | | `+0x76/+0x78` | `step_state_lo/hi` | Medium | Step-state pair cleared by `EnableSingleStep`. | | `+0x7a` | `callstack_depth` | High | Current callstack depth. | | `+0x7c..+0x2f1` | `callstack_entries[30]` | High | Thirty inline `UsecodeDebuggerCallstackEntry` records. | ## Live Ghidra Authoring Status Verified first live class batch landed on 2026-04-06. - Created class owner `Remorse::UsecodeDebuggerBreakState`. - Created `/Remorse/UsecodeDebuggerBreakpointEntry` (`0x0b`) and `/Remorse/UsecodeDebuggerCallstackEntry` (`0x15`) in the live data-type manager. - Rewrote `/Remorse/UsecodeDebuggerBreakState` in-session so the old blob regions are now explicit `UsecodeDebuggerBreakpointEntry[10]` and `UsecodeDebuggerCallstackEntry[30]` arrays at the recovered offsets. - Moved the main seg1408 helpers under the class owner: - `1408:0000` -> `Create` - `1408:0053` -> `MaybeBreakOnCurrentLine` - `1408:00dd` -> `BreakpointInsertSorted` - `1408:01a5` -> `BreakpointRemove` - `1408:0230` -> `BreakpointFindFirstForUnitAtOrAfterLine` - `1408:029e` -> `HasBreakpoint` - `1408:02f5` -> `CallstackPushFrame` - `1408:03b0` -> `CallstackPushEntry` - `1408:03f7` -> `CallstackPopEntry` - `1408:0419` -> `EnableSingleStep` - `1408:0432` -> `ClearStepState` - `1408:0444` -> `CurrentEntryGetUnitName` - Promoted the remaining bounded breakpoint-table helper `1408:0230` under the class owner as `BreakpointFindFirstForUnitAtOrAfterLine`; it now reads as the lower-bound search over `breakpoint_entries` for a given `(unit_name, line_number)` query instead of an anonymous seg1408 utility. - Resolved the retail debugger vtable root at `1478:65ab` one step further: slot 0 points to `1408:046f` and slot 1 points to `1408:0474`, and both entries are now class-owned as `OnBreakTriggeredNoop` and `VtableSlot1ReturnZero` with explicit comments that the shipped retail implementations are inert stubs. - Tightened the live method signatures to explicit object-style forms, including: - `UsecodeDebuggerBreakState * __cdecl16far Create(UsecodeDebuggerBreakState * this, dword init_spec)` - `void __cdecl16far MaybeBreakOnCurrentLine(UsecodeDebuggerBreakState * this, word current_line)` - `byte __cdecl16far BreakpointInsertSorted(UsecodeDebuggerBreakState * this, dword unit_name_farptr, word line_number)` - `void __cdecl16far BreakpointRemove(UsecodeDebuggerBreakState * this, dword unit_name_farptr, word line_number)` - `int __cdecl16far BreakpointFindFirstForUnitAtOrAfterLine(UsecodeDebuggerBreakState * this, dword unit_name_farptr, word line_number)` - `uint __cdecl16far HasBreakpoint(UsecodeDebuggerBreakState * this, dword unit_name_farptr, word line_number)` - `void __cdecl16far CallstackPushFrame(UsecodeDebuggerBreakState * this, dword unit_name_farptr, dword source_stream_target_farptr, dword current_frame_payload_farptr, dword aux_farptr)` - `byte __cdecl16far CallstackPushEntry(UsecodeDebuggerBreakState * this, dword unit_name_farptr)` - `void __cdecl16far CallstackPopEntry(UsecodeDebuggerBreakState * this)` - `void __cdecl16far EnableSingleStep(UsecodeDebuggerBreakState * this)` - `void __cdecl16far ClearStepState(UsecodeDebuggerBreakState * this)` - `dword __cdecl16far CurrentEntryGetUnitName(UsecodeDebuggerBreakState * this)` - The only verified `CallstackPushFrame` caller at `1418:051d` is now constrained a little more tightly in-session too: the trailing payload is still best kept as `source_stream_target_farptr`, `current_frame_payload_farptr`, and `aux_farptr`, but the raw stack setup now confirms that the last slot is literal zero in retail, the middle slot is pushed from `frame_base + 0x04`, and the first slot is pushed from the interpreter `+0xd6/+0xd8` source-stream lane plus a local offset. - The next seg109 consumer pass closed the main follow-up from that batch. `13a0:0291 Debugump_13a0_0291` now has a decompiler comment showing that it resolves the current callstack entry as `this + 0x67 + depth * 0x15`, passes entry `+0x09` as the descriptor/source-stream cursor, and passes entry `+0x0d` as the frame payload context into `13a0:045c`. - `13a0:045c FUN_13a0_045c` now also carries a decompiler comment recording the same split from the consumer side: it reads descriptor bytes and inline strings directly from the `+0x09` lane and dereferences the `+0x0d` lane as the evaluation context while formatting debugger dump/watch text into the shared output buffer at `0x45a6`. - The `CallstackPushFrame` comment in seg1408 was updated to reflect that narrower live read: `+0x09` is no longer just a generic source-derived far pointer, but a real source-stream cursor used by the seg109 formatter path. - That naming decision is now applied live too. `/Remorse/UsecodeDebuggerCallstackEntry` now exposes `source_stream_cursor_farptr` at offset `+0x09` with a matching field comment in the datatype manager, and `CallstackPushFrame` now uses `source_stream_cursor_farptr` in its parameter list instead of the older `source_stream_target_farptr` wording. - Added decompiler comments on the breakpoint and callstack helpers so the recovered inline-record layout is visible in-session even before every field is formally typed. - Added decompiler comments on the only verified `Interpreter_NextUsecodeOp` caller of `CallstackPushFrame`, which confirms the current live read of the three trailing callstack dwords: - `source_stream_target_farptr` is source-stream-derived from the interpreter `+0xd6/+0xd8` lane plus one fetched word - `current_frame_payload_farptr` is current-frame-derived from the `frame_base + 0x04` lane - `aux_farptr` is still zero in the only verified caller ## Current Cautions - The retail instantiation path still appears absent; no normal caller currently reaches `Create` in the unpatched retail binary. - The record boundaries inside both tables are now landed in the live datatype, and the first two trailing callstack dwords now have both producer-side and seg109 consumer-side structural evidence plus live datatype names/comments. The remaining uncertainty is no longer whether those lanes are meaningful, but whether they should ever become more subsystem-specific than `source_stream_cursor_farptr` and `current_frame_payload_farptr`. - The retail vtable root is no longer an open slot-map question for the first two entries. What remains open is whether any non-retail or UI-side build ever installed non-stub behavior behind those same callback positions. - `init_spec` on `Create` and `unit_name_farptr` on the breakpoint/callstack helpers are intentionally neutral names; the live signatures are object-correct, but the payload semantics should stay conservative. ## Best Next Moves 1. Cross-check the seg1408 class note against the interpreter callback site at `1418:04b5` so the dormant-orphan lifecycle remains explicit in the live notes now that slot 0 is confirmed to land on an inert retail stub. 2. Decide whether `aux_farptr` should remain neutral or can be promoted after one more caller or consumer pass. 3. Decide whether `13a0:0291` and `13a0:045c` are ready for stable debugger-UI names or should remain comment-backed until another direct caller or string anchor lands. 4. If the debugger family stalls there, switch to the next planned class-lift family instead of overworking this orphaned subsystem. ## Bottom Line `UsecodeDebuggerBreakState` is now past the “interesting orphan subsystem” stage and into real class territory. The live database now has a bounded debugger-state class with a constructor, breakpoint gate, breakpoint table helpers, callstack helpers, explicit recovered `0x0b` / `0x15` entry schemas, and step-state helpers, even though the retail game still appears to leave that object dormant.