Unwind to the depth the stack really has, and say how each frame was found - #17
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What the stack showed before
Exactly two frames, wherever
.debug_frameran out — which on a GBA is most ofthe 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.
interrupts.c:11(in an IRQ)m4a.c:94(in the VBlank IRQ)math.c:17wait_for_next_frame.c:50sprite.c:60splash_screen.c:34The 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 asa 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:
.debug_frameis synchronous: its rows track the prologue and stop, so fromthe first
poponward the table gives a CFA a whole frame too high and readsthe return address out of a slot that has already been popped.
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.
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.
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.
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:
StackFrame.method, withframeConfidence/FRAME_METHODSmaking 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);Session.stack().end), which the adapter shows asa final label row —
this frame's stack pointer is at the top of its stack, so nothing called it, orthe outermost frame's saved return address reads 0;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;
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.isNamedCodevouched foraddresses below 0x4000), while a name is no longer required of a return address
the unwinder derived — crt0's
bl mainreturns into a NOTYPE symbol of size 0, anddropping 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 --forceandpnpm run format:check: green, 43/43 tasks, 1032 tests passing. A new fixturebuilt with
objcopy --remove-section=.debug_framecovers the no-CFI path, so theagbcc shape is tested and not only observed.
On both real ROMs, driven through the public
SessionAPI and compared againsta worktree of
main, then ground-truthed by dumping raw stack words with the symboleach lands in:
.debug_frameat all) — the numbers above. Atinterrupts.c:11the stub's pushed lr at0x3007f9cis0x8051450, and0x8051450 − 4(the IRQ return bias) is exactly the recoveredVBlankIntrWait+0x4 @0x805144c..debug_frame) — the added bottom frame isstart_vector+0xa6 @0x8000186, confirmed bysp+0xcholding0x8000187.A 24-stop sweep across both ROMs showed depths 2–11 with no
scanorguessframes on either real ROM.
Non-top frames over the DAP wire, through the built
dist/cli.jsadapter withreal Content-Length framing:
sprite.c:60,totalFrames=12:scopes(frameId: 1)gives frame 1'sown locals (
card_object=0x03002ac4),evaluate card_object @frame 1answers0x03002ac4while the same name@frame 0isunknown symbol, andr4isgenuinely per-frame (
0x100,0x03002ac4,0x03004028at frames 0/1/2).evaluate new_game_state @frame 4answers<r1 not recoverable in this frame>—a refusal, not a plausible number.
interrupts.c:11,totalFrames=6:scopes(frameId: 4), five frames upand across the interrupt boundary, gives
AgbMain's owni=2.🤖 Generated with Claude Code