Skip to content

refactor(proto): one EntityType, and it is the generated enum - #1024

Merged
andrewgazelka merged 1 commit into
mainfrom
feat/entity-type-enum
Jul 28, 2026
Merged

refactor(proto): one EntityType, and it is the generated enum#1024
andrewgazelka merged 1 commit into
mainfrom
feat/entity-type-enum

Conversation

@andrewgazelka

Copy link
Copy Markdown
Member

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:

#[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.

Base automatically changed from feat/registry-enums to main July 28, 2026 10:56
## 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
andrewgazelka force-pushed the feat/entity-type-enum branch from 35afcc9 to 3ba49f4 Compare July 28, 2026 10:57
@andrewgazelka
andrewgazelka merged commit a25c3a9 into main Jul 28, 2026
9 of 11 checks passed
@andrewgazelka
andrewgazelka deleted the feat/entity-type-enum branch July 28, 2026 10:57
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.90164% with 5 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@a5db463). Learn more about missing BASE report.
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/hyperion/src/simulation/entity_kind.rs 95.90% 5 Missing ⚠️
@@           Coverage Diff           @@
##             main    #1024   +/-   ##
=======================================
  Coverage        ?   51.13%           
=======================================
  Files           ?      335           
  Lines           ?    30052           
  Branches        ?     1137           
=======================================
  Hits            ?    15368           
  Misses          ?    14438           
  Partials        ?      246           
Files with missing lines Coverage Δ
crates/hyperion/src/simulation/entity_kind.rs 93.43% <95.90%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.7 ns ]      +0.10%
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.08%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.06%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.29%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      +0.15%
overlap/no_overlap                                 [  15.6 ns ...  15.6 ns ]      -0.04%
overlap/partial_overlap                            [  15.6 ns ...  15.6 ns ]      +0.22%
overlap/full_containment                           [  14.7 ns ...  14.7 ns ]      +0.08%
point_containment/inside                           [   5.3 ns ...   5.4 ns ]      +0.58%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.04%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      +0.11%

Comparing to a5db463

andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…rce (#1028)

fix(tools): scripted clients read protocol.json, not each other's source

## What broke, and how it got through

#1021 turned `crates/hyperion-minecraft-proto/src/generated/registry.rs`
into a
directory. Three Python clients under `tools/` were reading that file as
text
and pulling registry names out of it with a regex. They kept "building",
passed
every Rust gate, and failed twenty minutes into CI inside four
end-to-end
checks:

```
FileNotFoundError: [Errno 2] No such file or directory:
'/nix/store/bavm52as33izbs3kl5zihvszxgskz7lw-source/crates/hyperion-minecraft-proto/src/generated/registry.rs'

enforced checks that failed: completions-e2e smash-e2e smash-hotbar-e2e smash-hud-e2e
```

A path in a Python string is invisible to cargo and to every Rust grep.
I
searched for `generated::registry` before landing #1021 and found the
Rust
callers; I did not search for `generated/registry.rs`, and nothing made
me.

`smash-selector-e2e` is the same bug one merge later:
`tools/smash-selector.py`
read `src/entity_type.rs`, which #1024 deleted, with a regex matching
`Self::new("...", n)` — a spelling that stopped existing when entity
types
became a plain enum. CI had not reached it yet. Running the whole gate
locally
found it:

```
enforced checks that failed: completions-e2e smash-e2e smash-hotbar-e2e smash-hud-e2e smash-selector-e2e
```

## The fix is that they stop reading Rust

Pointing three regexes at three new paths would have restored green and
left
the same trap armed. All three now call one reader:

```python
def registry_entries(registry: str) -> list[str]:
    """A vanilla registry's entry names, in network-id order."""
```

in `tools/client-26.2.py`, reading
`crates/hyperion-minecraft-proto/protocol.json`
— the same file `build.rs` reads and the same file every generated table
comes
out of. `protocol.json` joins the e2e client fileset in `nix/e2e.nix`.

Two bugs went with the scrapers. `completions-check.py` carried a
comment
explaining that it had to index from `entries: &[` rather than from the
declaration, because the registry's own name sits in the same table and
looks
exactly like an entry — getting it wrong shifted every id by one and
turned
`brigadier:string` into `brigadier:long`. `smash-match.py` compensated
for the
same thing with a bare `[1:]`. Neither is needed: `entries` in the JSON
is the
entries, and the index is the id.

## The gate, because the class outlives the instance

`nix flake check .#tool-paths` fails when any tracked `.py`, `.sh` or
`.nix`
file names a repository path that does not exist. Only strings anchored
at a
real top-level directory count, and anything containing a format
placeholder or
a glob is skipped, so prose is not mistaken for a claim.

It earned itself immediately. Its first run, before I had looked at
`smash-selector.py`, found the bug #1024 had just introduced:

```
4 repository paths named in scripts, 1 of them missing
MISSING tools/smash-selector.py:89  crates/hyperion-minecraft-proto/src/entity_type.rs
```

Broken deliberately as well, by putting the dead path back into
`completions-check.py`:

```
4 repository paths named in scripts, 1 of them missing
MISSING tools/completions-check.py:44  crates/hyperion-minecraft-proto/src/generated/registry.rs

A script names a path this tree does not have. Nothing else catches this: a path in a
string is invisible to cargo and to every Rust grep, so it fails inside whichever gate runs
that script. Either fix the path, or -- better -- stop reaching into another component's
source and read its committed data instead.
```

Restored, and it reports `3 repository paths named in scripts, 0 of them
missing`.

This is the cheap half of the fix and it is worth saying which half. It
cannot
tell that a regex has stopped matching a file that still exists — only
that the
file is gone. What stops the other half is the three clients no longer
parsing
Rust at all.

## Gates

| gate | result |
| --- | --- |
| `nix build .#checks.aarch64-darwin.completions-e2e` | rc=0, `RESULT:
every completion claim held` |
| `nix build .#checks.aarch64-darwin.tool-paths` | rc=0, 3/3 |
| `nix eval .#checks.aarch64-darwin.flake-gate.drvPath` | rc=0 |
| `cargo fmt --all --check` | rc=0 |
| `flake8` on all five changed scripts | rc=0 for everything this change
touched |

`completions-e2e` is the one that was failing and it is the one I ran to
completion. The other four e2e checks share the same reader through
`smash-match.py`'s `load_item_names` and `smash-selector.py`'s
`entity_names`,
both of which are exercised by the unit-level check above, but I did not
wait
on all five locally -- each compiles the whole server. CI runs them.

Two lint findings in `tools/smash-selector.py` (E301, E302) are
pre-existing;
I checked by stashing and re-running. Not fixed here.

---

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.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…place them (#1030)

Effects primitives for Super Smash Mobs: the engine layer an ability
composes out of. No `events/smash/` changes — the seam
(`Server::particles`, deleting `Cue`) follows as a second, small PR so
the abilities agent rebases once and deliberately.

## The particle table is generated, and checked against Mojang's encoder

`minecraft:particle_type` is the one registry the general registry
codegen (#1021) deliberately skips, and the exclusion note there names
this script. It cannot emit this one: `ParticleTypes.STREAM_CODEC` is a
dispatch, so `nix/extract-protocol.py` marks it `unresolved` and
`protocol.json` carries only the 125 names — not which entries write a
body, nor what shape.

`nix/generate-particles.py` reads the dispatch table out of the
decompiled Java, where every `register` call names its option class
beside the particle, and reads each option class's own `StreamCodec` to
learn the body. It emits two enums following #1021's conventions exactly
— `#[repr(u16)]`, discriminant is the network id, `const fn id`,
`name()` off a static table, `from_id`/`from_name` at the decode
boundary only, closed, no `Unknown`:

- `ParticleKind` — the id on its own, 125 unit variants.
- `Particle<'a>` — the type together with its options. Thirteen payload
shapes.

There is no `Particle::from_id`, and the module says why: a type id
alone does not say how many bytes follow, so turning one back into a
particle means reading the body.

**The reading of the Java is only as good as the parse, so it is checked
against the encoder it was parsed from.** `nix/java/VanillaEncoder.java`
now drives Mojang's own `ParticleTypes.STREAM_CODEC` over all thirteen
shapes, and `tests/play_particles.rs` compares byte for byte, both bare
and inside `level_particles`:

```
particle.block                 -> 0101
particle.item                  -> 36c407030000
particle.dust                  -> 15ffff000040000000
particle.dust_color_transition -> 16ffff0000ff0000ff3fc00000
particle.entity_effect         -> 1c80336699
particle.vibration             -> 37000000004000003ffe28
particle.trail                 -> 383ff8000000000000c002000000000000400e000000000000ff00ff001e
```

Anything the script does not already understand is a hard failure rather
than a skipped entry — a particle silently omitted would encode as
another particle's id the next time the registry shifted.

`nix run .#sync-minecraft-particles` regenerates;
`checks.minecraft-particles` fails on drift, alongside the other six.

## `hyperion::effects`

```rust
Effect::burst(particle, at) / line(from, to) / ring(c, r) / disc(c, r) / sphere(c, r)
    .count(n).offset(spread).speed(v).points(n).normal(n).long_distance(b)
    .emit(world)                                   // one bundle, one broadcast
    .expanding(to, seconds).emit(world)            // a ParticleEmitter entity a system ages

apply_impulse(entity, delta) / set_velocity(..) / knock_back(entity, from, magnitude, lift)
knockback_impulse(origin, target, magnitude, lift) -> Vec3
quantized(v) -> Vec3                               // what the client will actually see

players_within(world, center, radius, except) -> Vec<Hit>   // nearest first
nearest_player(..) -> Option<Hit>;  Hit::falloff(radius)

spawn(world, kind, at) -> EntityView
launch(world, kind, at, velocity, shooter) -> EntityView
Lifetime::new(seconds)                             // expires and despawns itself
```

Only particles is a flecs `Module`, because only particles have state
outliving the call that started them. The emitter ages on flecs' delta
time, not on any game clock, so an effect started in the hub does not
freeze — same reason #1023's `Effect` did.

`smash::module::knockback::strength` stays where it is and stays the
only health-scaled knockback in the game; this layer takes an
already-computed magnitude.

## Two bugs found while building it

**Nothing outside `PacketState::Play` ever sent `RemoveEntities`.**
`remove_player_from_visibility` is gated on it, so a projectile, a
dropped item or a turret the server destructed stayed drawn on every
client for the rest of the match. `SpawnModule` now has the observer for
everything else.

**`HyperionCore` never imported `SpatialModule`,** although
`update_projectile_positions` (via `EgressModule`) calls
`spatial::get_first_collision`, which reads the `SpatialIndex`
singleton. A world that spawned an engine projectile panicked with
`Component hyperion::spatial::SpatialIndex is not registered` on the
next tick. bedwars imported it by hand; smash did not, so the panic was
one arrow away. Found by the new spawn test, which panicked exactly that
way before the import went in.

## What I did not do

- **No e2e gate for the new shapes.** A gate needs something to *emit*
an effect, and nothing does until the seam PR lands. The wire assertions
here are byte-level against Mojang's own encoder, which is more precise
about content than an e2e is, but it is not a client seeing a packet.
The e2e peer belongs with the seam PR and I will add it there.
- **The area query is a scan, not the BVH.** `SpatialIndex` is rebuilt
empty every frame because nothing in production adds `Spatial`;
ENG-10879. The fence is in `area.rs`'s module comment with the count it
holds at (sixteen players, fine; a thousand, not).
- **No `SoundEvent` enum.** Positional sound at an arbitrary point
already works and I was not going to build a second path for it. The
1968 string sound ids remain.
- `nix/generate-rust.py:288` refers to `nix/generate-entity-types.py`,
which #1024 deleted. Not my file, not touched.

## Every new guard broken, and what it said

Eighteen. Each mutation applied, the test run, the output recorded, the
mutation reverted; full suite green again afterwards.

| # | Mutation | Failure |
|---|---|---|
| 1 | dust colour as a `VarInt`, not a fixed int | `dust
(minecraft:dust) does not encode the way the jar does` / left `[21, 128,
128, 252, 255, 15, ...]` right `[21, 255, 255, 0, 0, ...]` |
| 2 | `Dust = 200`, an id drifted from the registry | `assertion left ==
right failed: dust` / left `200` right `21` |
| 3 | `BlockStateId::from_raw` accepts anything | `assertion failed:
BlockStateId::from_raw(-1).is_err()` |
| 4 | ring basis seeded from a fixed axis | `out of the plane by 3 with
normal Vec3(1.0, 0.0, 0.0)` |
| 5 | sphere bunched toward one pole | `64 of 64 above the equator` |
| 6 | disc drawn as a ring | `0 of 64 in the inner half` |
| 7 | area query forgets to exclude the caster | left `[Entity(906),
Entity(907)]` right `[Entity(907)]` |
| 8 | area query stops sorting by distance | left `[Entity(910),
Entity(909), Entity(908)]` right `[Entity(909), Entity(910),
Entity(908)]` |
| 9 | knockback steered by vertical separation | `Vec3(0.3577709,
1.1155418, 0.0)` |
| 10 | `quantized` reports what was asked, not what was sent |
`Vec3(0.8, 0.4, 0.0) did not survive the wire` / left `Vec3(0.7999756,
0.3999878, 0.0)` right `Vec3(0.8, 0.4, 0.0)` |
| 11 | `long_distance` dropped before the packet | `long_distance sets
it` |
| 12 | `offset` dropped before the packet | left `(0.0, 0.25, 0.125)`
right `(0.5, 0.25, 0.125)` |
| 13 | committed particle table hand-edited | `committed particle table
is stale; run: nix run .#sync-minecraft-particles` plus the diff |
| 14 | generator meets a codec it has never seen |
`GeyserBaseParticleOptions: 'ByteBufCodecs.FLOAT' is a codec this script
has never seen. Read its Java and add it to PRIMITIVES, COMPOSITES or
ID_MAPPERS; guessing at a body shifts every byte after the particle.` |
| 15 | Mojang renames the `register` helper | `125 registry entries have
no register() call in ParticleTypes.java, starting with
['minecraft:angry_villager', ...]; the regex no longer matches every
declaration` |
| 16 | `launch` forgets the owner | `an arrow with no owner collides
with the bow that fired it` / left `None` right `Some(Entity(919))` |
| 17 | a lifetime never expires | `outlived its lifetime` |
| 18 | an entity destructed the tick it spawns | `expired before its
time` |

**Two of these were wrong the first time, and only running them showed
it.** #2 and #10 as first written failed to *compile* rather than
failing the assertion, which proves nothing about the guard; both were
rewritten as mutations that build. And #16 first "passed" — the mutation
had matched the same text inside a doc comment above the code, so the
mutation harness (`replace(..., 1)`) had silently edited a comment and
left the real line alone. Re-anchored on a unique two-line span, it
fails as above. A mutation that does not compile and a mutation that
does not apply look identical to a green test run.

## Checks

- `nix run .#test` — 714 passed, 1 skipped
- `nix run .#lint` — clean for everything in this diff
- `nix run .#fmt` — clean
- `checks.minecraft-particles`, `checks.minecraft-encoder-fixtures`,
`checks.minecraft-proto-json` — green

**`origin/main` at `a25c3a9` is already red on lint**, from #1023:
`events/smash/tests/abilities.rs:595` has an unused `use
flecs_ecs::prelude::*;`. Verified by stashing this branch's changes and
linting that target at `a25c3a9`. Not touched here — the `bow` worktree
already has the one-line deletion staged.

## Also filed

- ENG-10879 — the spatial BVH is rebuilt empty every tick; nothing in
production adds `Spatial`.
- ENG-10893 — `git stash` is shared across worktrees, so one agent's
`pop` takes another agent's stash. Nearly lost a colleague's
work-in-progress to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
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