Skip to content

Unwind to the depth the stack really has, and say how each frame was found - #17

Merged
macabeus merged 2 commits into
mainfrom
deeper-call-stack
Sep 12, 2026
Merged

Unwind to the depth the stack really has, and say how each frame was found#17
macabeus merged 2 commits into
mainfrom
deeper-call-stack

Conversation

@macabeus

Copy link
Copy Markdown
Owner

What the stack showed before

Exactly two frames, wherever .debug_frame ran out — which on a GBA is most of
the stack. agbcc emits no call-frame information at all; a devkitARM table
covers the project's own C and stops at the edge of libgba, newlib and crt0.
Past the table the unwinder pushed one caller guessed from the link register
and stopped.

ROM, stop before after
kleod, interrupts.c:11 (in an IRQ) 1 5
kleod, m4a.c:94 (in the VBlank IRQ) 1 6
kleod, math.c:17 2 3
kleod, wait_for_next_frame.c:50 2 (one wrong) 2 (correct)
balatro-gba, sprite.c:60 10 11
balatro-gba, splash_screen.c:34 5 6
balatro-gba, paused in the BIOS wait 2 3

The last kleod row is the more interesting kind of fix. That stack really does
hold one return address, so depth 2 was the right depth — but frame #1 was
sub_08005CF4, read from a stale link register: a wrong answer presented as
a frame. It is now AgbMain, read from the slot it was saved in.

What it shows now

Unwinding is an ordered sequence of layers, tried per frame, where a layer
declining ends that layer's turn rather than the walk:

  1. the teardown the function has left to run. First, because gcc's
    .debug_frame is synchronous: its rows track the prologue and stop, so from
    the first pop onward the table gives a CFA a whole frame too high and reads
    the return address out of a slot that has already been popped.
  2. call-frame information, everywhere else.
  3. an exception boundary. A handler whose return address is a BIOS address
    is not a caller, so the interrupt stub's pushed block, the saved status
    register and the interrupted mode's banked stack pointer are read to continue
    onto the stack the interrupted code was using. An interrupt handler used to be
    one frame with nothing above it.
  4. the callee's own prologue, decoded to measure its frame, with each saved
    word attributed to the register whose value it holds — which is what makes
    both toolchains' real prologues work, since one copies a high register into a
    low one before pushing it and one parks r11 in lr, so the top slot of a push
    with the LR bit set is not always the return address. Every instruction is read
    in the instruction set the ELF records at its own address, because a function
    can change instruction set in its middle.
  5. the link register, but only where the decode proves it has not been
    spilled. An unproven lr is the return of a call the function already made —
    which reads as a caller and is in fact a function that has already returned.
  6. a stack word, last, and only one that is in an executable section, agrees
    with the instruction set the ELF records there, and has a real call ending
    exactly at it that could have reached the frame below.

The depth bound is the top of the stack the mode is on — the modes are stacked
on each other at the top of IWRAM — falling back to the region a relocated stack
pointer is in. Not a constant 32.

How inferred frames are marked

The project's rule is that the analysis surface refuses or flags what it cannot
answer rather than returning a plausible value, so:

  • every frame carries the layer that produced it (StackFrame.method, with
    frameConfidence / FRAME_METHODS making each layer declare what it is worth,
    so a new one cannot default to being presented as fact) and a sentence about
    what in it is not established (StackFrame.doubt);
  • a walk carries why it ended (Session.stack().end), which the adapter shows as
    a final label row — this frame's stack pointer is at the top of its stack, so nothing called it, or the outermost frame's saved return address reads 0;
  • a frame nothing could establish ends the walk with a sentence instead of a
    plausible caller. On balatro-gba's interrupt dispatcher, which pushes its block
    across a mode switch and so has no frame a stack pointer can measure, that is
    one frame and a reason — where a link register would have named the interrupted
    code as the dispatcher's caller;
  • a stack word that passes every test except the chain test is reported as the
    ancestor it is, carrying the sentence that says the frames between it and the
    frame below are missing.

Correct variables for any frame

A caller's registers used to be copied verbatim from the callee, so r0–r12
showed the callee's values as facts and no caller's stack-resident local was
readable at all. Now r4–r11 come out of the slots the callee saved them in, r13
is the frame's own address, and r0–r3 and r12 — the callee's under the ABI —
report that they were not recovered instead of showing a plausible number. A
variable whose location needs a register that was not recovered says so rather
than reading a wrong address.

Also here: three claims the BIOS region could make are refused (a linker-discarded
entry keeps its size with its address zeroed, so both a subprogram DIE and an FDE
claimed a range over the exception stubs, and Program.isNamedCode vouched for
addresses below 0x4000), while a name is no longer required of a return address
the unwinder derived — crt0's bl main returns into a NOTYPE symbol of size 0, and
dropping that frame loses the bottom of every stack. And step-out runs to a caller
whose address and stack pointer are both established, so stepping out of a function
called inside an interrupt handler stops in the handler rather than outside it.

Verified

pnpm turbo build test check-types lint check-deps --force and
pnpm run format:check: green, 43/43 tasks, 1032 tests passing. A new fixture
built with objcopy --remove-section=.debug_frame covers the no-CFI path, so the
agbcc shape is tested and not only observed.

On both real ROMs, driven through the public Session API and compared against
a worktree of main, then ground-truthed by dumping raw stack words with the symbol
each lands in:

  • Klonoa decomp (no .debug_frame at all) — the numbers above. At
    interrupts.c:11 the stub's pushed lr at 0x3007f9c is 0x8051450, and
    0x8051450 − 4 (the IRQ return bias) is exactly the recovered
    VBlankIntrWait+0x4 @0x805144c.
  • balatro-gba (a 13 KB partial .debug_frame) — the added bottom frame is
    start_vector+0xa6 @0x8000186, confirmed by sp+0xc holding 0x8000187.

A 24-stop sweep across both ROMs showed depths 2–11 with no scan or guess
frames on either real ROM.

Non-top frames over the DAP wire, through the built dist/cli.js adapter with
real Content-Length framing:

  • balatro at sprite.c:60, totalFrames=12: scopes(frameId: 1) gives frame 1's
    own locals (card_object=0x03002ac4), evaluate card_object @frame 1 answers
    0x03002ac4 while the same name @frame 0 is unknown symbol, and r4 is
    genuinely per-frame (0x100, 0x03002ac4, 0x03004028 at frames 0/1/2).
    evaluate new_game_state @frame 4 answers <r1 not recoverable in this frame>
    a refusal, not a plausible number.
  • kleod at interrupts.c:11, totalFrames=6: scopes(frameId: 4), five frames up
    and across the interrupt boundary, gives AgbMain's own i=2.

🤖 Generated with Claude Code

macabeus and others added 2 commits September 12, 2026 17:58
…found

The call stack answered with exactly two frames wherever `.debug_frame` ran
out, which on a GBA is most of the stack: agbcc emits no call-frame
information at all, and a devkitARM table covers the project's own C and stops
at the edge of libgba, newlib and crt0. Past that the unwinder pushed one
caller guessed from the link register and stopped.

Unwinding is now an ordered sequence of layers, and a layer declining ends
that layer's turn rather than the walk: the teardown a function has left to
run (first, because gcc's call-frame rows are synchronous — from the first
`pop` onward the table gives a CFA a whole frame too high and reads the return
address out of a slot already popped); call-frame information everywhere else;
an exception boundary, where a handler whose return address is a BIOS address
is not a caller, so the stub's pushed block, the saved status register and the
interrupted mode's banked stack pointer carry the walk onto the stack the
interrupted code was using; the callee's own prologue, decoded to measure its
frame with each saved word attributed to the register whose value it holds;
the link register, but only where the decode proves it has not been spilled;
and last a stack word, only one in an executable section, in the instruction
set the ELF records there, with a real call ending exactly at it that could
have reached the frame below.

The bound is the top of the stack the mode is on, not a constant 32, and a
frame nothing could establish ends the walk with a sentence rather than a
plausible caller. Every frame carries the layer that produced it
(`StackFrame.method`) and what in it is not established (`StackFrame.doubt`),
and a walk carries why it ended (`Session.stack().end`), which the adapter
shows as a final label row: inference still declares itself instead of passing
as fact.

A caller's registers are the caller's now. They used to be copied verbatim
from the callee, so r0-r12 showed the callee's values and no caller's
stack-resident local was readable at all. r4-r11 come out of the slots the
callee saved them in, r13 is the frame's own address, and r0-r3 and r12 —
the callee's under the ABI — report that they were not recovered. Selecting any
frame gives that frame's variables, and a variable whose location needs a
register that was not recovered says so rather than reading a wrong address.

Three claims the BIOS region could make are refused: an entry the linker
discarded keeps its size with its address zeroed, so both a subprogram DIE and
an FDE claimed a range over the exception stubs, and `Program.isNamedCode`
vouched for addresses below 0x4000. A name is no longer required of a return
address the unwinder derived, though — crt0's `bl main` returns into a NOTYPE
symbol of size 0, and dropping that frame loses the bottom of every stack — so
a name is corroboration and its absence is stated on the frame instead.

Step-out runs to a caller whose address and stack pointer are both
established, and leaves an exception handler by its mode changing back only
where that is the caller it found, so stepping out of a function called inside
an interrupt handler stops in the handler.

Verified on both real ROMs. On the Klonoa decomp, which has no `.debug_frame`,
a stop in an interrupt handler went from one frame to five across the
interrupt boundary, and the frame that main used to be given from a stale link
register (`sub_08005CF4`) is now `AgbMain` read from the slot it was saved in.
On balatro-gba a stop in `sprite.c` reaches eleven frames, ending at
`start_vector` confirmed against the raw stack word. Non-top frames were
checked over the wire through the built adapter: frame 4's own `i` five frames
up and across the interrupt, and a refusal, not a number, where a location
needs a register the ABI did not preserve. A new fixture built without
`.debug_frame` covers the no-CFI path in tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two unwinder tests named addresses and a frame size taken from the devkitARM
fixture as this machine built it. CI rebuilds that fixture with its own
arm-none-eabi-gcc, whose `bump` has a different layout, so the saved lr was not
where the test stubbed it and the walk fell through to the scan layer: green
here, red there.

They now read what they need out of the ELF — the address past the prologue from
where `.debug_frame` moves the CFA off sp, the return address from the `bl` in
main that reaches bump — and assert the relationships instead of the numbers: the
prologue decoder agrees with `.debug_frame` about the CFA, and it found the saved
lr. The agbcc tests keep naming addresses, which is sound there because CI pins
that compiler to a revision it caches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@macabeus
macabeus merged commit b61449b into main Sep 12, 2026
2 checks passed
@macabeus
macabeus deleted the deeper-call-stack branch September 12, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant