Evaluate expressions the way C spells them: a[i], p->m, *p, (T *)x - #18
Merged
Conversation
The expression grammar is about to name a bitfield member and a value the compiler keeps in a register, and both have to read exactly as the variables tree reads them — a debugger that shows `9 (4 bits)` in one pane and a raw storage word in another is showing two different programs. `formatBitfield` is `memberNode`'s bitfield branch, parameterised on the storage bytes rather than on a struct's, and `le32` — the four bytes of a 32-bit word — moves from `scopes.ts` to `values.ts` beside it. No behaviour changes here; both are lifted so there is one of each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`gEntityInfo[arg0].xPosBg2` was refused. So was `p->hp`, `*p`, and any index that was not a literal — with a hint telling the user to work out the address themselves and read it with `u32()`. That is arithmetic the debugger already has the DWARF to do, and every one of those forms is what the source in front of the user actually says. The grammar now has C's postfix operators. A name is a name again — the tokenizer no longer swallows a whole dotted path into one token — and `.`, `->` and `[` are operators over a value that carries its type. One rule makes them all fall out: a subscript, an arrow and a dereference take the operand's *word* as the base address, while `&` and `.` take its *place*, and an array's word is its own address, as C's decay makes it. So `a[i]`, `*a` and `a + 1` treat an array and a pointer alike without a case for each. Arithmetic on a pointer scales by the element, as in C and as in GDB: `e + 1` is one Entity on, `*(e + 1)` is exactly `e[1]`, and `p - q` counts elements. Only `+` and `-` scale, and only where a DWARF type says pointer or array — a register, a literal, a `u32()` or `[addr]` read has no type at all, so `r3 + 1` and `u32(a) + 1` cannot change meaning, and neither can `p & 3`, which is not pointer arithmetic in C either. A cast now means in the grammar what it means in C: `(T *)x` is a pointer value, shown as an address with the pointee as its child, and `*(T *)x` and `((T *)x)->m` are what read through it; `(T)x` keeps its meaning, the T at x's address. `(Entity *)x` against `(a + b) * c` is settled the way a compiler settles it, with the type table, after a shape check that asks nothing when the tokens cannot be a cast. Everything the new paths name is a place, so everything is writable: `gEntityInfo[i].xPosBg2 = 10`, `p->hp = 0`. Watches, hovers, the console, breakpoint conditions, logpoint messages and data breakpoints all reach them, because they all go through one compiler now — `Inspector.evaluate`'s own path walker is gone, and a path's display comes from the grammar's lvalue through the same formatter the variables tree uses. Types are resolved at compile time and never reach an evaluated closure, which carries an offset, a read width and a signedness flag and nothing else: a breakpoint condition costs address arithmetic and memory reads. Where the root lives is asked per evaluation instead, through `ExprEnv.place`, because a local moves between a stack slot and a register as the pc advances — a pointer parameter at -O2 usually never sees memory at all. New in `@gba-kit/debug-core`: `compile`, which answers the value, the type and the place together, and `ExprPlace`, `ExprLvalue`, `ExprBits`, `ExprHints.rootType` and `ExprHints.typeByName`. Every addition is optional, so an env or hints written against 0.7.0 keeps compiling and keeps answering — a root the debug info does not type still resolves as text, which is what a symbol map alone can do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pointer's one row in the variables tree is what it points at, and it had no `evaluateName` at all: dragging it to the watch pane or the console produced nothing, because `*` was not a name the grammar accepted. It is now, so the row hands back `*(g_player.counterRef)` and evaluating that gives the same value the row shows. `&g_keys` answers a hex address now that `&x` is a typed pointer, which one test was reading as decimal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module header, both package READMEs and `inspector.ts`'s own header all described a grammar with constant subscripts and no pointers, which is not the one the code implements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every node the binary operators built carried an empty `text` that the parser overwrote a line later, so the span an error message quotes existed only after the node did. The parser knows the span before it calls `apply` — the right operand is already consumed — so it passes it in, and the mutation goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The commit that lifted `formatBitfield` "so there is one of each" lifted only the formatting. Where a bitfield member sits — the first byte of its storage and the bits within it — was still derived from the same `MemberDesc` fields in two packages, and the rule for whether a type reads as signed was copied out character for character, with a comment naming the duplication instead of removing it. That predicate is exactly what decides whether a watch and a variables row disagree about the sign of one member. `bitfieldPlacement` and `isSignedType` join `formatBitfield` in the type model they belong to, and `BitPlacement` names the shape both sides pass around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rong Four things the grammar was getting wrong, all in the same seam: it knew a type but not a name, or knew a name but forgot the bits. `typedRoot` built two closures, an address and a raw word, and each opened by asking `env.place(name)`, so a register-held root was resolved twice per evaluation — and one resolution is a scan of the scope's variables followed by a DWARF location expression. A place is one question, so it is now one closure answering one `ExprPlace`; `locatedNode` branches on what comes back. The `raw` branch that could only ever see a word, and the width that existed only to feed it, go with it. `p->pos.x` now costs the lookup `g_player.pos.x` costs. A step below a root the debug info does not type was diagnosed as a missing type even when the name was missing outright, and every suggestion was a dead end: `zzz->a` advised a cast that would fail on the same unknown name. Whether a name resolves is the env's to settle and it settles it at evaluation, so the value is asked for first: `zzz->a` says `unknown symbol 'zzz'` exactly where `zzz` does, and only a name that does resolve is told which cast reaches through it. With that, `Node.path` and the textual combining of `a.b[3]` go — a path below an untyped root could never resolve anyway, since measuring a member needs a type a symbol map does not have. `ExprEnv.symbol` says so now, and `replaceData` no longer defaults to hints that cannot answer. `&` on a bitfield dropped the bit offset and width on the floor, so `*&x` was not `x` and a four-bit field in an odd byte yielded a `u16 *` the bus cannot read unrotated. C forbids it and GDB refuses it; so does this. `&` on a place the debug info does not type is still an address, so it is shown as one — `void *` — rather than as a decimal count. Subtracting two pointers compared pointee sizes and then said "they point at different types", which was true only by accident: `int *` minus `volatile u32 *` answered a plausible element count across two unrelated objects. It compares the types, ignoring the qualifiers C ignores, and the count it answers is an `int`. A ternary erased the type of its arms, so `(c ? p : q)->m` — which GDB reads — could not be written. Two arms of one type make the result that type. While in here: `#postfix` passes each node the span it came from instead of overwriting `text` a line after construction, the way `apply` already did; `#cast` decides the cast-versus-parenthesis question by itself rather than having `#primary` re-scan the same tokens; `isOp` says what three peeks and two casts said; and `pointerTo` spells a pointer to an array as C declares one, `u8 (*)[8]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things in the inspector outlived the semantics this branch gave casts and addresses. The hint on a symbol the ELF names but never types read "try (StructName*)x". Under C's cast rules that is now a pointer *value* read out of x, not the struct at x: on a decomp it answers the word stored there. `(StructName)x` is the one that means what the hint is offering. Whether a result could be opened in a memory view was decided by whether the source text started with `&` — written before the compiler knew a type. So `&gEntityInfo[1]` offered one and `gEntityInfo + 1`, the same address of the same type, did not. A pointer is a place; that is the question being asked. `evaluate` called `lvalue.address(env)` before asking whether the lvalue had a type to format, which is the one case where the call can throw for a reason the answer does not depend on. `hex8` was a third copy of a three-line function; it moves beside `formatNumber`, which is where the "how a number reads" helpers already live, and is re-exported from the same place as before. `compile`, `Compiled`, `ExprPlace`, `ExprLvalue` and `ExprBits` become exports, which is what the changeset already promised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pointee row named itself `*(parent)` — the operand parenthesised, not the expression — and postfix binds tighter than unary `*`, so a member one level below it came back as `*(gUnk_03004654).unk1B`, which parses as `*((gUnk_03004654).unk1B)` and refuses to evaluate. The whole dereference is parenthesised now, so the name reads back as the row it came from. Rows under an *evaluated* value had no name at all when the expression used anything the grammar gained here: the prefix was matched against a regex written before `->`, `*`, `(` and `)` were in it, so a watch on `p->pos` or `*p` could not have its children dragged into Watch or copied as an expression. Anything but a plain dotted path is parenthesised instead of refused. The round-trip loop in the spec asserted only that a string came back, which would have passed on both bugs. It compares each row's `evaluateName` against the row's own value, and a new case expands a pointer to a struct two levels deep through `->`, `*`, a cast and `&`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It promised `compile` and four types as exports of `@gba-kit/debug-core`, which `src/index.ts` never gained until now; it promised that an `ExprEnv` written against 0.7.0 "keeps compiling and keeps answering", when a dotted path through `symbol()` stopped answering; and it claimed a per-evaluation cost that a register-held root was paying twice. Those are release notes for a minor bump, so they now describe the behaviour that shipped, along with the scaled pointer difference, the refusal of `&` on a bitfield, and the memory reference a pointer result carries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ames a cast `pointerTo` appended " *" to a name that already ended in one, so `&p` on a `struct Entity *` read as `struct Entity * *`. Beside the array case it already handles, that is the same defect: a type string a decomp reader reads as a C declaration. The summary that tells a user to cast an untyped symbol had no test, which is how it came to name `(StructName*)x` after that spelling stopped meaning the struct at x. It has one now, derived from the ELF rather than from an offset written down here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ExprHints.symbolSigned` can no longer be reached. It is consulted only by `#untypedRoot`, which the parser builds only where `rootType` answered nothing — and both are driven by the same root lookup, so a name with a signedness has a type, and a name without a type has no signedness either. It earned its keep while paths went through the inspector node by node; paths go through types now, and it died with `#pathNode`. A symbol table states no signedness, so an untyped root reads its word as stored, which is what the hint already evaluated to. `ExprBits` added no field and no constraint to `BitPlacement`, and both were exported, so one shape travelled under two names and the compiler could not tell a holder of either that they matched. `ExprLvalue.type` already names a `@gba-kit/debug-info` type without a local alias; `bits` does the same now. `scalarWidth` and `scalarLength` were two switches over the same kinds asking the same question — how many bytes does a value of this type occupy — that differed only in what they did with the answer. `scalarSize` in `@gba-kit/debug-info` states it once, beside `isSignedType`, and each caller spells its own fallback: the grammar refuses what no 32-bit word holds, and a data breakpoint watches the word where an aggregate starts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four messages named the wrong cause, or no alternative at all. Subtracting two pointers folded "the pointee has no size" into "they point at different types", so `gMPlayJumpTable[1] - gMPlayJumpTable[0]` printed `void *` and `void *` side by side and insisted they differed. The size question is not the type question: two pointers of one type are one type whether or not that type has a width, and a width the debug info withholds is already handled on the `+` side by stepping a byte. The difference counts bytes for the same reason, which is GDB's answer under C's own extension. Dereferencing a `void *` answered in a watch — the inspector formatted four bytes of a type that has none — and threw in a condition, where `scalarWidth` refused it. C has no value at the end of a generic pointer and GDB refuses to invent one; so does the grammar now, in one place, naming the reads that state their own width. Arithmetic on a pointer to a function stepped one byte, landing in the middle of an instruction, and named the result `function *` — a type nobody writes. Code is not an array of values; C forbids the arithmetic and so does this. A member of a register-held aggregate blamed "past its low 4 bytes" for a member at offset 0, because `scalarWidth` answers 0 for a nested struct as readily as for a member that overflows the word. The two reasons are now two sentences, and the comment above them covers the code again. And `.` and `->` on a plain word said only that a word has no members, where `[` and `*` both name the cast that would work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`setDataBreakpoints` asked for hints with no scope at all, so `rootType` saw only globals and every local of the stopped frame compiled as an untyped root. A condition of `p->pos.x > 0`, set from a frame where the tree shows `p` as a `struct Player *` and where a watch, a source-breakpoint condition and a logpoint all answer it, then failed at every watched write with `'p' has no type` — a sentence about a variable whose type is on screen beside it. A watched address can be written from anywhere, so there is no one frame the condition belongs to; the frame it was typed in is the one whose names the user had in front of them, which is the frame GDB scopes a watchpoint to as well. A condition naming a local now answers while that frame is live, and says the name is unknown once it is not — which is the truth about a name out of scope. The refusal itself says `here` rather than `in the debug info`, because a type is looked up where the expression compiles: a local of another function is untyped at this pc however fully the ELF describes it elsewhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ternary is the one expression that carries a type and names no storage: both
arms of one type make the result that type, but which arm ran is not knowable
until it runs, so there is nothing to take the address of. The inspector's
no-lvalue branch then painted the 32-bit word as the value's bytes, which for an
array is its own address — `1 ? g_samples : g_samples` showed
`{50331648, 0, 0, 0}` where `g_samples` shows `{3, 5, 8, 13}`, and
`g_player.name` came out as `"("`, the low byte of the address spelled as a
character. No error, and nothing on screen to say the pane was showing the
pointer instead of the object.
An array's word is its own address, as C's decay makes it — the rule the whole
grammar already turns on — so its contents are read from there, and the result
offers a memory view like any other place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A watch on `p` and a watch on `(p)` offered different memory views of the same pointer. A bare name is read by the tree's own reader, which hands back where the compiler keeps the value — nothing, when that is a register — while the grammar's branch offers what a computed pointer points at. So at -O2, where `p` lives in r0, `(p)` opened the struct it points at and `p` opened nothing; at -O0, where `p` has a stack slot, both opened the slot. One label, two meanings, decided by the optimiser. A value that names storage offers that storage, and offers nothing while the machine keeps it in a register. What a value *computed* — `&x`, `p + 1`, `(T *)x` — points at is still a memory view, because there is no storage there to mean anything else. The header comment said a watch shows its result through the formatter the tree uses; two readers sit behind `evaluate`, and it now says which does what. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"cannot step" reads right for `fp + 1` and wrong for `fp - fp`, which is the same refusal: arithmetic on a pointer to code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four entries had drifted from the code: `ExprBits` is gone, `scalarSize` is new, `ExprHints.symbolSigned` was removed and that is a source-compatible break worth stating, and the bounds sentence said "constant" where the code checks a literal. The no-width pointee rule, the refusals C itself makes, the scope a data breakpoint's condition compiles in and what a memory reference means all belong in the entry beside the capability they qualify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comments this branch left behind still speak of the shape it replaced. `ExprEnv.place` explained itself as what "an env written against the older interface" lacks, which tells a reader about a version rather than about the seam: an env with only a symbol table behind it has no location to give, and that is why the method is optional. The same sentence had leaked into `typedRoot`. Pointer arithmetic said untyped words "mean what they always did", which is a promise to yesterday's reader; what matters today is that a word with no type takes the number as it stands. The rest are names the change moved out from under. `variableTarget` and `dataBreakpointTarget` still offered "a variable or member path" when both now take any expression that names typed storage — `g_samples[i]`, `p->pos.x`, a pointee. `BreakpointStore.replace` promised a local's *signedness* to a condition, from when that was the whole of what a hint carried; it is the whole type now, which is what lets a path be measured at all. `castNode` called itself the cast operator's reader, and the cast operator no longer calls it — only a declaration joined to linker-placed storage does. Two test comments carried the same tense: "(T)x is still the T at x's address" and "the compatibility spine", both of which describe a diff rather than the assertion under them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a[i], p->m, *p, (T *)x
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.
Typing
gEntityInfo[arg0].xPosBg2— a line copied straight out of the source beingdecompiled — used to answer
only constant subscripts and .member paths are supported,with a hint to work the address out by hand and read it with
u32(). A variable index,an arrow and a dereference are now the grammar's own, everywhere an expression is
accepted: watches, hovers, the console, breakpoint conditions, logpoint messages, data
breakpoints, and writes.
A name is a name again — the tokenizer no longer swallows a whole dotted path into one
ident token — and
.,->and[are real postfix operators over a value that carriesits DWARF type. One rule makes them all fall out: a subscript, an arrow and a dereference
take the operand's word as the base address, while
&and.take its place, andan array's word is its own address, as C's decay makes it. So
a[i],*aanda + 1treat an array and a pointer alike, and a chain like
a[i].b->c[j]costs one addresscomputation per step.
Before / after
Against
kleod.elf(agbcc, DWARF-2), stopped insub_0801F128atcode_08014184.c:7271:Everything those paths name is writable, wherever a constant path was:
Two approved behaviour changes
Pointer arithmetic is scaled, as in C and as in GDB. With a
struct Entity *eof 0x50bytes,
e + 1is 0x50 bytes on and*(e + 1)is exactlye[1]; subtracting two pointersof one type counts elements; an array decays, so
gEntityInfo + 1is&gEntityInfo[1].Scaling applies only to
+and-, and only where a DWARF type says pointer or array. Aregister, a literal, a
u32()or[addr]read and the machine values have no type atall, so
r3 + 1andu32(a) + 1mean what they always meant — and neither doesp & 3or
p * 2change, which are not pointer arithmetic in C either.(T *)xis a pointer value, not theTatx. It reads as a hex address with thepointee as its one expandable child, and
*(T *)x,((T *)x)->mand((T *)x)[i]arewhat read through it.
(T)xis unchanged: theTatx's address.&xis likewise apointer now rather than a bare number — the same number, shown as an address, with what it
points at underneath — and because a pointer is a place,
&x,p + 1and(T *)xallhand back a memory reference, where before only an expression that started with
&did.Deliberately out of scope
when the expression compiles; a computed one is not. GDB does not check one either, and
a pointer has no count. An index the reader folds in their head —
a[2 + 3]— isarithmetic here like any other.
byte on is the middle of an instruction — and none on a value the debug info leaves
untyped, which stays a plain 32-bit word.
*is not a synonym for[addr]or{addr}. Those keep their byte and halfwordmeaning; on a value with no type
*says so and namesu8(),u16()andu32()instead of guessing a width.
.,->and[below one arerefused, by the name that is actually wrong:
zzz->asaysunknown symbol 'zzz', andonly a name that resolves is told which cast would reach through it.
Design note
Types are resolved once, when the expression compiles, and never reach an evaluated
closure, which carries an offset, a read width and a signedness flag and nothing else — a
breakpoint condition is address arithmetic and memory reads, and dragging DWARF objects
into it would put a type walk on every hit.
TypeDescis imported type-only from@gba-kit/debug-inforather than duplicated indebug-core: one type model, and none ofit in the hot path. Where the root lives is asked per evaluation instead, through the new
ExprEnv.place, because a local moves between a stack slot and a register as the pcadvances — at
-O2a pointer parameter often never sees memory at all, and reading onethrough its register is how
p->pos.xanswers there.How it was tested
pnpm build,pnpm test(1072 tests across 8 packages, nothing cached),pnpm lint,pnpm check-types,pnpm format:check.units.spec.tsdrive the grammar against a syntheticTypeDesc/memory env — no ELF — covering scaling, casts,&, bitfields, register-heldroots, and every refusal message.
session.spec.tsanddap.spec.ts, runacross all three build variants (thumb-O0, thumb-O2, arm-O0), using
g_samples[g_frame & 3]as the variable-index case andp->pos.x— a pointer local inmove_player— as the arrow case, through conditions, logpoints, data breakpoints andwrites. No test hard-codes an address, an offset or a frame size; every one is derived
from the ELF at run time, which is what sent the previous PR red in CI. The struct step in
the pointer-arithmetic test is measured against the ELF rather than written down, and the
one region assertion (
/^0x03/) is pinned by the fixture's own committed linker script,not by the compiler's layout.
evaluateNameround-trips:dap.spec.tswalks the Variables tree and every computedvalue's rows and re-evaluates each
evaluateName, asserting the value comes back equal —so a pointee
(*(g_player.counterRef))or a member(p->pos).xcan be dragged into Watch.debug-coreheadlessly: the kleoddecomp (agbcc, DWARF-2) for the five reported source lines above plus a write, a
breakpoint condition, a logpoint and a data breakpoint (
gEntityInfo[arg0].xPosBg2→0x03002b6c, length 2); and balatro-gba (devkitARM, DWARF-5,-O3) for the optimisedcase, where
deck: Card *[52]subscripts at a variable index andstate_info + 1equals&state_info[1], confirming the scaling on a real struct array.🤖 Generated with Claude Code