tables: a union arm is a field line — the C++ reference, the compiler and the corpus (#396 item 1, #392) - #493
Merged
Merged
Conversation
gafferongames
force-pushed
the
union-arms-cpp
branch
4 times, most recently
from
September 4, 2026 05:53
07c4325 to
5ef32cf
Compare
… and the corpus (#396 item 1, #392) An arm of any field type, on both wires, exactly as docs/SPEC-TABLES.md §2.6 states it: the arm's payload is the bytes a FIELD of that type puts after its own framing prefix, and the arm's `L` stands in for the field's own length where that kind has one and frames the fixed width where it does not. The parser takes a field line in a union body and refuses what an arm cannot take — `= default`, `?`, `was`, `json`, an `[E]T` keyed array, an `if` guard. The C++ reference emits every arm through the field emitters it already had (measure, save, load, text, cook, pack), so nothing about an arm is spelled twice, and the descriptors carry an arm's `field` row and its `size` beside the `table` column a declared payload keeps. A PAYLOAD-FREE arm rides in all nine packet backends: no storage, the tag alone on the packet wire, the arm id with `L = 0` on the table wire, `null` in the text. Every union now projects into the protocol id (§20.8, SPEC §3.1), which moves the id of every unit whose table closure holds one — m1, m2, messages and stream here — because an arm is a field line in the wire-shape projection too. The baseline's arm line takes the three disjoint spellings of §18.1, the rendering version moves to 6, and the build version's arm line takes §20.2's token order. `internal/codegen/cpptable/pointers.go`'s string surgery over `src`/`dst` paths is replaced with a structured edge visitor, the fix the #430 blind read asked for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames
force-pushed
the
union-arms-cpp
branch
from
September 4, 2026 06:07
5ef32cf to
6cc13b1
Compare
This was referenced Sep 4, 2026
Open
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.
Closes the language half of #396's adopt item 1 and the C++ half of #392: a union arm is a field line, of any field type, on both wires. The spec landed first in #481; this is the tree catching up to it.
What rides
The grammar and the checker. A union body takes a field production — type plus value-shaping attributes — and refuses what an arm cannot take, each by name:
= default,?,was,json = "...", an[E]Tkeyed array, anifguard. A bare identifier is a PAYLOAD-FREE arm. General arms are a table-closure construct, so a union outside one still takestypepayloads only.The C++ reference.
internal/codegen/cpptable/arms.goemits an arm's storage, measure, save and load by dispatching into the field emitters that were already there, so an arm and a field of the same type are one piece of code, one level apart. The text form, the cook writer, the pack walk and the descriptors follow the same rule:TableUnionArmInfogained afieldcolumn (theTableFieldInfoa field of that type would carry, offsets taken within the union's storage, the tag at 0) and asizecolumn, which selection zero-establishes.A payload-free arm takes no storage: the tag alone on the packet wire, the arm id with
L = 0on the table wire,nullin the text. Seven packet backends carry it (rust, go, cs, java, js, dart, elixir); c and cpp give the named refusal instead, because their emitters are under the #170 C/C++ lock and the 2026-09-04 carve admits #488 and #489 and nothing else. The refusal lives incompiler/packetvoidarms.gowith the other cross-target refusals and names the follow-on. The TABLE side of the arm is C++'s and rides here.The reader's three outcomes, since an arm carries no kind byte: a fixed-width arm whose
Lis not its width is akind_mismatchwith the unionNone; a length-shaped arm damaged inside its ownLismalformedwith the unionNone; and what neither can see is silent, which the baseline refuses.The tool (
internal/tablewire,internal/tabletext,internal/tablecook,internal/tablepack) reads and writes every arm shape, so the fuzzer and thereportrows have an independent oracle for them.The projection (ruling 1k, §20.8): every union projects into the protocol id now, table-closure ones included, because an arm is a field line in the wire-shape projection too. That moves the protocol id of every unit whose table closure holds a union —
m1,m2,messages,streamhere — and their cook goldens with it. No table field edit reaches the id;TestTableArmsProjectAndTablesDoNotholds both halves, and now also §20.8's own control: a union a table closure holds gains a SCALAR arm and the id moves.The baseline takes §18.1's three disjoint arm spellings (
payload=<Name>,kind=none, the field tokens includingmin=andmax=) and the rendering version moves to 6, main's constant plus one. A unit whose arms all name declaredtypes regenerates its projection byte-identically —tables/examplesandtables/pointersmove by their version line and one history entry and nothing else.tables/messages/tables.baselinejoins the committed corpus baselines, so the arm line's three spellings are pinned as the field line's are.The build version takes §20.2's arm line, in the field line's emitted token order with
offset=inside the union's storage andsize=unconditional, andkind=nonefor a payload-free arm.BuildVersionFormis untouched.#430's blind-read fix.
internal/codegen/cpptable/pointers.goderived a destination path by slicing the source path's prefix ("dst" + src[len("src"):]). It is now anedgeExpr{Src, Dst}pair walked by anedgeVisitor, so a path is built rather than patched.Not in this PR: the union-arm SETTER that #449 proposes. The construct lands in its simplest form; the setter is a named follow-on.
The corpus
tables/messagescarries an arm of every shape at three depths — aflagsarm, a fixed-array arm, a nested-union arm and a payload-free arm inToolBody; a bounded scalar arm, a counted-array arm, abytesarm and anenumarm inEditBody; a scalar arm and astringarm inOrigin, which puts a scalar arm inside every element of the arrays of unions above.tables/streamgained a POINTER arm sharing a node with a pointer field, and a scalar arm beside it.test/tables/A1.schema/A2.schemaare the ARM EVOLUTION pair, and they are conformance units: A2 declares A1's six arms under the same names at other types, so one wire written under A1 and read under A2 names each of the reader's answers exactly once. Five newreportrows pin the five counters and the value, each red for one reason:a1_arm_moved_width_as_a2int32arm read asint640,1,0,0,false— kind mismatch, the unionNone, siblings intacta1_arm_one_width_as_a2int32read asfloat320,0,0,0,false— §4.1's fifth member, silent, the value differenta1_arm_as_string_as_a2string(8)0,0,0,0,false— silent, the string holding the scalar's bytesa1_arm_as_body_as_a2float32read as a BODY arm0,0,0,0,true— the terminator rule, the unionNone, the parent reading ona1_arm_unknown_as_a21,0,0,0,false—unknown, the unionNoneFour new instances (
message_arms,message_arm_edits,message_ack,stream_arm_pointer) ride every conformance surface, four new json-hostile rows pin the text form's arm rules, and the wire fuzzer's seed set grows to 77 over 20 roots.New gates and their negative controls
Two mutators the merged §4.2 names, both in
test/conformance/harness/wireframe.go:Lmoved off its declared width, to every fixed width the closed set has and to zero;Each has a Makefile negative control that removes the check from the emitter and requires the fuzzer to go red on the verdict that check guards:
§18.6's arm sub-cases each get a fixture pair in
internal/baseline/baseline_test.go, and each goes red for one reason — the token beside it — with the discrimination and attribution controls the file already runs on every case:int32arm against anint64armwire kind 4 -> 5kind[..8]float32arm against a[..8]int32armarray element kind 10 -> 4elemChunkarm against a*Chunkarmarm payload Chunk removed,wire kind 17 added,nested table Chunk addedpayloadint32wire kind none -> 4kindAnd §20.8's own two: a union a table closure holds gains a SCALAR arm and the protocol id moves; an arm retyped under one width moves the build version with kind, size, offset and wire id all unchanged.
Three divergences the new rows found
None. §3 says the element kind is part of the array's identity and a mismatch skips the field, which for an ARM is the arm — fixed ininternal/tablewire/decode.go.Lleft the outer arm selected; it now readsNonewithmalformed. And an arm's storage was not established at selection, so a counted-array arm's tail past the live count carried whatever the overlay held — invisible on the wire and in the text, visible in a cook image, which is where the conformance run caught it.stream_arm_pointerfailedjson-readuntil the union path called it.Two GCC-only findings
The Linux legs build the generated code with
-Wshadow -Werror, which clang does not raise here:arm_fields[]initializer, where that name is already in scope, so both were namedarm_fields. The array is now named for its union.flags Capsbeside an arm namedcapsputs a typedef and a scoped enumerator of one spelling in one scope. The corpus flags declaration is renamedCapabilities; the arm keeps its name and its id. A schema that spells both the same way still generates, and hardening the tag names against it is not this PR.Gate state
All 20 CI checks pass, including
conformanceon all nine legs,big-endian,msvc,windowsandcpp-lock. Locally:go test ./...,make tables-block-zero-cost, the C++ table tests plain and under ASan/UBSan,make tables-wire-fuzz N=20000(87075 mutants, 0 divergences, both builds), all four wire-fuzz negative controls red-then-green,tables-zero-cost,tables-json-walk,tables-json-graph-walk,tables-flat-wireand its control, and the C++ conformance leg on every surface (wire 48/48, report 35/35, json-read 46/46, json-write 46/46, json-hostile 125/125, cook 6/6, cook-write 92/92, cook-foreign 6/6, block 2/2, block-foreign 2/2, block-dump 2/2, forgery 11/11, cook-forgery 111/111).🤖 Generated with Claude Code
One test of #488's that this PR turns around
#488 landed
TestBuildVersionSeesATableArmedUnionArmRenamed, which asserts that renaming an arm of a TABLE-ARMED union does NOT move the protocol id, "such a union has no packet wire". §20.8 of the merged page states the opposite as the arm's own id control: a union a table closure holds gains a SCALAR arm, and the PROTOCOL id moves, because an arm is a field line in the wire-shape projection. It is red if that id stands still. The page is the ruling (#396 item 1, 1k), so the test is turned around and renamedTestTableArmedUnionArmRenameMovesBothIds: both ids move.What that costs is the ISOLATION #488 wanted from it — an edit that moves the build version through group 3's union vocabulary and nothing else. Under this rule no such union edit exists: every union fact the cook projection carries, the protocol id carries too, and the protocol id rides in the cook projection whole. The union tokens' own coverage is a gap for a follow-on to re-establish; it is named here rather than papered over.
My own
TestArmProjectsAsAFieldLinemoved with #491 in the other direction: an arm RENAME now moves the protocol id, because an arm's name projects beside its payload.