Crusader_Decomp/docs/usecode-debugger-break-state-layout.md
2026-04-09 00:32:12 +02:00

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 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.
  • 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_buffer
    • 13a0: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:0291 resolves the current debugger callstack entry and writes the formatted result into the shared output buffer, while 13a0:045c interprets 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_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
  • A second caller pass on 2026-04-08 still did not widen that lane: get_callers(1408:02f5) still reports only 1418:051d Interpreter_NextUsecodeOp, and the live CallstackPushFrame comment now records that the trailing aux_farptr slot remains literal zero in retail while the current seg109 consumers still only read +0x09 and +0x0d.

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. 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.
  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.