Skip to content

tool: the tutorial's findings, fixed (#521, #447) - #543

Merged
gafferongames merged 12 commits into
mainfrom
fix-wave-521
Sep 4, 2026
Merged

tool: the tutorial's findings, fixed (#521, #447)#543
gafferongames merged 12 commits into
mainfrom
fix-wave-521

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The tutorial drafting passes' findings, worked. Twelve land as commits, one
per finding, each carrying its reproduction command, the fix and a focused
test whose negative control was run and shown red. Seven are left for a
ruling, six of them because the fix needs a path bench/LOCK freezes.

make test passes locally, and the C/C++ lock is untouched: no path under
internal/codegen/c/, internal/codegen/cpp/, generated/c/,
generated/cpp/ or generated/c-ludicrous/ is in this diff.

The findings

finding verdict commit
G-19 / #535: a flipped bit in a pointered wire loads with an all-zero report and a node that points at itself the page is right and the tool already matches it, in four clauses. The gate is pinned, the check is a ruling 1a68952
G-07: a payload-free arm in a type body passes check and is refused by C and C++ at generate the page was stale and the behavior is right, so the page moved e57ec96
G-02 / F-01: one reserved enum variant draws four errors tool fixed efa6a9e
G-03 / F-02: a line-separated enum body says neither the rule nor where the mistake is tool fixed 9a03cab
G-09: ? on a union draws a second, false error tool fixed 902d34b
G-12: cook --verbose prints its report twice tool fixed 9f5935c
G-01: a misplaced flag surfaces as a stat error on the flag text tool fixed 0c8c3e8
G-17: two refusals still call the wire "field-tagged TLV" tool fixed 58d0237
G-18 / F-16: the baseline history stamps an unlabeled UTC date tool and page fixed 806e893
F-05: the float-const diagnostic points at column 1 tool fixed bab3517
G-06 / F-21: cpp_native no-ops inside its declaring file the behavior is right and SPEC states it. USAGE did not, so the page moved 800a903
G-14: RenderFrameShips beside RenderFrameships_span tool fixed a2a395e
G-05 / F-09: a [A..B] count born at 0, and a release build writing bytes no reader takes for a ruling, LOCKED none
G-11: UnitView() specified in §8.3, emitted by no backend for a ruling, a gap none
G-13: new_<type>() beside <table>_reset(&v) for a ruling, LOCKED none
F-08: C++ union arm selection is an untied two-step for a ruling, LOCKED none
F-12: the else-side guard comment reads if X else for a ruling, LOCKED none
F-15 / G-08: union tag enums carry no Count anywhere, and a name function only in C for a ruling, parity with the reference LOCKED none
F-06: constant folding is visible in generated output not a defect. The tutorial teaches it none

G-19 in particular

#535 asks the reference and the Go oracle to refuse or count a reference
cycle on load. Read through, the page says they should not, and they already
do exactly what it specifies:

  • SPEC-TABLES §3.1: "A pointer field's payload is a NUMBER: it is
    bounds-checked and stored, never followed. There is no traversal on the
    load path, and therefore no traversal bound — no depth cap, no visited set,
    no ordering rule on the indices." The forged index names a node that exists
    and whose type matches, so no §4 event happened and no counter may move.
  • §3.1 again: "What a cyclic structure costs is paid by whatever WALKS it,
    and a consumer walking untrusted table data — a reflection dump, a text
    export (§16) — carries its own visit bound." Both walking consumers do,
    measured: the text export and a re-encode each refuse the loaded instance
    by name with the §3.1 cite, and each returns rather than recursing away.
  • §6.5's data_cycle is not the missing check: its own row calls it "the
    AUTHORING side's -1 and the one value here that is not about a wire",
    and §6.5's status line says TableRefuseReason is spelled in no target
    yet (owed as schema#523).
  • §14 note 9: "A reader-side acyclicity check — PRICED, NOT TAKEN ... for
    a property the writer already guarantees (§3.1). It is the shape the check
    would take if untrusted-input hardening ever demands one."

So the mutant is pinned as the gate on that contract rather than as a red.
testdata/wire/tables/fuzz-vectors/pointer_self_cycle.bin is rooted at
Scene, the root the C++ leg carries a codec for. Rooted at ListNode it
would have landed in the harness's "absent" bucket and never reached the
reference, which is the trap this vector had to avoid and the run's own
counters confirm it did (91 seeds and 93653 mutants with it, 90 and 93652
without). The leg and the oracle agree on it, 0 divergences under both the
plain and the ASan legs.

What is left for a ruling, and why

G-05 / F-09 is the one that matters. It is live in this repository's own
shipped corpus:

generated/cpp/Wire.h:172:    int32_t samples_count = 0;      // declared wire range [1, 8]
generated/cpp/WireWire.h:249:    serialize_assert( int32_t( value.samples_count ) >= int32_t( 1 ) && ... );
generated/cpp/WireWire.h:250:    write_bits( stream, uint32_t( value.samples_count ) - uint32_t( 1 ), 3 );

Under -DNDEBUG the assert is gone, 0u - 1u truncates to three bits, and
the write reports success on bytes the reader refuses. Both available fixes
land inside the lock: making the born count the declared minimum moves
internal/codegen/cpp/ and internal/codegen/c/ and rewrites
generated/cpp/ and generated/c/. Refusing [A..B] with A above zero in
check deletes examples/Wire.schema's own samples [1..8]uint16 and
bench/corpus/Bench.schema's entities [1..8]MixedEntity, which is the
bench corpus under the lock's reproduction control. A carve or a lift is the
next step, and the fix is a few lines once the window is open.

G-11: UnitView() is emitted by none of the nine targets, verified by
generating a table-declaring unit to each and grepping. §8 carries
BACKEND STATUS lines for its doc/tags columns and for the map
descriptor, and §8.3 has none, so it survived #541's sweep. Landing the
registry is the large piece, and the cheap alternative is one status line in
the form #541 established. Not taken here either way, because a reader who builds
against §8.3 today writes UnitView() and does not compile, and which of
the two answers is right is the owner's.

G-13: new_ship_state() comes from internal/codegen/c/dispatch.go:65
and ship_config_reset(&v) from internal/codegen/ctable/codecs.go. The
table side's mutator is the right shape for a table, so it is the locked
side that would move.

F-08: a Set<Arm>() helper, or an assert that a non-None arm was
value-established, is internal/codegen/cpp/.

F-12 is smaller than #447 recorded, and worth writing down: the
if X else string is a COMMENT ONLY. The reflection descriptors carry a
separate, correct spelling built by guardWalk (at_rest, !at_rest,
active && has_target), and that is what the generated table-JSON walkers
parse at runtime. Fixing the comment still rewrites generated/cpp/Joins.h.

F-15 / G-08, measured more sharply than #447 states it: the tag enum
carries no Count in ANY target (#489 gave declared enums one and did not
reach tag enums), and the debug-name function exists in exactly ONE target. C
emits enum_name_weapon_fire_type for a union tag enum, and cpp, cs, dart,
elixir, go, java, js and rust emit nothing. So the shape of the fix is known
and cheap, C having already paid for it. What stops it here is that C++ is
the reference and is locked, and landing eight of nine with the reference
missing is worse than a clean ruling. Feature parity is the 3.0.0 gate.

… one (#521 G-07)

Reproduction, from a checkout of the tool:

    $ cat U.schema
    package pfree
    type LaserFire { target_id uint16 }
    union WeaponFire { laser LaserFire
                       ram }
    type FireCommand { fire WeaponFire }
    $ bin/schema check .            # silent, exit 0
    $ bin/schema generate --lang go  --out gg . && echo ok   # ok
    $ bin/schema generate --lang cpp --out gc .
    schema: unit declares a union with a payload-free arm (WeaponFire) — ...

SPEC §4.8 said such a unit is refused by `check`, by name, in every target.
The tool does not refuse it, and the tool is right: an arm with no payload
has nothing for a port to guess, it rides the packet wire as its tag alone,
and seven backends carry it. C and C++ refuse it at generate time through
compiler/packetvoidarms.go's carrier registry, naming the union, the
carriers and the follow-on, because their tagged-union storage has no member
for an arm that has none. The stale sentence grouped it with the arms that
ARE a front-end refusal: a `table` arm, a pointer arm, a scalar arm.

The page moves, in all three places that carried the grouping: SPEC §4.8,
SPEC-TABLES §11's refusal list, and USAGE's union section, which also now
says which targets carry the arm and that `check` is target-neutral so the
refusal arrives one command later.

The test pins both halves: the tool's behavior across all nine targets, and
the pages' own sentences. Reverting the three page edits turns
TestPagesPlaceTheVoidArmOutsideTheTableClosureClass red on four lines.

No generated output moves and no golden is re-pinned.
…F-01)

Reproduction:

    $ printf 'package bad\n\nenum ShipType { None, Fighter }\n' > Bad.schema
    $ bin/schema check .
    Bad.schema:3:17: variant None is a compile error — every enum has None = 0 implicitly (SPEC §4.2)
    Bad.schema:3:17: enum ShipType's generated variant constant collides with enum ShipType's generated None constant — both generate the symbol ShipTypeNone; rename at the source (SPEC §4.6)
    Bad.schema:3:17: variant None collides with the implicit None variant inside enum ShipType (both become the associated constant NONE in Rust) — rename at the source (SPEC §4.6)
    Bad.schema:3:17: enum ShipType's generated variant constants (C form) collides with enum ShipType's generated variant constants (C form) — both generate the symbol SHIP_TYPE_NONE; rename at the source (SPEC §4.6)
    schema: 4 error(s)

Four errors for one mistake, and it is a beginner's first enum mistake. The
last three restate the first per target: the Go symbol, the Rust associated
const, the C #define. checkClaimedNames now registers no generated symbol
for a variant that IS one of the three names an enum generates for itself,
so the reserved-word rule stands alone. The predicate is
reservedEnumVariant, read by both sites, so the two lists cannot drift.

    $ bin/schema check .
    Bad.schema:3:17: variant None is a compile error — every enum has None = 0 implicitly (SPEC §4.2)
    schema: 1 error(s)

The collision checks stay live for a name that merely COLLIDES, such as `none`,
`NONE` and `max`, where the per-target wording is the whole explanation.

The C-form label also stops naming the same side twice. One shared `whyC`
covered the sentinels and every variant, so the C line read "enum E's
generated variant constants (C form) collides with enum E's generated
variant constants (C form)" and could not tell you which two declarations
collided. Each registration now carries its own label.

Negative control: dropping either reservedEnumVariant guard turns
TestReservedEnumVariantDrawsOneDiagnostic red at four errors. Restoring the
shared whyC turns TestCollidingEnumVariantKeepsItsPerTargetDiagnostics red.

No generated output moves and no golden is re-pinned.
…G-03, #447 F-02)

Reproduction:

    $ printf 'package bad\n\nenum Big\n{\n    A\n    B\n}\n' > Bad.schema
    $ bin/schema check .
    Bad.schema:5:6: expected }, found "newline"
    Bad.schema:6:5: unexpected "B" at file scope (declarations begin with package, const, enum, flags, type, table or union)
    schema: 2 error(s)

An `enum` or `flags` body is comma-separated variant names. A `type` or
`table` body is line-separated fields. Writing the first the way the second
is written is the most likely early mistake in the language, and neither
line named it. The second was worse than a silence: the newline ended the
variant list, the closing `}` was expected on it, the declaration closed
there, and the next variant reached the top-level loop, so the reader was
told their mistake was at FILE SCOPE and handed the declaration keywords,
two lines below where they actually typed it.

parseVariantList now reads a newline followed by another name as the
separator it was meant to be: it says the rule ONCE per body, then reads the
rest of the body, so the declaration closes where the author closed it.

    $ bin/schema check .
    Bad.schema:5:6: enum variants are COMMA-separated, so A needs a comma after it — a `type` or `table` body separates its FIELDS by newline, and the two body grammars differ (SPEC §4.2)
    schema: 1 error(s)

One fix covers both bodies: `flags` reads the same list.

Negative control: reverting the missing-comma arm turns
TestLineSeparatedVariantsNameTheRuleAndRecover red at two diagnostics
neither of which names comma separation, and
TestLineSeparatedVariantsStillYieldTheirNames red at one declaration.

No generated output moves and no golden is re-pinned.
Reproduction:

    $ printf 'package bad\n\ntable A { x int32 }\ntable B { y int32 }\nunion U { a A\n b B }\ntable T { u ?U }\n' > Bad.schema
    $ bin/schema check .
    Bad.schema:7:14: field u: ?U marks a union optional, and a union is ALREADY optional — ... drop the ? (docs/SPEC-TABLES.md §2.3)
    Bad.schema:5:1: union U: the arm a A is not a declared type, and no table reaches U — ... hold the union in a table body, or make the arm a type (docs/SPEC-TABLES.md §2.6, §11, §15)
    schema: 2 error(s)

The second message is false on its own terms: `table T` reaches `U` in the
very line the first error is about, and the fix it prescribes is what the
schema already does. The `?` refusal dropped the field, and
checkTableArmsReached walked the surviving IR fields alone, so a
single-token mistake sent the reader off to rewrite a correct declaration.

resolveField now records the union a body's field NAMED at the point its
type resolves, before any later rule can refuse the field, and the
reachability walk reads that beside the surviving fields. Arms are excluded
(c.arm), so a union arm inside another union does not fabricate reach.

    $ bin/schema check .
    Bad.schema:7:14: field u: ?U marks a union optional, and a union is ALREADY optional — ... drop the ? (docs/SPEC-TABLES.md §2.3)
    schema: 1 error(s)

Negative control: dropping the unionNamedBy record turns
TestOptionalUnionDrawsOneDiagnostic red at two diagnostics.
TestUnreachedTableArmUnionIsStillRefused holds the refusal live where it is
true, over a union no closure names and one a `type` body holds.

No generated output moves and no golden is re-pinned.
Reproduction:

    $ bin/schema cook --root PackConfig --in tables/pack/pinned/PackConfig \
        --out tree.cook --verbose tables/examples
    report: silent — the data matched the schema exactly
    report: silent — the data matched the schema exactly
    cooked tree.cook: 824 bytes, build version 0x913551e66dc67157, ...

One command, one input, two identical report lines. `cook --in <dir>` packs
the tree and then cooks the wire, so it genuinely reads twice. But two
identical unlabelled lines read as the cook having read one input twice, and
nothing in the output said which read produced which.

reportLineStage names the read. cookInput answers whether it packed, so the
label appears only where there are two reads to tell apart:

    report (pack): silent — the data matched the schema exactly
    report (cook): silent — the data matched the schema exactly

`--in` a wire file reads once and keeps the unlabelled "report:" line.
Without --verbose a silent report still prints nothing. The cooked bytes are
identical before and after.

Negative control: restoring the unlabelled reportLine at both sites turns
TestCookVerboseNamesEachRead red on the indistinguishable pair.

No generated output moves and no golden is re-pinned.
 G-01)

Reproduction:

    $ bin/schema generate --lang cpp --out gen . --verbose
    schema: stat --verbose: no such file or directory

Flag parsing stops at the first positional argument, so everything after it
is another input path and a misplaced flag became a filename. The failure
then surfaced as a syscall error on a string beginning with two dashes.
The reader's mistake is legible to the tool, because nothing that begins with a
dash is a schema path, and every other refusal in the tool names the rule.

    $ bin/schema generate --lang cpp --out gen . --verbose
    schema: --verbose looks like a flag, and flags come BEFORE the first path — everything after the first path is another input path; move it ahead of the paths, or spell a file that really begins with a dash as ./--verbose (SPEC §7)

The guard is in GatherPaths, so every command that takes paths gets it.

Negative control: reverting the guard turns
TestGatherPathsRefusesAMisplacedFlagByName red, back on the stat error.

No generated output moves and no golden is re-pinned.
Reproduction:

    $ printf 'package bad\n\ntable T { const(0xC7, 8)\n x int32 }\n' > Bad.schema && bin/schema check .
    Bad.schema:3:11: const(value, bits) is a packet-wire construct — a table's wire is field-tagged TLV with no bit positions; remove it from table T (docs/SPEC-TABLES.md)
    $ printf 'package bad\n\ntable T { x int32\n align\n y int32 }\n' > Bad.schema && bin/schema check .
    Bad.schema:4:2: align is a packet-wire construct — a table's wire is field-tagged TLV with no bit positions; remove it from table T (docs/SPEC-TABLES.md)

The refusals are right and their reason survives the change, but
"field-tagged TLV" is the framing #507 replaced. A field is now
`id reference, kind, payload` against a trailing id table, and the tag is a
position into that table rather than the id itself. These were the only
place in the tool still naming the old shape.

The message now names the framing SPEC-TABLES §3 states, and the cite
carries the section:

    align is a packet-wire construct — a table's wire is `id reference, kind, payload` against the id table, with no bit positions; remove it from table T (docs/SPEC-TABLES.md §3)

`reserved(bits)` carried the same sentence and moves with the pair the issue
names.

Negative control: restoring "field-tagged TLV" turns
TestTableRefusalsNameTheIdTableWire red on all three.

No generated output moves and no golden is re-pinned.
Reproduction, from a machine east of Greenwich:

    $ date && date -u
    Sat Sep  5 01:05:03 AEST 2026
    Fri Sep  4 15:05:03 UTC 2026
    $ bin/schema tables-baseline --update --reason "first baseline before 1.0 ships" .
    $ sed -n '/## history/,$p' tables.baseline
    ## history
    ### 2026-09-04 — first baseline before 1.0 ships

The stamp is UTC, which is the right clock for an artifact read on other
machines in other zones. It did not say so, so an author writes a baseline
in the evening and reads yesterday's date on the file they just wrote, then
doubts their tooling.

The entry now reads `### 2026-09-04 (UTC) — <reason>`.

SPEC-TABLES §18.4 stated no timezone at all, so the page moves with the
tool and now says which clock and why. §18.1's and USAGE's example history
blocks carry the label.

Entries already committed keep the spelling they have: the history is prose,
salvaged verbatim by every rewrite, and nothing reads a date back. The four
committed tables.baseline files are unchanged.

Negative control: reverting the stamp in Update turns
TestBaselineHistoryStampsUTCAndLabelsIt red on the bare date.

No generated output moves and no golden is re-pinned.
Reproduction:

    $ printf 'package bad\n\nconst C int32 = 1.5\n' > Bad.schema && bin/schema check .
    Bad.schema:3:1: constant C has integer type int32 but a float expression

Column 1 is the declaration. The refusal is about the expression, and a
range refusal on the same kind of line points at the offending value:

    Bad.schema:5:5: field a: its range [10, 20] excludes zero, ...

The three constant refusals that say something about the EXPRESSION rather
than the name now carry the expression's own position: the float expression
under an integer type, the float32 overflow, and the non-finite float. A
constant with no expression at all keeps the declaration's position, because
there is nothing else to point at.

    Bad.schema:3:17: constant C has integer type int32 but a float expression

Negative control: reverting valuePos turns
TestConstValueDiagnosticsPointAtTheExpression red at column 1.

No generated output moves and no golden is re-pinned.
…G-06, #447 F-21)

Reproduction:

    $ cat A.schema
    package nat
    type Vec2 | cpp_native = GameVec2, cpp_include = "vec2.h" { x float32
                                                                y float32 }
    type Body { p Vec2 }
    $ bin/schema generate --lang cpp --out g . && grep -n 'GameVec2\|vec2.h' g/*.h
    $

No mapping, no include, no diagnostic. Split the two declarations across two
files in the same unit and the feature works as documented.

The behavior is deliberate and right: your native header derives from the
generated basis struct, so it includes the header the basis is in, and a
mapped reference inside that same generated header would be circular. SPEC
§4.2 states it: "Inside the basis type's own generated header the mapping
is off ... Sibling types declared in the same schema file therefore store
the basis type", and USAGE's per-language note did not, so a reader who
starts there meets a single-file unit that can never show the feature work.

USAGE's C++ note now carries both facts SPEC states and the attribute's own
spelling hides: the mapping is off inside the declaring file's generated
header, with the worked repro and its two-file fix, and `cpp_native` names a
GLOBAL type, so a namespaced engine type is reached through an alias.

The tool is unchanged: the attribute is a mapping, not a request, and a file
whose own siblings keep the basis type is correct.

No generated output moves and no golden is re-pinned.
…G-19)

Reproduction, an exhaustive single-bit sweep over a `Scene` whose head is a
three-node `ListNode` chain: one mutant at byte 26 turns the first node's
`next` index from 3 into 2, which is that node's own index. The wire loads,
the report is all zero, and the loaded node points at itself.

#535 asks the reference and the Go oracle to refuse or count that. Reading
the page through, they should not, and they already do exactly what it
specifies, in four separate clauses:

SPEC-TABLES §3.1: "A pointer field's payload is a NUMBER: it is
bounds-checked and stored, never followed. There is no traversal on the load
path, and therefore no traversal bound — no depth cap, no visited set, no
ordering rule on the indices." The index names a node that exists and whose
type matches, so no §4 event happened and no counter may move. §3.1 again:
"Load itself is safe on any input: it scans, it terminates, it fabricates
nothing. What a cyclic structure costs is paid by whatever WALKS it, and a
consumer walking untrusted table data — a reflection dump, a text export
(§16) — carries its own visit bound." Both walking consumers do, measured:
the text export and a re-encode each refuse this instance by name with the
§3.1 cite, and each returns rather than recursing away.

§6.5's `data_cycle` is not the missing check either: its own row says it is
"a data cycle reached from a builder, which is the AUTHORING side's `-1` and
the one value here that is not about a wire", and §6.5's status line says
TableRefuseReason is spelled in no target yet (owed as schema#523).

The reader-side check #535 asks for is §14 note 9, "A reader-side
acyclicity check — PRICED, NOT TAKEN ... for a property the writer already
guarantees (§3.1). It is the shape the check would take if untrusted-input
hardening ever demands one." Taking it is that ruling, not a fix wave item,
so this lands the gate instead and leaves the ruling on the issue.

What lands:

- testdata/wire/tables/fuzz-vectors/pointer_self_cycle.bin, the mutant,
  rooted at Scene because that is the root the C++ leg carries a codec for.
  Rooted at ListNode it would have gone into the harness's "absent" bucket
  and never reached the reference. Verified: the run reports 91 seeds and
  93653 mutants with it, 90 and 93652 without.
- The wire fuzzer therefore seeks it rather than searching, and the leg and
  the oracle are held to one answer on it: 0 divergences under both the
  plain and the ASan legs.
- internal/tablewire's TestForgedPointerCycleLoadsSilentlyAndRefusesEveryWalk
  pins all four clauses in Go.

Negative controls, all three red:

- reverting the text writer's cycle guard: "the text export walked a cyclic
  graph and produced 134 bytes".
- reverting the wire encoder's: "a save reproduced a data cycle as 83 bytes".
- adding a counter on the load path: "a counter moved for a pointer index
  that names a node of the right type".

No generated output moves and no golden is re-pinned.
Reproduction:

    $ bin/schema generate --lang c --out gc tables/block
    $ grep -n "TableBlockRows Render\|_span(" gc/RenderBlock.h
    static SCHEMA_UNUSED TableBlockRows RenderFrameShips( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderShip * RenderFrameships_span( const RenderFrameBlock * block )
    ...
    static SCHEMA_UNUSED TableBlockRows RenderFrameDynamicProps( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderDynamicProp * RenderFramedynamic_props_span( const RenderFrameBlock * block )

Two accessors for one field, one exported name PascalCased and the other
gluing the same PascalCase table prefix onto the field's own lowercase
spelling. `RenderFramedynamic_props_span` is what that produces for a
multi-word field. Neither is the snake_case the rest of this backend spells
its name-first surface in, namely `render_frame_block_open`,
`enum_name_ship_type` and `ship_config_load`, and the two could not both
be right.

Both now take the C target's own spelling through `api`, as one name with
two verbs:

    static SCHEMA_UNUSED TableBlockRows render_frame_ships_rows( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderShip * render_frame_ships_span( const RenderFrameBlock * block )
    static SCHEMA_UNUSED TableBlockRows render_frame_dynamic_props_rows( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderDynamicProp * render_frame_dynamic_props_span( const RenderFrameBlock * block )

The accessors are `static SCHEMA_UNUSED` and nothing in the tree called
them, so no C source moves. docs/TUTORIAL.md's part 12 C consumer did print
the old pair, two lines under its own `render_frame_block_open`, which is
where the asymmetry was most visible to a reader. It prints the new pair.

The C++ names are untouched and were never mixed: that backend spells the
pair `RenderFrameShips` / `RenderFrameShipsSpan`, PascalCase throughout,
which is its own convention. The emitter is internal/codegen/ctable, outside
bench/LOCK's prefixes (verified against the cpp-lock job's own matcher). No
locked emitter and no locked generated tree is touched.

`make test-c` passes: the conformance surfaces, the forgery fuzzers and the
wire byte counts are all unchanged.

Negative control: restoring either old spelling turns
TestCBlockAccessorsAreSnakeCasePairs red, on the missing pair and on the
mixed name still being present.

No wire golden is re-pinned. No source-copy golden moved either, because the
C table tree is generated into build/ and not committed.
@gafferongames
gafferongames merged commit aee0dba into main Sep 4, 2026
20 checks passed
@gafferongames
gafferongames deleted the fix-wave-521 branch September 4, 2026 18:55
rowan-claude pushed a commit that referenced this pull request Sep 6, 2026
… trees re-pinned (#521 G-05, #447 F-09)

Two claims, one test each so a regression in either is named on its own: a
[A..B] count is born at A in all nine targets, and a count outside its wire
range is refused in every build mode in all nine, never through a predicate the
build can remove. The second claim also walks every line naming the count and
refuses any that carries the target's own build-removable assert spelling.

The source-copy goldens and the committed generated tree follow. No wire golden
moves and the protocol id is unchanged: a birth value is not a specified
default, so the SPEC §3.1 projection never sees it, and the bytes a valid write
produces are the bytes it produced before.

generated/cpp/Wire.h:172, the reproduction PR #543 recorded in this
repository's own corpus, now reads samples_count = 1 against its declared wire
range of [1, 8], and generated/cpp/WireWire.h refuses a count outside that
range rather than asserting it.
gafferongames added a commit that referenced this pull request Sep 6, 2026
…597)

* tool: a [A..B] count is born at its minimum and a count outside its wire range is refused in every build (#521 G-05, #447 F-09)

A counted array's count bound is the one range in the language that had no
birth value: SPEC §4.6 refuses a scalar or element range that excludes zero
because zero initialization would put the field outside its own range, and a
count bound above zero fell between the rules. generated/cpp/Wire.h:172 carried
it in this repository's own corpus, samples_count born 0 against a declared
wire range of [1, 8], and the write under -DNDEBUG packed 0u - 1u truncated to
three bits and reported success on bytes the reader refuses.

ir.Field.BornCount names the birth value: a counted array's declared minimum,
the one wire-legal count a fresh value can carry. Every target's constructed
form now carries it the way it carries a specified default, and a [..N] array
is born empty as before.

The write side refuses rather than asserts. In C, C++, Dart, Java and
JavaScript the count's range was held by an assert or a checked-build
predicate, gone under -DNDEBUG, without -ea, without --enable-asserts and in
the production JavaScript writer. The count guards the element loop and a
wrapped count is bytes no reader accepts, so it is refused in every build
mode. Go, Rust, C# and Elixir already refused unconditionally through their
error-returning runtimes.

* tool: the nine-way gate on the count's birth and its refusal, and the trees re-pinned (#521 G-05, #447 F-09)

Two claims, one test each so a regression in either is named on its own: a
[A..B] count is born at A in all nine targets, and a count outside its wire
range is refused in every build mode in all nine, never through a predicate the
build can remove. The second claim also walks every line naming the count and
refuses any that carries the target's own build-removable assert spelling.

The source-copy goldens and the committed generated tree follow. No wire golden
moves and the protocol id is unchanged: a birth value is not a specified
default, so the SPEC §3.1 projection never sees it, and the bytes a valid write
produces are the bytes it produced before.

generated/cpp/Wire.h:172, the reproduction PR #543 recorded in this
repository's own corpus, now reads samples_count = 1 against its declared wire
range of [1, 8], and generated/cpp/WireWire.h refuses a count outside that
range rather than asserting it.

* tool: a union's tag enum exports Count and its debug name, in all nine (#447 F-15, #521 G-08)

A generated <Union>Type looks and acts like a declared enum — implicit None,
dense variants, an exported extent, unsigned storage — so a reader logging
which message arrived writes the enum's own name call against it. It carried no
Count in any target, because #489 gave declared enums one and did not reach tag
enums, and a debug-name function in exactly one: C's
enum_name_weapon_fire_type. The other eight got "use of undeclared identifier"
on the first thing a message system's user does.

Each target now emits both, in its own spelling: EnumName overloaded in C++,
EnumNameWeaponFireType where the language has no overloads, snake_case in C,
Rust and Elixir. The nine-way gate declares an enum of three variants beside a
union of two, so a count claim names the tag enum and not its neighbour.

Both are diagnostic surface and neither is on a path: Count is a constant, and
the name function has no caller in generated code. The read and write
benchmarks either side of this PR are in its body.

* tool: the guard comment spells the branch the way the reflection descriptors do (#447 F-12)

Generated storage marks each branch field with the guard that puts it on the
wire. The comment spelled it "if on_radar", nested with " / " and negated with
a trailing " else", so the else side read "if on_radar else" — which is not a
sentence, and says the opposite of what a beginner parses on first read.

The reflection descriptors always carried the right spelling, built by each
table backend's guardWalk: at_rest, !at_rest, active && has_target. The comment
now spells it the same way, so a reader who compares a storage comment against
the descriptor a table-JSON walker parses at runtime sees one language.

Field.Guard reaches comments only in every backend, so nothing on a wire moves.
The nine-way gate nests three branches and takes both sides of the innermost,
so the negation, the conjunction and the else side are all under it, and it
refuses the stale spelling anywhere in a target's output.

* tool: the count gate walks lines with strings.SplitSeq (#521 G-05)

The line walk that proves no assert reaches the count ranged over
strings.Split, which the pinned modernize analyzer rewrites to SplitSeq.

* check: a packet union's arm named Count is refused by name (#447 F-15)

A packet union's tag enum carries Count now, so an arm whose exported spelling
is Count would define the member twice, which C++ and C# refuse as a
redefinition. Max has been refused by name since the tag enum carried an
extent; Count joins it, in the declared enum's own wording.

The reservation is scoped exactly to where the member exists. A TABLE-CLOSURE
union's tag shape is emitted beside the tables and carries Max alone, so the
name stays free there — tables/messages/Messages.schema's EditBody uses it, and
reserving a name against a member that does not exist would break the corpus
for nothing. Both sides are gated. Giving the table-closure tag shape its own
Count is a named follow-on, and it is the pass that would reserve the name
there too.

Generated output for a unit that passes is unchanged, so no golden moves.

* test: the count's contract is a refusal in every build, in the legs that asserted it (#521 G-05, #447 F-09)

Three leg tests pinned the count's OLD contract and go red on the fix, which is
the fix arriving where it should:

- test/dart and test/java asserted that an out-of-range count trips the
  checked-twin writer contract. It no longer does, because a count is not a
  checked-twin contract: the write refuses it in every build. Each now checks
  the refusal directly, which also holds without --enable-asserts and
  without -ea, where the old claim was skipped outright.
- test/rust asserted that a freshly constructed ProbeSample is WIRE-ILLEGAL,
  samples_count born 0 against [1, 8]. That is exactly the defect. It now
  checks the two halves that replace it: the count is born at 1 and a fresh
  value writes cleanly, and a count set below the minimum is still refused.

test/elixir's raise is unchanged: the BEAM has no compile-out assert, so its
count contract was already on in every build.

* docs: the count's birth and its every-build refusal, and the tag enum's full surface (#521 G-05, #447 F-09, F-15)

SPEC §4.2 states a [A..B] count with A above zero as the one exception to
zero initialization: born at its declared minimum, because zero is outside
that count's own wire range and an array takes no specified default to name
another birth value.

SPEC §4.6 carries the rule the generated comments cite. The count bound is
the stated exception to the excludes-zero refusal, and a count outside
[A, B] is refused by the write in every build, in all nine targets, each in
its own convention.

SPEC §5 states that refusal as the one exception to its debug-assert tiers:
C++, Dart and Java refuse from the write rather than through an assert, and
JavaScript's flat production tier refuses rather than trusting the caller.

SPEC §4.8, §4.11 and §4.2's exported-extent rule state the tag enum's full
surface: None, the variants, Count and Max, plus the debug-name function in
every target, with the nine spellings listed once. Count is reserved as an
arm name on a packet union and refused by name. A table-closure union's tag
shape carries Max alone today, which is #601.

SPEC §6.1's [Min..N]T row and its union item say the born state and the
generated surface. USAGE and TUTORIAL match, and the tutorial's part 4
hazard now shows what the tool emits.

* test: the debug-name gate is anchored to the name function's body, and the comments state the rule

The coverage gate searched the whole generated output for None, Laser,
Missile and "???". All four appear elsewhere: Laser and Missile name the arm
types, None is the tag enum's own member, and "???" is the declared enum's
name function default. Deleting the tag enum's name function left the gate
green. It now slices the function's body, from the opening line each target's
claim already carries to the out-of-set arm, so a missing function and a
missing name are both refused. The nine spellings move into a named struct so
one map serves both gates.

emitWriteCount's bits == 0 branch is deleted. SPEC §4.6 refuses [Min..N]T
with Min at or above N, so a count range is never degenerate and the branch
had no case to serve. The generated corpus is byte-identical across the
delete.

The comments state present-state rules rather than narrating what the tool
used to do, in the two test headers, the C and C++ count emitters, and the
count-birth comments in every backend. Em dashes and semicolons out of the Go
comments this branch added, and neighbour to neighbor.

* docs, test: §4.6 says why the write refusal lives there, and the body slice uses strings.Cut

SPEC §4.6's list is compile errors, so the write refusal now says out loud
that it is the other half of the same rule and that it is stated there because
that is the section every emitted refusal comment cites. §4.11's Count bullet
states the reservation rather than dating it.

The name function's body slice uses strings.Cut, which the modernize lint
asks for and which reads better. The control still goes red with the C++ name
function removed.

* docs: the page reader's four fixes on the count and tag-enum law

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

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