15 KiB
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
0x2f2bytes whenthis == null - writes retail vtable offset
0x65abat object+0x00 - fills the breakpoint-entry region starting at
+0x04with0xffff - 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
uintinAX
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
+0x7aas the current callstack depth - acts as a thinner wrapper over
CallstackPushFramewhen 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/UsecodeDebuggerBreakStatein-session so the old blob regions are now explicitUsecodeDebuggerBreakpointEntry[10]andUsecodeDebuggerCallstackEntry[30]arrays at the recovered offsets. - Moved the main seg1408 helpers under the class owner:
1408:0000->Create1408:0053->MaybeBreakOnCurrentLine1408:00dd->BreakpointInsertSorted1408:01a5->BreakpointRemove1408:0230->BreakpointFindFirstForUnitAtOrAfterLine1408:029e->HasBreakpoint1408:02f5->CallstackPushFrame1408:03b0->CallstackPushEntry1408:03f7->CallstackPopEntry1408:0419->EnableSingleStep1408:0432->ClearStepState1408:0444->CurrentEntryGetUnitName
- Promoted the remaining bounded breakpoint-table helper
1408:0230under the class owner asBreakpointFindFirstForUnitAtOrAfterLine; it now reads as the lower-bound search overbreakpoint_entriesfor a given(unit_name, line_number)query instead of an anonymous seg1408 utility. - Resolved the retail debugger vtable root at
1478:65abone step further: slot 0 points to1408:046fand slot 1 points to1408:0474, and both entries are now class-owned asOnBreakTriggeredNoopandVtableSlot1ReturnZerowith 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
CallstackPushFramecaller at1418:051dis now constrained a little more tightly in-session too: the trailing payload is still best kept assource_stream_target_farptr,current_frame_payload_farptr, andaux_farptr, but the raw stack setup now confirms that the last slot is literal zero in retail, the middle slot is pushed fromframe_base + 0x04, and the first slot is pushed from the interpreter+0xd6/+0xd8source-stream lane plus a local offset. - The next seg109 consumer pass closed the main follow-up from that batch.
13a0:0291 Debugump_13a0_0291now has a decompiler comment showing that it resolves the current callstack entry asthis + 0x67 + depth * 0x15, passes entry+0x09as the descriptor/source-stream cursor, and passes entry+0x0das the frame payload context into13a0:045c. 13a0:045c FUN_13a0_045cnow also carries a decompiler comment recording the same split from the consumer side: it reads descriptor bytes and inline strings directly from the+0x09lane and dereferences the+0x0dlane as the evaluation context while formatting debugger dump/watch text into the shared output buffer at0x45a6.- The
CallstackPushFramecomment in seg1408 was updated to reflect that narrower live read:+0x09is 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/UsecodeDebuggerCallstackEntrynow exposessource_stream_cursor_farptrat offset+0x09with a matching field comment in the datatype manager, andCallstackPushFramenow usessource_stream_cursor_farptrin its parameter list instead of the oldersource_stream_target_farptrwording. - The seg109 consumer helpers are now named live as stable free functions rather than left comment-only:
13a0:0291->usecode_debugger_format_expression_to_shared_buffer13a0:045c->usecode_debugger_format_descriptor_expression
- Those names are still intentionally conservative. They record the verified behavior now visible in both producer-side and consumer-side comments:
13a0:0291resolves the current debugger callstack entry and writes the formatted result into the shared output buffer, while13a0:045cinterprets the descriptor/source-stream cursor plus frame payload context rather than merely dumping raw bytes. - 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_NextUsecodeOpcaller ofCallstackPushFrame, which confirms the current live read of the three trailing callstack dwords:source_stream_target_farptris source-stream-derived from the interpreter+0xd6/+0xd8lane plus one fetched wordcurrent_frame_payload_farptris current-frame-derived from theframe_base + 0x04laneaux_farptris still zero in the only verified caller
- A second caller pass on 2026-04-08 still did not widen that lane:
get_callers(1408:02f5)still reports only1418:051d Interpreter_NextUsecodeOp, and the liveCallstackPushFramecomment now records that the trailingaux_farptrslot remains literal zero in retail while the current seg109 consumers still only read+0x09and+0x0d.
Current Cautions
- The retail instantiation path still appears absent; no normal caller currently reaches
Createin 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_farptrandcurrent_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_speconCreateandunit_name_farptron the breakpoint/callstack helpers are intentionally neutral names; the live signatures are object-correct, but the payload semantics should stay conservative.
Best Next Moves
- Cross-check the seg1408 class note against the interpreter callback site at
1418:04b5so the dormant-orphan lifecycle remains explicit in the live notes now that slot 0 is confirmed to land on an inert retail stub. - Decide whether
aux_farptrshould remain neutral or can be promoted after one more caller or consumer pass. - If the debugger family is revisited again, prioritize non-retail callback behavior or instantiation evidence before spending more time on the already-stable retail formatter lane.
- 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.