Skip to content

Evaluate expressions the way C spells them: a[i], p->m, *p, (T *)x - #18

Merged
macabeus merged 19 commits into
mainfrom
typed-expression-paths
Sep 12, 2026
Merged

Evaluate expressions the way C spells them: a[i], p->m, *p, (T *)x#18
macabeus merged 19 commits into
mainfrom
typed-expression-paths

Conversation

@macabeus

@macabeus macabeus commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Typing gEntityInfo[arg0].xPosBg2 — a line copied straight out of the source being
decompiled — 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 carries
its 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, 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, and a chain like a[i].b->c[j] costs one address
computation per step.

Before / after

Against kleod.elf (agbcc, DWARF-2), stopped in sub_0801F128 at code_08014184.c:7271:

  gEntityInfo[arg0].xPosBg2
- !! only constant subscripts and .member paths are supported
+ => 240 (0x00f0)   [u16]   @0x03002b6c

  gEntityAnimationInfo[arg0 - gUnk_0300363C].frame
- !! only constant subscripts and .member paths are supported
+ => 0 '\0'   [volatile u8]   @0x03000876

  gUnk_080E2AB4[gEntityAnimationInfo[arg0 - gUnk_0300363C].frame + 1][2]
- !! only constant subscripts and .member paths are supported
+ => -6 '\xfa'   [const s8]   @0x080e2abc

  gUnk_03004654->unk1B
- !! unknown symbol 'gUnk_03004654->unk1B'
+ => 51 '3'   [u8]   @0x080521c3

  gBgDataPtrs.pBufBg2Tilemap[((gEntityInfo[arg0].xPosBg2 - 0xC) >> 3)]
- !! only constant subscripts and .member paths are supported
+ => 0 '\0'   [u8]   @0x0201e638

Everything those paths name is writable, wherever a constant path was:

  gEntityInfo[0].xPosBg2            => 118 (0x0076)   [u16]   @0x03002920
  gEntityInfo[0].xPosBg2 = 0x0123   => 291 (0x0123)
  gEntityInfo[0].xPosBg2            => 291 (0x0123)   [u16]   @0x03002920

Two approved behaviour changes

Pointer arithmetic is scaled, as in C and as in GDB. With a struct Entity *e of 0x50
bytes, e + 1 is 0x50 bytes on and *(e + 1) is exactly e[1]; subtracting two pointers
of one type counts elements; an array decays, so gEntityInfo + 1 is &gEntityInfo[1].
Scaling applies only to + and -, and only where a DWARF type says pointer or array. A
register, a literal, a u32() or [addr] read and the machine values have no type at
all, so r3 + 1 and u32(a) + 1 mean what they always meant — and neither does p & 3
or p * 2 change, which are not pointer arithmetic in C either.

(T *)x is a pointer value, not the T at x. It reads as a hex address with the
pointee as its one expandable child, and *(T *)x, ((T *)x)->m and ((T *)x)[i] are
what read through it. (T)x is unchanged: the T at x's address. &x is likewise a
pointer 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 + 1 and (T *)x all
hand back a memory reference, where before only an expression that started with & did.

Deliberately out of scope

  • No runtime bounds checking. A literal index outside a sized array is still refused
    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] — is
    arithmetic here like any other.
  • No function calls in expressions, and no floating point.
  • No pointer arithmetic on a function pointer — code is not an array of values, and one
    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 halfword
    meaning; on a value with no type * says so and names u8(), u16() and u32()
    instead of guessing a width.
  • A name the debug info does not type stays a word. ., -> and [ below one are
    refused, by the name that is actually wrong: zzz->a says unknown symbol 'zzz', and
    only 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. TypeDesc is imported type-only from
@gba-kit/debug-info rather than duplicated in debug-core: one type model, and none of
it 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 pc
advances — at -O2 a pointer parameter often never sees memory at all, and reading one
through its register is how p->pos.x answers there.

How it was tested

  • The full gate is green from a clean tree: pnpm build, pnpm test (1072 tests across 8 packages, nothing cached),
    pnpm lint, pnpm check-types, pnpm format:check.
  • Hermetic unit tests in units.spec.ts drive the grammar against a synthetic
    TypeDesc/memory env — no ELF — covering scaling, casts, &, bitfields, register-held
    roots, and every refusal message.
  • End-to-end against the owned fixture in session.spec.ts and dap.spec.ts, run
    across all three build variants (thumb-O0, thumb-O2, arm-O0), using
    g_samples[g_frame & 3] as the variable-index case and p->pos.x — a pointer local in
    move_player — as the arrow case, through conditions, logpoints, data breakpoints and
    writes. 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.
  • evaluateName round-trips: dap.spec.ts walks the Variables tree and every computed
    value's rows and re-evaluates each evaluateName, asserting the value comes back equal —
    so a pointee (*(g_player.counterRef)) or a member (p->pos).x can be dragged into Watch.
  • Real acceptance runs on this machine, driving debug-core headlessly: the kleod
    decomp (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 optimised
    case, where deck: Card *[52] subscripts at a variable index and state_info + 1 equals
    &state_info[1], confirming the scaling on a real struct array.

🤖 Generated with Claude Code

macabeus and others added 19 commits September 12, 2026 19:15
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>
@macabeus macabeus changed the title Read a typed path the way the program writes it: gEntityInfo[arg0].xPosBg2, p->pos.x, *p, (Entity *)x Evaluate expressions the way C spells them: a[i], p->m, *p, (T *)x Sep 12, 2026
@macabeus
macabeus merged commit 9cc6253 into main Sep 12, 2026
2 checks passed
@macabeus
macabeus deleted the typed-expression-paths branch September 12, 2026 20:49
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