Skip to content

tables: a union arm is a field line — the C++ reference, the compiler and the corpus (#396 item 1, #392) - #493

Merged
gafferongames merged 1 commit into
mainfrom
union-arms-cpp
Sep 4, 2026
Merged

tables: a union arm is a field line — the C++ reference, the compiler and the corpus (#396 item 1, #392)#493
gafferongames merged 1 commit into
mainfrom
union-arms-cpp

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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]T keyed array, an if guard. A bare identifier is a PAYLOAD-FREE arm. General arms are a table-closure construct, so a union outside one still takes type payloads only.

The C++ reference. internal/codegen/cpptable/arms.go emits 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: TableUnionArmInfo gained a field column (the TableFieldInfo a field of that type would carry, offsets taken within the union's storage, the tag at 0) and a size column, which selection zero-establishes.

A payload-free arm takes no storage: the tag alone on the packet wire, the arm id with L = 0 on the table wire, null in 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 in compiler/packetvoidarms.go with 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 L is not its width is a kind_mismatch with the union None; a length-shaped arm damaged inside its own L is malformed with the union None; 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 the report rows 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, stream here — and their cook goldens with it. No table field edit reaches the id; TestTableArmsProjectAndTablesDoNot holds 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 including min= and max=) and the rendering version moves to 6, main's constant plus one. A unit whose arms all name declared types regenerates its projection byte-identically — tables/examples and tables/pointers move by their version line and one history entry and nothing else. tables/messages/tables.baseline joins 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 and size= unconditional, and kind=none for a payload-free arm. BuildVersionForm is untouched.

#430's blind-read fix. internal/codegen/cpptable/pointers.go derived a destination path by slicing the source path's prefix ("dst" + src[len("src"):]). It is now an edgeExpr{Src, Dst} pair walked by an edgeVisitor, 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/messages carries an arm of every shape at three depths — a flags arm, a fixed-array arm, a nested-union arm and a payload-free arm in ToolBody; a bounded scalar arm, a counted-array arm, a bytes arm and an enum arm in EditBody; a scalar arm and a string arm in Origin, which puts a scalar arm inside every element of the arrays of unions above. tables/stream gained a POINTER arm sharing a node with a pointer field, and a scalar arm beside it.

test/tables/A1.schema / A2.schema are 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 new report rows pin the five counters and the value, each red for one reason:

row the edit pinned
a1_arm_moved_width_as_a2 int32 arm read as int64 0,1,0,0,false — kind mismatch, the union None, siblings intact
a1_arm_one_width_as_a2 int32 read as float32 0,0,0,0,false — §4.1's fifth member, silent, the value different
a1_arm_as_string_as_a2 scalar read as string(8) 0,0,0,0,false — silent, the string holding the scalar's bytes
a1_arm_as_body_as_a2 float32 read as a BODY arm 0,0,0,0,true — the terminator rule, the union None, the parent reading on
a1_arm_unknown_as_a2 a payload-free arm A2 does not declare 1,0,0,0,falseunknown, the union None

Four 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:

  • an ARM's L moved off its declared width, to every fixed width the closed set has and to zero;
  • a BODY's TERMINATOR moved inside its own length, the u16 zero written ahead of the payload's last two bytes.

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:

$ make tables-wire-fuzz-arm-width-negative-control N=0
harness: FAILED after 47871 mutants: the report differs: the leg says 1,0,0,0,true, the oracle says 1,1,0,0,false
negative control: removing the arm-width check from the emitter turns the wire fuzzer RED

$ make tables-wire-fuzz-arm-terminator-negative-control N=0
harness: FAILED after 738 mutants: the decoded value differs: the leg saves 391 bytes, the oracle 380, first difference at byte 19
negative control: removing the arm-terminator check from the emitter turns the wire fuzzer RED

§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:

the edit the finding the token
an int32 arm against an int64 arm wire kind 4 -> 5 kind
a [..8]float32 arm against a [..8]int32 arm array element kind 10 -> 4 elem
a Chunk arm against a *Chunk arm arm payload Chunk removed, wire kind 17 added, nested table Chunk added payload
a payload-free arm given an int32 wire kind none -> 4 kind

And §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

  • The oracle was wrong on an array arm whose body declares another element kind: it kept the arm selected at its declared defaults where the reference left the union 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 in internal/tablewire/decode.go.
  • The reference was wrong twice. A nested-union arm whose inner union runs past the arm's L left the outer arm selected; it now reads None with malformed. 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.
  • A POINTER arm's JSON value is the pointee in place or a node reference, which is the read a pointer FIELD takes and not the scalar walk — stream_arm_pointer failed json-read until 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:

  • a NESTED-union arm's field rows were emitted inside the outer union's arm_fields[] initializer, where that name is already in scope, so both were named arm_fields. The array is now named for its union.
  • flags Caps beside an arm named caps puts a typedef and a scoped enumerator of one spelling in one scope. The corpus flags declaration is renamed Capabilities; 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 conformance on all nine legs, big-endian, msvc, windows and cpp-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-wire and 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 renamed TestTableArmedUnionArmRenameMovesBothIds: 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 TestArmProjectsAsAFieldLine moved with #491 in the other direction: an arm RENAME now moves the protocol id, because an arm's name projects beside its payload.

@gafferongames
gafferongames force-pushed the union-arms-cpp branch 4 times, most recently from 07c4325 to 5ef32cf Compare September 4, 2026 05:53
… 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>
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