Skip to content

ci: enforce the flake checks, by name, instead of exempting all of them - #1007

Merged
andrewgazelka merged 1 commit into
mainfrom
eng-10817-self-hosted-nix-gates
Jul 28, 2026
Merged

ci: enforce the flake checks, by name, instead of exempting all of them#1007
andrewgazelka merged 1 commit into
mainfrom
eng-10817-self-hosted-nix-gates

Conversation

@andrewgazelka

@andrewgazelka andrewgazelka commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes ENG-10817.

What was wrong

nix flake check ran under continue-on-error: true. No nix gate this
repository has landed had ever been built by CI. The last green-looking run on
main, 30340104855,
reported 0 of 41 checks passing and the job was still advisory.

Why nothing built, measured

The daemon decides whether a content-addressed derivation can be realised, and
cargoUnit content-addresses every crate unit.
Run 30341528713
built a one-line CA derivation under three configurations:

config result
baseline CA-BUILD-FAIL
NIX_CONFIG: experimental-features = ... ca-derivations CA-BUILD-FAIL
nix-installer-action extra-conf: extra-experimental-features = ca-derivations CA-BUILD-OK

The flake's own nixConfig and a workflow NIX_CONFIG reach the client only,
which is why the failure read as store path '...bedwars-0.1.0.drv' does not exist. The gate re-checks this itself and prints what to set.

Real versus environmental

Per check rather than as one all-or-nothing job,
run 30341722090,
41 checks:

  • 38 pass, including every minecraft-* up-to-date check, nixos-modules,
    genmap-url-pinned and differential-traces.
  • 3 fail: e2e, smash-e2e, completions-e2e, all on the same line.
hyperion-e2e> the game server exited before opening 127.0.0.1:60917
hyperion-e2e> Client::new(): reqwest::Error { kind: Builder, source: General("No CA certificates were loaded from the system") }

Real, not the runner. The server builds a reqwest client at startup and reqwest
will not construct one without a trust store; a darwin build finds the keychain
and a NixOS host often leaks /etc/ssl into the sandbox, so these gates only
ever looked green on the machines they were written on. SSL_CERT_FILE in
nix/e2e.nix makes the bundle an input.

differential-traces is the one that looked ambiguous: it failed on run
30341066882 and then passed four times (30341722090 plus three independent
repeats in
30342250503).
The only difference was ca-derivations. Environmental, and enforced.

Every package builds, 19 of 19 in each of three runs. The prose this replaces
said packages "cannot succeed yet" and that cargoUnit could not vendor this
lock. nix flake check never built packages at all, it prints
(build skipped) for each, and they build.

What the first real run caught

Run 30342733873
is the first time smash-e2e has ever run to completion in CI. It failed, on
47 of 47 abilities:

COOLDOWN FAILED Blaze / Firefly has a 9.0s cooldown and let a second use through
...
 280.23s         NOT PROVED  cooldowns refused a second use
 280.23s       RESULT: 1 of 12 steps did not happen

The action bar had become an NBT compound when the component API landed in
#1004, and tools/smash-match.py read only Tag::String, so it logged
<- action bar: <nbt tag type 0x0A, not a string> and never saw the refusal.
It was red on main for two commits and nothing reported it. #1006 fixed the
reader; ENG-10838 has the write-up and the remaining gap.

What is enforced now

nix/ci/flake-gate.nix, subtractive: every attribute of checks is built
unless named in excluded, so a check added tomorrow is enforced the day it
lands. nixos-modules moved into the per-system set so nothing sits outside
the gate's view, and the gate reads its own names with builtins.attrNames,
which is how three checks that landed in sibling PRs while this branch was open
(smash-text-no-legacy-formatting, smash-selector-e2e,
smash-selector-e2e-app) became enforced with no edit.

45 enforced. Exclusion list empty.

Watching the guards fire

Three guards, none of them trusted on reading alone.

An exclusion that starts passing turns the gate red. Run 30342733873 still
had differential-traces excluded while it in fact passed:

STALE     differential-traces
checks.x86_64-linux.differential-traces is excluded from CI but now builds.
Delete it from nix/ci/flake-gate.nix; CI enforces it after that.

enforced checks that failed: smash-e2e
##[error]Process completed with exit code 1.

Breaking something only a newly enforced check protects turns the job red.
PR #1008 reordered --port ahead of --ip in the game server module. No cargo
job can see that: Formatting, Clippy, Documentation, Tests, deny, unused-deps
and Code Coverage all read rust.
Job 90222622214
went red with the message intended:

FAILED    nixos-modules
hyperion-nixos-modules> hyperion NixOS module argv lost: /bin/bedwars --ip :: --port 35565
hyperion-nixos-modules> what the modules built:
hyperion-nixos-modules> ...bin/bedwars --port 35565 --ip :: --root-ca-cert ...

Reverted immediately.

A misspelled exclusion throws at evaluation.

$ nix eval .#checks.aarch64-darwin.flake-gate.drvPath
error: nix/ci/flake-gate.nix excludes checks that do not exist: differential-tracez
       Delete them, or spell them the way `checks.aarch64-darwin` does.

And the ca-derivations preflight, run against a store without it:

$ NIX_CONFIG="experimental-features = nix-command flakes" flake-gate .
this nix store cannot build content-addressed derivations.
add to the DAEMON config (/etc/nix/nix.conf, then restart it):
  extra-experimental-features = ca-derivations
on GitHub Actions that is the nix-installer-action 'extra-conf' input.

Not done, and tracked

runs-on stays ubuntu-latest against the house rule. The fleet cannot serve
this org: ix crates/ci/dispatcher/src/config.rs holds GithubApp.org as one
String and github.rs registers every JIT runner at orgs/{org} with
org = indexable-inc, so a hyperion-mc job is never offered one. Pointing
runs-on at an ix-ci-run-* label today would hang until timeout, which is
strictly worse than a hosted runner that works. ENG-10824. The gate is the
same nix build .#checks.x86_64-linux.<name> either way, so the migration is
one line when it lands.

No CI job here is a required status check, and the comment on the check job
claimed the opposite. Corrected in this PR; ENG-10827 proposes adding the
rule, not done here because four agents had PRs in flight and turning eight
jobs into required contexts mid-flight would have blocked all of them.

Authored by an AI agent (Claude Opus 4.5 via Claude Code).

@github-actions github-actions Bot added the ci label Jul 28, 2026
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.7 ns ]      +0.06%
ray_intersection/aabb_size_1                       [  18.7 ns ...  18.7 ns ]      -0.01%
ray_intersection/aabb_size_10                      [  18.8 ns ...  18.8 ns ]      -0.23%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.07%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      -0.11%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      +0.24%
overlap/no_overlap                                 [  15.6 ns ...  15.6 ns ]      -0.25%
overlap/partial_overlap                            [  15.6 ns ...  15.5 ns ]      -0.17%
overlap/full_containment                           [  14.5 ns ...  14.5 ns ]      +0.02%
point_containment/inside                           [   5.7 ns ...   5.8 ns ]      +0.31%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.19%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      +0.24%

Comparing to 8c04c38

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 47.17%. Comparing base (4779844) to head (3a30234).
⚠️ Report is 3 commits behind head on main.

@@           Coverage Diff           @@
##             main    #1007   +/-   ##
=======================================
  Coverage   47.17%   47.17%           
=======================================
  Files         238      238           
  Lines       27097    27097           
  Branches     1087     1087           
=======================================
  Hits        12784    12784           
  Misses      14077    14077           
  Partials      236      236           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`nix flake check` ran under `continue-on-error: true`, so no nix gate this
repository has landed had ever been built by CI. The last green-looking run on
main, 30340104855, reported zero of 41 checks as passing and the job was still
advisory. ENG-10817.

## Why nothing built

The daemon decides whether a content-addressed derivation can be realised, and
cargoUnit content-addresses every crate unit. The flake's own
`nixConfig.extra-experimental-features` and a workflow-level `NIX_CONFIG` both
reach the client only. Run 30341528713 measured the three side by side on a
one-line CA derivation: baseline fails, client-only fails, `extra-conf` on the
installer action passes. Hence the job's own error, `store path
'...bedwars-0.1.0.drv' does not exist`, which points nowhere near the cause.
The gate now checks this itself and says what to set.

## Real versus environmental, measured per check

With the daemon fixed, run 30341722090 built all 41 checks individually on
ubuntu-latest. 38 passed. The 3 that failed were the sandboxed end to end
gates, all with the same line: the server builds a reqwest client at startup
and reqwest will not construct one without a trust store. A darwin build finds
the keychain and a NixOS host often leaks /etc/ssl into the sandbox, so those
gates only ever looked green on the machines they were written on. That is a
real defect and nix/e2e.nix names the CA bundle now.

`differential-traces` failed once, on run 30341066882, then passed four times:
run 30341722090 plus three independent repeats in 30342250503. The only
difference was ca-derivations, so it was the store and not the check.

Every package builds too, 19 of 19 in each of three runs. The comment this
replaces said `packages` "cannot succeed yet" and that cargoUnit could not
vendor this lock. `nix flake check` never built packages in the first place, it
prints "(build skipped)" for each one, and they build.

## What replaces the exemption

nix/ci/flake-gate.nix, subtractive: every attribute of `checks` is built unless
it is named in `excluded`, so a check added tomorrow is enforced the day it
lands. `nixos-modules` moved into the per-system set so nothing sits outside
the gate's view, and the gate reads its own names with `builtins.attrNames`,
which is how three checks that landed in sibling PRs while this branch was open
became enforced with no edit.

The exclusion list is empty. An entry is the evidence that set it, never a
justification: the gate prints that text, builds the check anyway, and fails if
it passes. A misspelled exclusion throws at evaluation.

Both guards were watched failing, in CI and not only locally. Run 30342733873
had `differential-traces` excluded while it in fact passed, and reported
`STALE differential-traces` with the entry to delete. A deliberate argv
reordering in the game server module, PR 1008, turned the job red with
`hyperion NixOS module argv lost: /bin/bedwars --ip :: --port 35565` and was
reverted.

That first full run also caught a real regression nothing else would have:
`smash-e2e` failed 47 of 47 cooldowns because the action bar had become an NBT
compound the match client could not read. It was red on main for two commits.
#1006 fixed the reader; ENG-10838 has the write-up.

runs-on stays hosted against the house rule. The fleet cannot serve this org:
ix `crates/ci/dispatcher/src/config.rs` holds `GithubApp.org` as one `String`
and registers every runner at `orgs/indexable-inc`. ENG-10824.

Authored by an AI agent (Claude Opus 4.5 via Claude Code).
@andrewgazelka
andrewgazelka force-pushed the eng-10817-self-hosted-nix-gates branch from 430e846 to 3a30234 Compare July 28, 2026 09:23
@andrewgazelka
andrewgazelka merged commit b5ce627 into main Jul 28, 2026
9 of 11 checks passed
@andrewgazelka
andrewgazelka deleted the eng-10817-self-hosted-nix-gates branch July 28, 2026 09:23
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  22.5 ns ...  22.5 ns ]      -0.01%
ray_intersection/aabb_size_1                       [  22.5 ns ...  22.4 ns ]      -0.48%
ray_intersection/aabb_size_10                      [  22.5 ns ...  22.4 ns ]      -0.10%
ray_intersection/ray_distance_1                    [   0.9 ns ...   0.9 ns ]      +0.03%
ray_intersection/ray_distance_5                    [   0.9 ns ...   0.9 ns ]      +0.98%
ray_intersection/ray_distance_20                   [   0.9 ns ...   0.9 ns ]      -0.00%
overlap/no_overlap                                 [  15.1 ns ...  15.1 ns ]      +0.03%
overlap/partial_overlap                            [  15.2 ns ...  15.2 ns ]      +0.08%
overlap/full_containment                           [  14.5 ns ...  14.5 ns ]      +0.16%
point_containment/inside                           [   6.0 ns ...   6.0 ns ]      +0.03%
point_containment/outside                          [   6.0 ns ...   6.0 ns ]      +0.23%
point_containment/boundary                         [   6.1 ns ...   6.0 ns ]      -0.19%

Comparing to 4779844

andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…1012)

## The bug

Twelve of the fifteen Super Smash Mobs kits left hotbar slot 0 empty and
numbered their abilities from 1. A vanilla client selects slot 0 on its
own when
it reaches the world and no packet in the join sequence changes that, so
twelve
kits handed a player a bar whose first ability could not be fired until
they
scrolled. An operator saw it in a real client: an empty first square,
then a
sword, an axe and a block.

Nothing failed. Every ability was present, correctly bound and
reachable, and no
gate had an opinion about which key a hand starts on.

## The layout, and why it is a shift

Enumerated from source, all fifteen kits already declared their
abilities in
ascending slot order with the ultimate last, and the three that were
right
(Enderman, Skeleton, Slime) were exactly the three whose first ability
was
numbered 0:

```
enderman       0 1 2 8    ok        skeleton   0 1 2 8    ok
slime          0 1   8    ok        blaze        1 2 8     no slot 0
guardian         1 2 3 8            iron_golem   1 2 3 8
...twelve in all
```

Slot 8 was already consistent across the whole roster, so the far-right
convention for the Smash Crystal was understood and simply not applied
at the
left end. Every kit's starting abilities were contiguous, just starting
from the
wrong number. So the whole set was one key to the right of where it
belonged and
the fix is a shift.

The alternative reading, that slot 0 was reserved for a melee weapon not
yet
implemented, does not hold: `KitStats` documents that a kit hits for the
same
amount with any item including a fist, nothing in `kit.rs`, `server.rs`
or
`adapter.rs` has any notion of a weapon item, and `Op::SetHotbar` clears
the
inventory and refills it purely from abilities, so nothing could ever
have
occupied slot 0.

## Why the values were not the whole problem

There is nothing a per-ability `slot: u8` can be checked against on its
own.
`slot: 1` is wrong only in relation to the rest of its kit, and the doc
comment
on `AbilitySpec` showed a `slot: 1` (Enderman's Blink, genuinely its
second
ability) that new kit files then copied as their *first*. That is the
shape that
let twelve kits drift.

So the field is gone. `KitBuilder::ability` hands out slots in
declaration order
from 0 and `KitBuilder::ultimate` always takes `ULTIMATE_SLOT`. The
order a kit
file declares its abilities in *is* the layout. Fifty-one hand-written
numbers
deleted, and the empty first key is now unreachable rather than merely
absent
today. Declaring more starting abilities than fit to the left of the
ultimate is
an assert at module init rather than an ability nobody can press.

## The gates

`events/smash/tests/hotbar.rs` sweeps `kit::registry` and
`ability::manifest`,
following the pattern `tests/sound.rs` set, so a kit added tomorrow is
covered
the moment its module runs. Four invariants, and each earns its place:

* **slot 0 is filled** on every kit. The operator's bug, stated as
itself, so a
failure log reads "Blaze leaves slot 0 empty" rather than a puzzle about
sets.
* **starting abilities run from 0 with no holes.** Contiguity and not
merely
"starts at 0", because a gap is a dead key in the middle of the bar and
two or
  three abilities have no business being spread across nine keys.
* **every ultimate sits in the last slot.** It is the one binding a
player
  carries across every mob, which is worth nothing if a kit can move it.
* **no slot is off the end of the bar.** Worth having independently:
this is the
  one that catches a typo, and `set_hotbar` returns an error the adapter
discards, so an ability in slot 12 is silently absent rather than loud.

Plus two that read one layer out: playing every kit on a real player and
reading
`kit::hotbar`, which is the function `push_stale_hotbars` calls, because
a
perfect declaration can still be dropped on instantiation; and granting
a Smash
Crystal, which reaches the bar through a different path from
`kit::apply`.

`nix run .#smash-hotbar-e2e` proves it over the protocol. One client
stands in
the hub (the lobby needs four to start a countdown), changes kit fifteen
times,
and holds the `ClientboundContainerSetSlot` packets it receives against
the
server's own `/abilities` output: slot 0 filled, keys exactly the ones
the
registry names, items matching, no holes, and slot 8 empty until a
crystal is
picked up. Seventy-six checks. Framing comes from `tools/client-26.2.py`
by way
of `smash-match.py`, the same route every other scripted client takes.
Ports
default off `smash-identity-e2e`'s, so it does not collide with the pair
already
sharing +4000 (ENG-10834).

### Watching them fail

Reintroducing the bug as `next_slot: 1`:

```
test starting_abilities_fill_the_left_of_the_bar_with_no_holes ... FAILED
a kit's starting abilities must occupy slot 0 upwards with no gaps:
["Blaze lays out {1, 2}, wanted {0, 1}", ... all fifteen]

test every_kit_puts_something_on_the_key_a_hand_rests_on ... FAILED
these kits leave hotbar slot 0 empty, so a player who spawns and right-clicks
fires nothing: ["Blaze", "Chicken", "Cow", ... all fifteen]

test playing_any_kit_hands_out_a_bar_that_starts_at_slot_zero ... FAILED
```

The same change against the e2e gate, which is the one that reads the
wire:

```
FAIL Blaze fills slot 0, the key a client has selected when it spawns
     (bar: 1:minecraft:iron_sword, 2:minecraft:iron_axe)
FAIL Blaze runs from slot 0 upwards with no holes: [1, 2]
... 30 failures, rc=1
```

Making `next_slot` step by 2 fires the contiguity check only, and
raising
`ULTIMATE_SLOT` past `HOTBAR_SLOTS` fires the range check only, so each
invariant was watched failing on its own cause.

## What depended on the old numbering

Three things, and two of them would have failed quietly:

* `tools/smash-match.py --ability-slot` defaulted to 3 for Iron Golem's
Seismic
Slam, now slot 2. A stale value produced *no message at all*: an empty
slot
has no registry entry to disagree with, so the check pressed a key
holding
nothing, saw no motion, and reported a broken knockback model. It is now
a
recorded failure the report reads, since the old complaint was appended
to a
  list already folded into a proof by the time the match phase runs.
* `tests/modularity.rs` asked slot 3 for a ground requirement and got
`Ok(())`,
which is what `activate` returns for a slot holding nothing. Its
cooldown test
  passed on slot 1 while measuring a different ability than it named.
* `tests/verification.rs` drives four test-local kits by slot. Skeleton,
one of
the three already correct, is deliberately left on slot 1 there: slot 0
is
Barrage, which charges rather than firing, so a bare `use_slot` on it
never
  reaches the cooldown that test is about.

## Verdict on the API shape

The values were wrong, but so was the shape. A per-ability slot number
with no
notion of the kit's layout is unfalsifiable in isolation, and the doc
example
made the wrong value the default thing to copy. Deriving the layout from
declaration order is what makes the class impossible rather than merely
gated,
and it costs a kit author nothing: no kit in the roster wanted a
non-contiguous bar.

`Slot` stays a plain component and not a relation. A slot is a scalar
property of
an ability, not a reference to another entity: there is no slot entity
to point
at, and no sharing for a relation to earn its keep on, unlike
`(PlaysOnCast,
sound)` where the sound really is interned and shared across abilities.
Making
it a relation would mint nine singletons to encode a `u8` and turn
`granted_in_slot`, which runs on every right-click, from a component
read into a
target lookup. The layout is likewise not stored on the kit: walking
`(Grants, ability)` already derives it, and a second copy is a thing
that can
disagree.

## Verified locally

Rebased onto `b5ce627`, which closed ENG-10817 and made every attribute
of
`checks` enforced in CI subtractively. So `checks.smash-hotbar-e2e` and
`checks.smash-hotbar-e2e-app` are enforced the day they land, with no
edit to
`nix/ci/flake-gate.nix`, which is what `nix eval` confirms:

```
$ nix eval --json .#checks.aarch64-darwin --apply \
    'c: builtins.filter (n: builtins.match ".*hotbar.*" n != null) (builtins.attrNames c)'
["smash-hotbar-e2e","smash-hotbar-e2e-app"]
```

The `+6000` port offset is for `nix run` only; the sandboxed check picks
a free
port, so it cannot collide with the pair already sharing `+4000`
(ENG-10834).

Run locally on aarch64-darwin, after the rebase:

* `nix run .#test`: 636 passed, 1 skipped (the count moved with #1010
and #1011
  deleting crates on main; it was 656 before the rebase)
* `nix run .#lint`: clean
* `nix run .#fmt`: clean
* `nix run .#smash-hotbar-e2e`: RESULT ok, 0 checks failed, 76 passes
* `nix run .#smash-e2e`: RESULT a whole match ran at protocol 776, rc=0.
This
is the gate whose `--ability-slot` moves, and it proved knockback from
an
ability on the new slot: P4 took (-2.227, 0.646, -2.227) blocks/tick,
which
solves to the same strength horizontally and vertically and so matches
the
  model rather than a double impulse.

Not done, and worth knowing:

* The mutation gate (`nix run .#mutants`) was not run.
* The other four e2e gates were not re-run, since nothing here touches
their
  paths. `smash-e2e` was, because this moves its `--ability-slot`.
* Neither e2e gate was run on linux. CI will be the first to do that,
and per
  #1007 the sandboxed smash gates do now work there.
* `cargo test -p smash` segfaulted once under parallel test threads, in
  `game_flow`, and passed on the next run and every run with
`--test-threads=1`. Pre-existing flake in flecs registration, not caused
by
  this change, and unfiled: it is outside this change's scope.

Authored by Claude Code (Opus) at the operator's direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
## The gap, measured

`crates/hyperion-minecraft-proto/protocol.json` carried **65 entries
marked
`complete: false`**. Each one is a packet or a data component whose byte
layout
the extractor could not recover in full, which means somebody
hand-writes it or
the feature does not exist. Nothing anywhere reported the number, so
nobody knew
there were 65. One of them was `minecraft:interact`: no entity
interaction of any
kind worked in this game until #1006 found it by hand.

Grouped by the reason that stopped each one:

| n | cause |
| --- | --- |
| 15 | runtime-dispatched union (`StreamCodec.dispatch`, registry
dispatch) |
| 13 | a helper `x.write(buf)` whose own body was itself unresolved |
| 6 | optional `ItemStack` (`if (itemStack.isEmpty())`) |
| 6 | conditional presence, an `if` that is really an `Option` or a
small union |
| 5 | `DataComponentPatch` add and remove split |
| 4 | booleans packed into one bitfield byte |
| 4 | loop over a fixed or length-known sequence |
| 4 | `CustomPacketPayload`, an identifier plus trailing opaque bytes |
| 3 | `Vec3#LP_STREAM_CODEC`, the quantised velocity vector |
| 3 | one-off unmodelled statements |
| 2 | two singletons (`potion_contents`, `update_recipes`) |

Two things about that table are worth more than the counts.

**The 13-entry bucket was not a cause at all, it was a reporting hole.**
`_read_nested_write` recursed into the helper, and when the helper did
not
resolve it returned `None`, so the reason printed named the outer call
site and
the real cause was discarded. Thirteen of the 54 partial packets said
nothing
about why they were partial.

**The #1006 diagnosis was wrong in an instructive way.** That PR
reported the
generator "had everything it needed and refused anyway" over one
statement in the
`Vec3` LP codec. It did not. `net.minecraft.network.LpVec3.write` is one
byte for
a near-zero vector, otherwise six bytes carrying three 15-bit quantised
components and a two-bit shared scale, plus a trailing `VarInt` when the
scale
overflows two bits. No field list describes that and no amount of
statement
modelling recovers it. The refusal was correct. What was missing was a
way to say
so about **one field**, because this crate has implemented that exact
codec all
along, in `packets/play/entity.rs`.

## What this changes

**A field can be delegated to a hand-written codec.** `protocol.json`
gains
`{"kind": "custom", "codec": <name>}`. `CUSTOM_CODECS` in
`nix/extract-protocol.py`
names the Java codecs it applies to; `CUSTOM_CODECS` in `build.rs` names
the Rust
type and the `#[proto(with = ...)]` module that owns those bytes.
Nothing about
the layout is asserted, so unlike the existing `CODEC_FIELDS` table this
one
cannot silently disagree with the source about a byte order. It claims
only that
the codec exists and that Rust owns it.

Held together in both directions, and both watched failing:

```
$ nix build .#packages.aarch64-darwin.minecraft-protocol   # after renaming the Java member
> modelled Java names that no longer exist:
>     net.minecraft.world.phys.Vec3.LP_STREAM_CODEC_RENAMED

$ cargo build -p hyperion-minecraft-proto                  # after renaming the Rust row
thread 'main' panicked at crates/hyperion-minecraft-proto/build.rs:969:25:
protocol.json names the custom codec `lp_vec3`, which build.rs has no Rust type
for; add it to CUSTOM_CODECS
```

**Refusals name the field.** `unresolved_reasons` threads a field path,
and a
nested helper write now reports the reason from inside the helper rather
than the
call site. `minecraft:explode` went from `dispatched codec: the layout
depends on
a runtime type` to `blockParticles[].value.particle: dispatched codec
...`, which
is a different kind of sentence: it names work.
`minecraft:merchant_offers` now
points at `offers[].itemCostA.components[]`.

**27 protocol enums stopped being bare varints.**
`ByteBufCodecs.idMapper` on an
enum's own codec was emitting `{kind: varint}`, which is why 29 enums
including
`GameType`, `Difficulty`, `DyeColor`, `Rarity`, `InteractionHand` and
`EquipmentSlotGroup` had to be retyped by hand downstream. The extractor
now
follows the key extractor (`GameType::getId`, `hand -> hand.id`,
`Enum::ordinal`)
back to the constructor argument the ids come from and reads them. Read,
not
assumed: `Rabbit.Variant.EVIL` is 99, not its ordinal 6.

The two that still refuse do so honestly. `EquipmentSlot` takes its ids
from one
of two constructors and this reader cannot tell which constant used
which;
`TropicalFish.Pattern` computes its id rather than storing it. Both fall
back to
a varint rather than guess.

**The gap is now visible and bounded.** `nix flake check
.#minecraft-proto-coverage`
prints the remaining gap grouped by cause on every run, pass or fail,
and holds
the list of ids against `nix/proto-coverage-baseline.json`. A ratchet,
not a
target of zero, and it fails in both directions: a new gap is a
regression, and a
closed one means the baseline has stopped bounding anything. `nix run
.#sync-minecraft-proto` writes the baseline, so tightening it is the
same command
as regenerating what it describes.

Because #1007 landed, this is enforced in CI the day it merges with no
edit to
`nix/ci/flake-gate.nix`.

### Watching the ratchet fail, on the real regression

Deleting the `CUSTOM_CODECS` entry, which is exactly the state that lost
`minecraft:interact`:

```
62 layouts not recovered in full, by cause:
   18  unmodelled statement
   13  runtime-dispatched union
    7  conditional presence
    ...
NEW GAP play/clientbound/minecraft:add_entity
          movement: unmodelled statement: double z
NEW GAP play/clientbound/minecraft:set_entity_motion
          movement: unmodelled statement: double z
NEW GAP play/serverbound/minecraft:interact
          location: unmodelled statement: double z

3 layout(s) stopped being recoverable. Each one is a packet that now has to be
hand-written or does not exist, so fix the extractor rather than widening the
baseline.
```

And the other direction, with two ids added to the baseline:

```
CLOSED  play/serverbound/minecraft:interact

1 layout(s) are now recovered, so the baseline is looser than the code. Tighten
it with: nix run .#sync-minecraft-proto
```

## Hand-written code retired

`AddEntity`, `SetEntityMotion` and `Interact` are generated and only
re-exported
from `packets/play/entity.rs`. The verification is not that they
compile:
`add_entity_matches_the_server` and
`set_entity_motion_matches_the_server`
compare the generated codecs against bytes Mojang's own encoder printed,
and pass
unchanged. `interact_is_generated_and_round_trips` is new, and pins the
byte
count so a `location` that silently became three `f64`s would fail
rather than go
quiet.

`GameType` is generated. The `byId` helpers and a `Default` stay
hand-written in
`play_login.rs`, deliberately: which variant is the default and how an
out-of-range id resolves are facts about the game, not about the wire,
and the
generator only reads layouts.
`crates/hyperion/src/simulation/gamemode.rs` is
untouched and its `wire_ids_match_game_type` test now checks the
generated type.

`tests/play_combat.rs` had `operation: 1`. It is
`Operation::AddMultipliedBase`
now, which is the whole point of the enum work showing up as a compile
error in a
test rather than as a wrong byte on the wire.

## Judgements, including what I did not do

**Refusal is not granular in the second sense the brief asked about, and
should
not be.** A packet with one unreadable field is still refused whole
rather than
emitted with that field missing. A codec short by one field looks
correct and
desynchronises the stream; there is no honest partial packet to emit,
which is
what `build.rs`'s own header has always said. What the escape hatch
changes is
the cost of fixing one, from writing a packet to writing a table row,
and the
field-path reporting is what makes the row obvious to write. Emitting
the packet
with a required hand-written hook was the other option considered; it
would turn
62 silent gaps into 62 build failures on a tree nobody touched, which is
a
different bug.

**`#[repr(i32)]` on generated enums was considered and rejected.** The
derive
encodes an enum through a `match` to a varint, not an `as` cast, so a
repr buys
no correctness and costs size: the default lets rustc pick one byte
where one
byte will do, and the wire form is variable-length anyway, so there is
no fixed
wire size for the repr to match. If the derive ever switches to a cast,
the repr
becomes load-bearing and should be added then.

**Decoding is already the single boundary.** The derive's generated
`decode` is a
`match` returning `Error::InvalidEnum` for anything unknown, with no
`Unknown(u32)` catch-all, so every generated protocol enum is closed
inside the
server and checked exactly once at the edge. That was already true; this
PR only
widens how many types get it.

**Not in this PR, and sequenced deliberately.** The registry-id work
(sound
events, entity types, blocks, items as generated enums) and the
migration of the
659 raw `minecraft:...` literals in hand-written code are stages 2 and
3. Stage 1
is the foundation: the `custom` escape hatch, per-field reporting, and
the
ratchet. Landing it first is what makes the rest cheap, and keeps this
diff
reviewable.

**Buckets I looked at and left.** `ItemStack.OPTIONAL_STREAM_CODEC` (6
entries)
and `DataComponentPatch` (5) are the next-cheapest wins and both have
Rust
implementations already, but `Slot<'a>` implements `Encode` and not
`Decode`,
so taking them needs `build.rs` to emit encode-only packets first. That
is real
work rather than a table row, and it belongs in its own change.

## Gates

Every gate is a flake check or a cargo invocation, run locally, exit
codes read
from files rather than from a pipeline.

| gate | result |
| --- | --- |
| `checks.aarch64-darwin.minecraft-proto-coverage` | rc=0 (new) |
| `checks.aarch64-darwin.minecraft-proto-generated` | rc=0 |
| `checks.aarch64-darwin.minecraft-proto-json` | rc=0 |
| `checks.aarch64-darwin.minecraft-registry-data` | rc=0 |
| `checks.aarch64-darwin.minecraft-tag-data` | rc=0 |
| `checks.aarch64-darwin.minecraft-tags-load` | rc=0 |
| `checks.aarch64-darwin.minecraft-block-states` | rc=0 |
| `checks.aarch64-darwin.minecraft-entity-types` | rc=0 |
| `checks.aarch64-darwin.minecraft-encoder-fixtures` | rc=0 |
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |
| `cargo test --workspace` | rc=0, 117 suites |
| `cargo clippy -p hyperion-minecraft-proto --all-targets` | rc=0 |

Coverage moved from 202 mechanical packets to 205, and `build.rs` from
177
generated packet classes to 180. 65 incomplete entries down to 62.

### Two red gates on main that are not mine

Both reproduce on a pristine `origin/main` checkout in the same
worktree, which
is how I know:

```
$ git checkout --detach origin/main && cargo fmt --all --check
Diff in .../crates/hyperion/src/net/intermediate.rs:185

$ cargo clippy -p hyperion-hot-reload-demo-module
error: this could be a `const fn`
  --> crates/hyperion-hot-reload-demo-module/src/lib.rs:68:1
```

Filed as ENG-10848. Not fixed here: `intermediate.rs` is a sibling
agent's area
this week and the charter keeps me inside
`crates/hyperion-minecraft-proto/` and the generator.

---

AI-authored (Claude) under human direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
## Two implementations of one concept, down to one

`generated::registry::EntityType` arrived in the previous PR as one of 94
registry enums. `entity_type::EntityType` was already here, generated by its
own script into its own shape. Both described `minecraft:entity_type`, both
were generated from the same `protocol.json`, and `tests/entity_type.rs`
existed solely to check they agreed -- a test whose necessity was the defect.

This deletes the older one. What goes with it:

| deleted | lines |
| --- | --- |
| `crates/hyperion-minecraft-proto/src/entity_type.rs` | 544 |
| `nix/generate-entity-types.py` | 171 |
| `crates/hyperion-minecraft-proto/tests/entity_type.rs` | 51 |
| `entityTypeCodegen`, `generatedEntityTypes`, `syncEntityTypesScript`, `entityTypesUpToDate` in `nix/minecraft-data.nix` | 47 |
| `minecraft-entity-types` check, `sync-minecraft-entity-types` app, `minecraft-entity-types-rust` package in `flake.nix` | 3 |

One generator fewer, one nix check fewer, one sync command fewer, and the
staleness of what remains is covered by `minecraft-proto-generated`, which
already diffs the whole `src/generated` tree.

## What a caller writes now

`EntityType::PIG` is `EntityType::Pig`. 121 call sites, all but two of them in
`crates/hyperion/src/simulation/entity_kind.rs`, which is the table pairing a
flecs `EntityKind` tag with the vanilla type it spawns as.

The rename was not done with a SCREAMING-to-Pascal regex. It reads the variant
list out of the generated file and maps each old constant to the variant the
generator actually emitted, so a name whose conversion is not the obvious one
fails loudly rather than silently producing an identifier that happens to
compile as something else. Every one of the 121 mapped; the script reported no
unmapped names.

The rest of the surface:

| was | is |
| --- | --- |
| `entity_type(name)` | `EntityType::from_name(name)` |
| `ENTITY_TYPES` | `EntityType::ALL` |
| `ENTITY_TYPE_COUNT` | `EntityType::COUNT` |
| `hyperion_minecraft_proto::entity_type::EntityType` | `...::generated::registry::EntityType` |

`id()` and `name()` are spelled the same and mean the same, so no call site of
either changed.

## What this costs, in bytes

The retired struct was a `&'static str` and an `i32`, which with padding is 16
bytes, and every copy of an `EntityKind`'s entity type moved a pointer. The
enum is one byte: 158 entries fit in a `u8` discriminant, and that discriminant
is the network id. `Option<EntityType>` -- which is what
`EntityKind::entity_type` returns for the four kinds this version has no type
for -- is also one byte, because the closed enum leaves 98 unused values for
the niche.

`from_name` went from a linear scan over 158 entries to a binary search, though
that is the least interesting part: the only caller is `EntityKind::named`,
which runs when a name arrives from outside.

## The pins the deleted test was carrying

`tests/entity_type.rs` did three things. Two of them -- the table matching the
registry, and every name resolving to its own entry -- are now structurally
impossible to get wrong, because there is one table rather than two, and
`tests/registry_enum.rs` already puts all 6854 names in the crate through
`from_name`.

The third was worth keeping and has moved rather than gone:

```rust
#[test]
fn the_entity_type_ids_are_the_26_2_ones() {
    assert_eq!(registry::EntityType::Pig.id(), RegistryId(100));
    assert_eq!(registry::EntityType::Player.id(), RegistryId(156));
}
```

Every other test in that file would pass against a table that was wrong in the
same way twice. `minecraft:entity_type` was renumbered between 1.20.1 and 26.2,
and the symptom of holding the old numbering is not an error, it is the entity
*next to* the one that was asked for appearing in the world. So is
`a_type_this_version_dropped_does_not_resolve`, which pins that
`minecraft:boat` is gone (split per wood in 1.21.2) and that `pig` without the
namespace is not a name.

## Gates

| gate | result |
| --- | --- |
| `cargo build --workspace` | rc=0 |
| `cargo clippy --workspace --all-targets` | rc=0 |
| `cargo fmt --all --check` | rc=0 |
| `cargo test --workspace` | rc=0 |
| `cargo test -p hyperion-minecraft-proto --test registry_enum` | rc=0, 13 tests |
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |

The flake-gate eval is the one that matters for the deletions: #1007 made CI
enforce the flake checks by name, so removing three attributes from `flake.nix`
would break the gate's own evaluation if any of them were still referenced.
They are not.

## What I did not do

**No literal migration.** `EntityKind::named` still takes a `&str`, and the
callers that hand it one still hand it one. That is stage 3 and the gate that
goes with it.

**`EntityKind` itself is untouched.** It is 125 flecs tags whose relationship
to `minecraft:entity_type` is a 125-arm match, and four of those arms have no
vanilla type at all (`Gui`, and three others named in the last arm). Collapsing
`EntityKind` into `EntityType` would be a real design change to the simulation
layer, not a rename, and it belongs to whoever owns that layer.

---

AI-authored (Claude) under human direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
refactor(proto): one EntityType, and it is the generated enum

## Two implementations of one concept, down to one

`generated::registry::EntityType` arrived in the previous PR as one of
94
registry enums. `entity_type::EntityType` was already here, generated by
its
own script into its own shape. Both described `minecraft:entity_type`,
both
were generated from the same `protocol.json`, and `tests/entity_type.rs`
existed solely to check they agreed -- a test whose necessity was the
defect.

This deletes the older one. What goes with it:

| deleted | lines |
| --- | --- |
| `crates/hyperion-minecraft-proto/src/entity_type.rs` | 544 |
| `nix/generate-entity-types.py` | 171 |
| `crates/hyperion-minecraft-proto/tests/entity_type.rs` | 51 |
| `entityTypeCodegen`, `generatedEntityTypes`, `syncEntityTypesScript`,
`entityTypesUpToDate` in `nix/minecraft-data.nix` | 47 |
| `minecraft-entity-types` check, `sync-minecraft-entity-types` app,
`minecraft-entity-types-rust` package in `flake.nix` | 3 |

One generator fewer, one nix check fewer, one sync command fewer, and
the
staleness of what remains is covered by `minecraft-proto-generated`,
which
already diffs the whole `src/generated` tree.

## What a caller writes now

`EntityType::PIG` is `EntityType::Pig`. 121 call sites, all but two of
them in
`crates/hyperion/src/simulation/entity_kind.rs`, which is the table
pairing a
flecs `EntityKind` tag with the vanilla type it spawns as.

The rename was not done with a SCREAMING-to-Pascal regex. It reads the
variant
list out of the generated file and maps each old constant to the variant
the
generator actually emitted, so a name whose conversion is not the
obvious one
fails loudly rather than silently producing an identifier that happens
to
compile as something else. Every one of the 121 mapped; the script
reported no
unmapped names.

The rest of the surface:

| was | is |
| --- | --- |
| `entity_type(name)` | `EntityType::from_name(name)` |
| `ENTITY_TYPES` | `EntityType::ALL` |
| `ENTITY_TYPE_COUNT` | `EntityType::COUNT` |
| `hyperion_minecraft_proto::entity_type::EntityType` |
`...::generated::registry::EntityType` |

`id()` and `name()` are spelled the same and mean the same, so no call
site of
either changed.

## What this costs, in bytes

The retired struct was a `&'static str` and an `i32`, which with padding
is 16
bytes, and every copy of an `EntityKind`'s entity type moved a pointer.
The
enum is one byte: 158 entries fit in a `u8` discriminant, and that
discriminant
is the network id. `Option<EntityType>` -- which is what
`EntityKind::entity_type` returns for the four kinds this version has no
type
for -- is also one byte, because the closed enum leaves 98 unused values
for
the niche.

`from_name` went from a linear scan over 158 entries to a binary search,
though
that is the least interesting part: the only caller is
`EntityKind::named`,
which runs when a name arrives from outside.

## The pins the deleted test was carrying

`tests/entity_type.rs` did three things. Two of them -- the table
matching the
registry, and every name resolving to its own entry -- are now
structurally
impossible to get wrong, because there is one table rather than two, and
`tests/registry_enum.rs` already puts all 6854 names in the crate
through
`from_name`.

The third was worth keeping and has moved rather than gone:

```rust
#[test]
fn the_entity_type_ids_are_the_26_2_ones() {
    assert_eq!(registry::EntityType::Pig.id(), RegistryId(100));
    assert_eq!(registry::EntityType::Player.id(), RegistryId(156));
}
```

Every other test in that file would pass against a table that was wrong
in the
same way twice. `minecraft:entity_type` was renumbered between 1.20.1
and 26.2,
and the symptom of holding the old numbering is not an error, it is the
entity
*next to* the one that was asked for appearing in the world. So is
`a_type_this_version_dropped_does_not_resolve`, which pins that
`minecraft:boat` is gone (split per wood in 1.21.2) and that `pig`
without the
namespace is not a name.

## Gates

| gate | result |
| --- | --- |
| `cargo build --workspace` | rc=0 |
| `cargo clippy --workspace --all-targets` | rc=0 |
| `cargo fmt --all --check` | rc=0 |
| `cargo test --workspace` | rc=0 |
| `cargo test -p hyperion-minecraft-proto --test registry_enum` | rc=0,
13 tests |
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |

The flake-gate eval is the one that matters for the deletions: #1007
made CI
enforce the flake checks by name, so removing three attributes from
`flake.nix`
would break the gate's own evaluation if any of them were still
referenced.
They are not.

## What I did not do

**No literal migration.** `EntityKind::named` still takes a `&str`, and
the
callers that hand it one still hand it one. That is stage 3 and the gate
that
goes with it.

**`EntityKind` itself is untouched.** It is 125 flecs tags whose
relationship
to `minecraft:entity_type` is a 125-arm match, and four of those arms
have no
vanilla type at all (`Gui`, and three others named in the last arm).
Collapsing
`EntityKind` into `EntityType` would be a real design change to the
simulation
layer, not a rename, and it belongs to whoever owns that layer.

---

AI-authored (Claude) under human direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
## The gate first, the migration second

A `"minecraft:..."` string in Rust is an unproven claim. Nothing checks that
the name exists, that this version still spells it that way, or that the
registry it indexes is the one the caller meant, so a typo or a Mojang rename
becomes a `None` on whichever tick that value was next needed. Every static
registry is a closed enum since #1021, so the name can be a variant and the
compiler answers all three questions at the line that asked.

Fixing occurrences by hand does not stop the class. `nix flake check
.#minecraft-literals` does, and it is the point of this change; the migration
below is what it enabled, not what it is for.

## Counted from the source, not from a regex over lines

The brief warned about this and it was warranted. A regex cannot tell
`//! minecraft:pig` in a doc comment from `"minecraft:pig"` in code, and this
repo's doc comments name registry entries constantly -- `entity_kind.rs` alone
has dozens. `nix/check-minecraft-literals.py` walks each file the way Rust
lexes it: line comments, nested block comments, char literals and lifetimes are
stepped over, and raw strings (`r#"..."#`) keep their contents.

What that finds across every file in `git ls-files '*.rs'`:

| | files | literals |
| --- | --- | --- |
| generated, recognised by the `@generated` header | 102 | 10009 |
| hand-written | 56 | 765 |

765, against the 659 an earlier pass measured. I did not reconstruct how that
number was reached, so I cannot say which 106 it missed, only that a Rust-aware
scan finds more.

Generated files are exempted by their own header rather than by a path list, so
a new generated file is covered the day it appears and a file that stops being
generated stops being exempt.

## Two kinds of exemption, and they are different on purpose

`ALLOWED` is a path with a reason: a file where these literals are correct and
are expected to stay. Two entries, 430 names between them.

- `crates/hyperion/src/net/protocol/registries.rs` -- the element names of the
  29 **dynamic** registries. `protocol.json` does not carry those, so they have
  no enum. The file is a name table of the same kind as generated output and
  its own header already says it should be deleted once the proto crate
  generates dynamic registry contents.
- `crates/hyperion-minecraft-proto/tests/registry_enum.rs` -- the test proving
  the enums cover the registries has to name entries as strings to check that
  the string form resolves, and names deliberate non-entries to check that they
  do not.

The baseline is a path with a *list*: names that have not been migrated yet. It
is debt with a name on it, it ratchets in both directions, and the intended end
state is that it is empty and the baseline file is deleted. 386 at the start of
this change, 323 after the migration below, and 327 after rebasing onto a main
that had grown four more while this branch was open.

I chose a ratchet over a hard failure for the remainder because most of them are
in `events/smash/**` and `crates/hyperion/src/simulation/**`, where three other
agents have open branches tonight. Migrating those files now would conflict
with work in flight for no gain the gate does not already give: the gate refuses
the *next* one either way.

## What was migrated, and why this one

`minecraft:command_argument_type`, 63 names across two files, chosen because it
was the largest single cluster and because it fixes something the brief names
directly: **encoding could fail**.

```rust
-fn argument_type_id(name: &str) -> Result<i32> {
-    let id = registry::COMMAND_ARGUMENT_TYPE
-        .id_of(name)
-        .ok_or_else(|| Error::InvalidIdentifier(name.to_owned()))?;
-    i32::try_from(id).map_err(|_| Error::InvalidIdentifier(name.to_owned()))
-}
```

That was a linear scan over 57 entries, returning a `Result`, run for every
argument of every command in the tree, so that this server could discover at
run time whether `brigadier:double` still existed. `ArgumentType::to_id` is now
`const fn` and infallible, and `ArgumentType::Empty` and `ArgumentType::Registry`
hold a `CommandArgumentType` rather than a bare `RegistryId` -- so the id cannot
be one the registry does not have, rather than being checked against it.

`decode` changed in the same direction and is the interesting half: it used to
resolve the incoming id to a *name* and then match on string literals, which is
57 string comparisons in the worst case, and silently fell through to
`Self::Empty` for a name it did not recognise. It now resolves through
`CommandArgumentType::from_id` once -- the single decode boundary -- and matches
on variants.

`crates/hyperion/src/simulation/command.rs` lost its own duplicate
`argument_type_id`, and `empty_argument` went from `-> anyhow::Result<...>` to a
`const fn`, which deleted a `?` from 43 call sites.

Runtime validation sites on non-decode paths, by the command in the previous
PR's body: 26 before, 22 after. The four removed are the two `argument_type_id`
helpers and the two `id_of` calls inside them. The remaining 22 are enumerated
in the baseline and in that list; several are legitimate -- the anvil world
loader really is resolving a name it read out of a file -- and the worst is
`events/smash/src/adapter.rs:800`, `ItemKind::from_str(bare).unwrap_or(ItemKind::Stick)`,
which hands back a stick for a name nobody typed correctly. That file belongs
to another agent tonight.

## The gate, broken in every direction and watched

**A new raw name appears.** Added a second biome lookup to `translate.rs`:

```
387 raw registry names in Rust string literals, 386 allowed by the baseline
NEW     crates/hyperion/src/simulation/blocks/translate.rs  minecraft:sunflower_plains

1 raw registry name(s) that were not there before. Every static registry is a closed enum in
hyperion_minecraft_proto::generated::registry, so name the variant instead: a typo or a
Mojang rename is then a compile error here rather than a None at run time. If the name
belongs to a dynamic registry and genuinely has no enum, add the file to ALLOWED in
nix/check-minecraft-literals.py with the reason.
```

**Names disappear and the baseline stops bounding anything.** This is not a
hypothetical; it is what the command-argument migration produced before the
baseline was tightened:

```
323 raw registry names in Rust string literals, 386 allowed by the baseline
GONE    crates/hyperion-minecraft-proto/src/packets/play/player.rs  brigadier:double
GONE    crates/hyperion-minecraft-proto/src/packets/play/player.rs  brigadier:float
... 85 lines ...
63 raw registry name(s) are gone, so the baseline is looser than the code and has
stopped bounding anything. Tighten it with: nix run .#sync-minecraft-literals
```

**The claim about comments, tested rather than asserted.** Added a doc comment
naming `minecraft:block_pos` and `"minecraft:vec3"`, and a block comment
containing `"minecraft:item_stack"`, to `command.rs`. The count stayed at 323
and no `NEW` line appeared, which is the whole reason this is a lexer and not a
`rg` invocation. A line-based check would have reported three regressions there.

## The gate caught something before it even merged

While this branch was open, #1023 landed on `main`. Rebasing onto it, with no
prompting and no edit to the checker:

```
327 raw registry names in Rust string literals, 323 allowed by the baseline
NEW     events/smash/src/module/kits/blaze.rs  minecraft:entity.player.hurt_on_fire
NEW     events/smash/src/module/kits/spider.rs  minecraft:entity.player.hurt_sweet_berry_bush
NEW     events/smash/tests/abilities.rs         minecraft:entity.arrow.shoot
NEW     events/smash/tests/contract.rs          minecraft:entity.player.hurt_on_fire
```

Four sound names written as strings in a PR authored after the enums existed,
which is the class this is meant to stop and a fair demonstration that it does
not stop itself. They are in the baseline rather than migrated: those files
have work in flight against them tonight and the gate refuses the *next* one
either way. Had this check been on `main` an hour earlier, #1023 would have
been asked to write `SoundEvent::EntityPlayerHurtOnFire` and the four would not
exist.

## Gates

| gate | result |
| --- | --- |
| `nix build .#checks.aarch64-darwin.minecraft-literals` | rc=0, 327/327 |
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |
| `cargo clippy --workspace --all-targets` | rc=0 |
| `cargo fmt --all --check` | rc=0 |
| `cargo test --workspace` | rc=0 |
| `flake8 nix/check-minecraft-literals.py` | rc=0 |

Because #1007 made CI enforce the flake checks by name, `minecraft-literals`
runs in CI the day this merges with no edit to `nix/ci/flake-gate.nix`.

## What I did not do

**327 names are still raw.** Named above, bounded by the baseline, and left
deliberately rather than overlooked.

**The dynamic registries still have no enum.** 430 of the 765 hand-written
literals are the one file that lists them, and generating them properly means
teaching `nix/generate-registry-data.py` to emit enums the way
`generate-rust.py` now does. That would delete the largest `ALLOWED` entry and
is the obvious next step; it is not this PR. Filed as ENG-10887.

**The worst remaining fallback is filed rather than fixed.** ENG-10884 covers
`ItemKind::from_str(bare).unwrap_or(ItemKind::Stick)`, which is in another
agent's file this week.

**`nix flake check` was not run whole.** The three checks this change touches
were built individually and `flake-gate` was evaluated, but the full check set
compiles a server and I did not wait on it.

## One red gate here is not mine

`cargo clippy --all-targets --all-features -- -D warnings` fails on
`events/smash/tests/abilities.rs:595`, an unused `use flecs_ecs::prelude::*;`.
It arrived with #1023, whose own Clippy check failed and which was merged
anyway, so it is red on `main` independently of this branch. Removing that one
line locally makes the whole workspace pass, which is how I know nothing here
contributes to it. Left alone: that file has another agent's work in flight
against it tonight.

Worth recording how I nearly missed it. `cargo clippy --workspace --all-targets`
reported clean three times on this tree, because the test target was cached and
because it does not pass `-D warnings`. CI's invocation is
`cargo clippy --all-targets --all-features -- -D warnings` and the difference
turns a warning into a failure. Running anything less than CI's exact command is
not a check.

---

AI-authored (Claude) under human direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
## The gate first, the migration second

A `"minecraft:..."` string in Rust is an unproven claim. Nothing checks that
the name exists, that this version still spells it that way, or that the
registry it indexes is the one the caller meant, so a typo or a Mojang rename
becomes a `None` on whichever tick that value was next needed. Every static
registry is a closed enum since #1021, so the name can be a variant and the
compiler answers all three questions at the line that asked.

Fixing occurrences by hand does not stop the class. `nix flake check
.#minecraft-literals` does, and it is the point of this change; the migration
below is what it enabled, not what it is for.

## Counted from the source, not from a regex over lines

The brief warned about this and it was warranted. A regex cannot tell
`//! minecraft:pig` in a doc comment from `"minecraft:pig"` in code, and this
repo's doc comments name registry entries constantly -- `entity_kind.rs` alone
has dozens. `nix/check-minecraft-literals.py` walks each file the way Rust
lexes it: line comments, nested block comments, char literals and lifetimes are
stepped over, and raw strings (`r#"..."#`) keep their contents.

What that finds across every file in `git ls-files '*.rs'`:

| | files | literals |
| --- | --- | --- |
| generated, recognised by the `@generated` header | 102 | 10009 |
| hand-written | 56 | 765 |

765, against the 659 an earlier pass measured. I did not reconstruct how that
number was reached, so I cannot say which 106 it missed, only that a Rust-aware
scan finds more.

Generated files are exempted by their own header rather than by a path list, so
a new generated file is covered the day it appears and a file that stops being
generated stops being exempt.

## Two kinds of exemption, and they are different on purpose

`ALLOWED` is a path with a reason: a file where these literals are correct and
are expected to stay. Two entries, 430 names between them.

- `crates/hyperion/src/net/protocol/registries.rs` -- the element names of the
  29 **dynamic** registries. `protocol.json` does not carry those, so they have
  no enum. The file is a name table of the same kind as generated output and
  its own header already says it should be deleted once the proto crate
  generates dynamic registry contents.
- `crates/hyperion-minecraft-proto/tests/registry_enum.rs` -- the test proving
  the enums cover the registries has to name entries as strings to check that
  the string form resolves, and names deliberate non-entries to check that they
  do not.

The baseline is a path with a *list*: names that have not been migrated yet. It
is debt with a name on it, it ratchets in both directions, and the intended end
state is that it is empty and the baseline file is deleted. 386 at the start of
this change, 323 after the migration below, and 327 after rebasing onto a main
that had grown four more while this branch was open.

I chose a ratchet over a hard failure for the remainder because most of them are
in `events/smash/**` and `crates/hyperion/src/simulation/**`, where three other
agents have open branches tonight. Migrating those files now would conflict
with work in flight for no gain the gate does not already give: the gate refuses
the *next* one either way.

## What was migrated, and why this one

`minecraft:command_argument_type`, 63 names across two files, chosen because it
was the largest single cluster and because it fixes something the brief names
directly: **encoding could fail**.

```rust
-fn argument_type_id(name: &str) -> Result<i32> {
-    let id = registry::COMMAND_ARGUMENT_TYPE
-        .id_of(name)
-        .ok_or_else(|| Error::InvalidIdentifier(name.to_owned()))?;
-    i32::try_from(id).map_err(|_| Error::InvalidIdentifier(name.to_owned()))
-}
```

That was a linear scan over 57 entries, returning a `Result`, run for every
argument of every command in the tree, so that this server could discover at
run time whether `brigadier:double` still existed. `ArgumentType::to_id` is now
`const fn` and infallible, and `ArgumentType::Empty` and `ArgumentType::Registry`
hold a `CommandArgumentType` rather than a bare `RegistryId` -- so the id cannot
be one the registry does not have, rather than being checked against it.

`decode` changed in the same direction and is the interesting half: it used to
resolve the incoming id to a *name* and then match on string literals, which is
57 string comparisons in the worst case, and silently fell through to
`Self::Empty` for a name it did not recognise. It now resolves through
`CommandArgumentType::from_id` once -- the single decode boundary -- and matches
on variants.

`crates/hyperion/src/simulation/command.rs` lost its own duplicate
`argument_type_id`, and `empty_argument` went from `-> anyhow::Result<...>` to a
`const fn`, which deleted a `?` from 43 call sites.

Runtime validation sites on non-decode paths, by the command in the previous
PR's body: 26 before, 22 after. The four removed are the two `argument_type_id`
helpers and the two `id_of` calls inside them. The remaining 22 are enumerated
in the baseline and in that list; several are legitimate -- the anvil world
loader really is resolving a name it read out of a file -- and the worst is
`events/smash/src/adapter.rs:800`, `ItemKind::from_str(bare).unwrap_or(ItemKind::Stick)`,
which hands back a stick for a name nobody typed correctly. That file belongs
to another agent tonight.

## The gate, broken in every direction and watched

**A new raw name appears.** Added a second biome lookup to `translate.rs`:

```
387 raw registry names in Rust string literals, 386 allowed by the baseline
NEW     crates/hyperion/src/simulation/blocks/translate.rs  minecraft:sunflower_plains

1 raw registry name(s) that were not there before. Every static registry is a closed enum in
hyperion_minecraft_proto::generated::registry, so name the variant instead: a typo or a
Mojang rename is then a compile error here rather than a None at run time. If the name
belongs to a dynamic registry and genuinely has no enum, add the file to ALLOWED in
nix/check-minecraft-literals.py with the reason.
```

**Names disappear and the baseline stops bounding anything.** This is not a
hypothetical; it is what the command-argument migration produced before the
baseline was tightened:

```
323 raw registry names in Rust string literals, 386 allowed by the baseline
GONE    crates/hyperion-minecraft-proto/src/packets/play/player.rs  brigadier:double
GONE    crates/hyperion-minecraft-proto/src/packets/play/player.rs  brigadier:float
... 85 lines ...
63 raw registry name(s) are gone, so the baseline is looser than the code and has
stopped bounding anything. Tighten it with: nix run .#sync-minecraft-literals
```

**The claim about comments, tested rather than asserted.** Added a doc comment
naming `minecraft:block_pos` and `"minecraft:vec3"`, and a block comment
containing `"minecraft:item_stack"`, to `command.rs`. The count stayed at 323
and no `NEW` line appeared, which is the whole reason this is a lexer and not a
`rg` invocation. A line-based check would have reported three regressions there.

## The gate caught something before it even merged

While this branch was open, #1023 landed on `main`. Rebasing onto it, with no
prompting and no edit to the checker:

```
327 raw registry names in Rust string literals, 323 allowed by the baseline
NEW     events/smash/src/module/kits/blaze.rs  minecraft:entity.player.hurt_on_fire
NEW     events/smash/src/module/kits/spider.rs  minecraft:entity.player.hurt_sweet_berry_bush
NEW     events/smash/tests/abilities.rs         minecraft:entity.arrow.shoot
NEW     events/smash/tests/contract.rs          minecraft:entity.player.hurt_on_fire
```

Four sound names written as strings in a PR authored after the enums existed,
which is the class this is meant to stop and a fair demonstration that it does
not stop itself. They are in the baseline rather than migrated: those files
have work in flight against them tonight and the gate refuses the *next* one
either way. Had this check been on `main` an hour earlier, #1023 would have
been asked to write `SoundEvent::EntityPlayerHurtOnFire` and the four would not
exist.

## Gates

| gate | result |
| --- | --- |
| `nix build .#checks.aarch64-darwin.minecraft-literals` | rc=0, 327/327 |
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |
| `cargo clippy --workspace --all-targets` | rc=0 |
| `cargo fmt --all --check` | rc=0 |
| `cargo test --workspace` | rc=0 |
| `flake8 nix/check-minecraft-literals.py` | rc=0 |

Because #1007 made CI enforce the flake checks by name, `minecraft-literals`
runs in CI the day this merges with no edit to `nix/ci/flake-gate.nix`.

## What I did not do

**327 names are still raw.** Named above, bounded by the baseline, and left
deliberately rather than overlooked.

**The dynamic registries still have no enum.** 430 of the 765 hand-written
literals are the one file that lists them, and generating them properly means
teaching `nix/generate-registry-data.py` to emit enums the way
`generate-rust.py` now does. That would delete the largest `ALLOWED` entry and
is the obvious next step; it is not this PR. Filed as ENG-10887.

**The worst remaining fallback is filed rather than fixed.** ENG-10884 covers
`ItemKind::from_str(bare).unwrap_or(ItemKind::Stick)`, which is in another
agent's file this week.

**`nix flake check` was not run whole.** The three checks this change touches
were built individually and `flake-gate` was evaluated, but the full check set
compiles a server and I did not wait on it.

## Also in here: a stale reference in my own generator

`nix/generate-rust.py` explained the `particle_type` carve-out by saying the
particle enum is owned separately "the same way `nix/generate-entity-types.py`
owns entity types". #1024 -- mine, two commits earlier -- deleted that script.
So the comment justified a real decision by appealing to a precedent that no
longer existed, which leaves the next reader either hunting a missing file or
concluding the carve-out was arbitrary.

It also named `nix/generate-particles.py` and `src/particle.rs` as though they
were present. They are not on `main` yet. The reason for the carve-out is a
fact about the data and is true today; the ownership is an arrangement that
lands separately. The comment now says the first without asserting the second,
and the reciprocal note in the generated `registry/particle_type.rs` changed
with it, so the two cannot drift.

Worth naming the general shape, because a gate in this very PR nearly caught it
and did not. `tool-paths` only matches paths at the start of a string literal;
this one was inside backticks in the middle of a sentence. A comment can name a
file that does not exist and nothing anywhere objects. I have not widened the
pattern -- matching every path-shaped substring in every comment would flag
prose constantly -- so this stays a thing a reader catches, and one did.

## A note on what a green local clippy is worth

`cargo clippy --workspace --all-targets` reported this tree clean three times
while CI was failing, for two reasons that will recur. It does not pass
`-D warnings`, so an unused import is a warning locally and an error in CI. And
cargo caches per target, so a test target nobody touched is not re-checked and
a warning that arrived with somebody else's merge stays invisible. `touch`ing
the file made it appear at once.

CI's invocation is `cargo clippy --all-targets --all-features -- -D warnings`
(flake.nix:185). Anything less is not the same check, and this PR is verified
with that one. Filed as ENG-10894 along with the red it exposed, which #1029
has since fixed.

---

AI-authored (Claude) under human direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…1025)

feat(proto): a raw `minecraft:` name in Rust is now a build failure

## The gate first, the migration second

A `"minecraft:..."` string in Rust is an unproven claim. Nothing checks
that
the name exists, that this version still spells it that way, or that the
registry it indexes is the one the caller meant, so a typo or a Mojang
rename
becomes a `None` on whichever tick that value was next needed. Every
static
registry is a closed enum since #1021, so the name can be a variant and
the
compiler answers all three questions at the line that asked.

Fixing occurrences by hand does not stop the class. `nix flake check
.#minecraft-literals` does, and it is the point of this change; the
migration
below is what it enabled, not what it is for.

## Counted from the source, not from a regex over lines

The brief warned about this and it was warranted. A regex cannot tell
`//! minecraft:pig` in a doc comment from `"minecraft:pig"` in code, and
this
repo's doc comments name registry entries constantly -- `entity_kind.rs`
alone
has dozens. `nix/check-minecraft-literals.py` walks each file the way
Rust
lexes it: line comments, nested block comments, char literals and
lifetimes are
stepped over, and raw strings (`r#"..."#`) keep their contents.

What that finds across every file in `git ls-files '*.rs'`:

| | files | literals |
| --- | --- | --- |
| generated, recognised by the `@generated` header | 102 | 10009 |
| hand-written | 56 | 765 |

765, against the 659 an earlier pass measured. I did not reconstruct how
that
number was reached, so I cannot say which 106 it missed, only that a
Rust-aware
scan finds more.

Generated files are exempted by their own header rather than by a path
list, so
a new generated file is covered the day it appears and a file that stops
being
generated stops being exempt.

## Two kinds of exemption, and they are different on purpose

`ALLOWED` is a path with a reason: a file where these literals are
correct and
are expected to stay. Two entries, 430 names between them.

- `crates/hyperion/src/net/protocol/registries.rs` -- the element names
of the
29 **dynamic** registries. `protocol.json` does not carry those, so they
have
no enum. The file is a name table of the same kind as generated output
and
  its own header already says it should be deleted once the proto crate
  generates dynamic registry contents.
- `crates/hyperion-minecraft-proto/tests/registry_enum.rs` -- the test
proving
the enums cover the registries has to name entries as strings to check
that
the string form resolves, and names deliberate non-entries to check that
they
  do not.

The baseline is a path with a *list*: names that have not been migrated
yet. It
is debt with a name on it, it ratchets in both directions, and the
intended end
state is that it is empty and the baseline file is deleted. 386 at the
start of
this change, 323 after the migration below, and 327 after rebasing onto
a main
that had grown four more while this branch was open.

I chose a ratchet over a hard failure for the remainder because most of
them are
in `events/smash/**` and `crates/hyperion/src/simulation/**`, where
three other
agents have open branches tonight. Migrating those files now would
conflict
with work in flight for no gain the gate does not already give: the gate
refuses
the *next* one either way.

## What was migrated, and why this one

`minecraft:command_argument_type`, 63 names across two files, chosen
because it
was the largest single cluster and because it fixes something the brief
names
directly: **encoding could fail**.

```rust
-fn argument_type_id(name: &str) -> Result<i32> {
-    let id = registry::COMMAND_ARGUMENT_TYPE
-        .id_of(name)
-        .ok_or_else(|| Error::InvalidIdentifier(name.to_owned()))?;
-    i32::try_from(id).map_err(|_| Error::InvalidIdentifier(name.to_owned()))
-}
```

That was a linear scan over 57 entries, returning a `Result`, run for
every
argument of every command in the tree, so that this server could
discover at
run time whether `brigadier:double` still existed. `ArgumentType::to_id`
is now
`const fn` and infallible, and `ArgumentType::Empty` and
`ArgumentType::Registry`
hold a `CommandArgumentType` rather than a bare `RegistryId` -- so the
id cannot
be one the registry does not have, rather than being checked against it.

`decode` changed in the same direction and is the interesting half: it
used to
resolve the incoming id to a *name* and then match on string literals,
which is
57 string comparisons in the worst case, and silently fell through to
`Self::Empty` for a name it did not recognise. It now resolves through
`CommandArgumentType::from_id` once -- the single decode boundary -- and
matches
on variants.

`crates/hyperion/src/simulation/command.rs` lost its own duplicate
`argument_type_id`, and `empty_argument` went from `->
anyhow::Result<...>` to a
`const fn`, which deleted a `?` from 43 call sites.

Runtime validation sites on non-decode paths, by the command in the
previous
PR's body: 26 before, 22 after. The four removed are the two
`argument_type_id`
helpers and the two `id_of` calls inside them. The remaining 22 are
enumerated
in the baseline and in that list; several are legitimate -- the anvil
world
loader really is resolving a name it read out of a file -- and the worst
is
`events/smash/src/adapter.rs:800`,
`ItemKind::from_str(bare).unwrap_or(ItemKind::Stick)`,
which hands back a stick for a name nobody typed correctly. That file
belongs
to another agent tonight.

## The gate, broken in every direction and watched

**A new raw name appears.** Added a second biome lookup to
`translate.rs`:

```
387 raw registry names in Rust string literals, 386 allowed by the baseline
NEW     crates/hyperion/src/simulation/blocks/translate.rs  minecraft:sunflower_plains

1 raw registry name(s) that were not there before. Every static registry is a closed enum in
hyperion_minecraft_proto::generated::registry, so name the variant instead: a typo or a
Mojang rename is then a compile error here rather than a None at run time. If the name
belongs to a dynamic registry and genuinely has no enum, add the file to ALLOWED in
nix/check-minecraft-literals.py with the reason.
```

**Names disappear and the baseline stops bounding anything.** This is
not a
hypothetical; it is what the command-argument migration produced before
the
baseline was tightened:

```
323 raw registry names in Rust string literals, 386 allowed by the baseline
GONE    crates/hyperion-minecraft-proto/src/packets/play/player.rs  brigadier:double
GONE    crates/hyperion-minecraft-proto/src/packets/play/player.rs  brigadier:float
... 85 lines ...
63 raw registry name(s) are gone, so the baseline is looser than the code and has
stopped bounding anything. Tighten it with: nix run .#sync-minecraft-literals
```

**The claim about comments, tested rather than asserted.** Added a doc
comment
naming `minecraft:block_pos` and `"minecraft:vec3"`, and a block comment
containing `"minecraft:item_stack"`, to `command.rs`. The count stayed
at 323
and no `NEW` line appeared, which is the whole reason this is a lexer
and not a
`rg` invocation. A line-based check would have reported three
regressions there.

## The gate caught something before it even merged

While this branch was open, #1023 landed on `main`. Rebasing onto it,
with no
prompting and no edit to the checker:

```
327 raw registry names in Rust string literals, 323 allowed by the baseline
NEW     events/smash/src/module/kits/blaze.rs  minecraft:entity.player.hurt_on_fire
NEW     events/smash/src/module/kits/spider.rs  minecraft:entity.player.hurt_sweet_berry_bush
NEW     events/smash/tests/abilities.rs         minecraft:entity.arrow.shoot
NEW     events/smash/tests/contract.rs          minecraft:entity.player.hurt_on_fire
```

Four sound names written as strings in a PR authored after the enums
existed,
which is the class this is meant to stop and a fair demonstration that
it does
not stop itself. They are in the baseline rather than migrated: those
files
have work in flight against them tonight and the gate refuses the *next*
one
either way. Had this check been on `main` an hour earlier, #1023 would
have
been asked to write `SoundEvent::EntityPlayerHurtOnFire` and the four
would not
exist.

## Gates

| gate | result |
| --- | --- |
| `nix build .#checks.aarch64-darwin.minecraft-literals` | rc=0, 327/327
|
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |
| `cargo clippy --workspace --all-targets` | rc=0 |
| `cargo fmt --all --check` | rc=0 |
| `cargo test --workspace` | rc=0 |
| `flake8 nix/check-minecraft-literals.py` | rc=0 |

Because #1007 made CI enforce the flake checks by name,
`minecraft-literals`
runs in CI the day this merges with no edit to `nix/ci/flake-gate.nix`.

## What I did not do

**327 names are still raw.** Named above, bounded by the baseline, and
left
deliberately rather than overlooked.

**The dynamic registries still have no enum.** 430 of the 765
hand-written
literals are the one file that lists them, and generating them properly
means
teaching `nix/generate-registry-data.py` to emit enums the way
`generate-rust.py` now does. That would delete the largest `ALLOWED`
entry and
is the obvious next step; it is not this PR. Filed as ENG-10887.

**The worst remaining fallback is filed rather than fixed.** ENG-10884
covers
`ItemKind::from_str(bare).unwrap_or(ItemKind::Stick)`, which is in
another
agent's file this week.

**`nix flake check` was not run whole.** The three checks this change
touches
were built individually and `flake-gate` was evaluated, but the full
check set
compiles a server and I did not wait on it.

## Also in here: a stale reference in my own generator

`nix/generate-rust.py` explained the `particle_type` carve-out by saying
the
particle enum is owned separately "the same way
`nix/generate-entity-types.py`
owns entity types". #1024 -- mine, two commits earlier -- deleted that
script.
So the comment justified a real decision by appealing to a precedent
that no
longer existed, which leaves the next reader either hunting a missing
file or
concluding the carve-out was arbitrary.

It also named `nix/generate-particles.py` and `src/particle.rs` as
though they
were present. They are not on `main` yet. The reason for the carve-out
is a
fact about the data and is true today; the ownership is an arrangement
that
lands separately. The comment now says the first without asserting the
second,
and the reciprocal note in the generated `registry/particle_type.rs`
changed
with it, so the two cannot drift.

Worth naming the general shape, because a gate in this very PR nearly
caught it
and did not. `tool-paths` only matches paths at the start of a string
literal;
this one was inside backticks in the middle of a sentence. A comment can
name a
file that does not exist and nothing anywhere objects. I have not
widened the
pattern -- matching every path-shaped substring in every comment would
flag
prose constantly -- so this stays a thing a reader catches, and one did.

## A note on what a green local clippy is worth

`cargo clippy --workspace --all-targets` reported this tree clean three
times
while CI was failing, for two reasons that will recur. It does not pass
`-D warnings`, so an unused import is a warning locally and an error in
CI. And
cargo caches per target, so a test target nobody touched is not
re-checked and
a warning that arrived with somebody else's merge stays invisible.
`touch`ing
the file made it appear at once.

CI's invocation is `cargo clippy --all-targets --all-features -- -D
warnings`
(flake.nix:185). Anything less is not the same check, and this PR is
verified
with that one. Filed as ENG-10894 along with the red it exposed, which
#1029
has since fixed.

---

AI-authored (Claude) under human direction.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant