Skip to content

fix(proto): a zero-byte packet has to be Mojang's word, not ours - #1038

Merged
andrewgazelka merged 1 commit into
mainfrom
gen-proto
Jul 28, 2026
Merged

fix(proto): a zero-byte packet has to be Mojang's word, not ours#1038
andrewgazelka merged 1 commit into
mainfrom
gen-proto

Conversation

@andrewgazelka

@andrewgazelka andrewgazelka commented Jul 28, 2026

Copy link
Copy Markdown
Member

play/clientbound/minecraft:boss_event has been extracted as carrying no bytes for this whole protocol version. It writes a UUID, an enum ordinal and a variable body.

Nothing caught it, and nothing here could have. A refusal is visible; a wrong answer is not. The packet was complete: true, absent from coverage.incomplete, with no reason recorded anywhere — the same shape as a packet the generator got right. Every hand-written boss-bar type in packets/play/player.rs, including the two ordinal enums at line 1023, exists because of this one silently wrong extraction that our own coverage measurement reported as covered.

Root cause

Analyzer.method_body resolved Foo::write by regex-scanning the class body text for the first write( followed by a block. A decompiled class body carries its nested and anonymous classes inline, so the scan cannot tell whose method it found — and the first match is not even reliably the outer one. ClientboundBossEventPacket declares an anonymous Operation at line 27 whose write is empty, textually above its own write at line 78. Zero fields became unit became complete: true.

What changed

methods() only sees declarations at brace depth zero. A member of a nested or anonymous class is never mistaken for the outer class's own. This also let two other layouts through: level_chunk_with_light now resolves down to Heightmap$Types#STREAM_CODEC (which is, separately, the source of truth for the hand-written HeightmapKind), and update_recipes past SelectableRecipe.noRecipeCodec to the dispatch underneath.

An empty layout can no longer be inferred. read_encode_body returning zero fields is now a refusal. The only empty layout is one Mojang spells StreamCodec.unit(..). This is a no-op on today's data, which is exactly the point: the silent path is gone rather than merely unoccupied.

coverage.empty records every packet claimed to carry no bytes, and each is warranted independently of the code path that produced it — StreamCodec.unit(..) has to appear in the packet's own source or in the expression it was registered with, which is where the bundle delimiter's lives. A packet that comes out empty without that fails the extraction by name.

The baseline holds the cause of each refusal, not only its id. This is the part that matters. The refusal count did not move when this bug was introduced and would not move if it came back, so a ratchet over ids alone measures the generator's honesty rather than its correctness.

The four custom_payload refusals are carved out into coverage.opaque. A channel id followed by bytes whose meaning belongs to whoever registered the channel has no layout in the server either. Counting them as gaps made the remaining number mean "work left, plus four that are finished". The carve-out is checked, not asserted: an id that stops being refused, or starts being refused for a different reason, fails the extraction rather than hiding a real gap behind a finished one.

Numbers, with composition

Gaps 62 → 59: boss_event added (+1), the four custom_payload entries carved out (−4). The 59 are 19 unmodelled statement, 14 runtime-dispatched union, 7 conditional presence, 5 DataComponentPatch, 5 optional ItemStack, 4 loop over a sequence, 4 packed bitfield, 1 other. 16 layouts are declared empty by StreamCodec.unit, all warranted. Generated packet structs 180 → 179; the one that vanished is the boss bar packet's empty struct, which was never a packet.

Guards broken, and what they said

Six mutations. Each was checked to have applied (assert t.count(old) == 1, then grep for the replacement in the file) and to parse (ast.parse) before being run, because a mutation that does not apply and one that does not compile are both indistinguishable from a green run.

A — remove the depth filter from methods(). The interesting one, because it shows why the cause is held:

63 layouts not recovered in full, by cause:
   18  unmodelled statement
   ...
    1  inferred-empty layout
    1  unmodelled codec factory
RECAUSED play/clientbound/minecraft:boss_event: unmodelled statement -> inferred-empty layout
          encoder body wrote no bytes; only StreamCodec.unit declares an empty layout
RECAUSED play/clientbound/minecraft:update_recipes: runtime-dispatched union -> unmodelled codec factory

2 layout(s) fail for a different reason than the baseline records. The count is unchanged, so
only the cause says whether the extractor moved forwards or backwards; read the reasons above
before regenerating.

The count is 63 both before and after. The id set is identical. The old ratchet would have passed.

B — revert both the depth filter and the inferred-empty refusal, i.e. the code exactly as it is on main. The extractor refuses to finish:

  packets:              256
  fully mechanical:     205
  partial:              51
  EMPTY-LAYOUT CHECK FAILED: 1
    play/clientbound/minecraft:boss_event (net.minecraft.network.protocol.game.ClientboundBossEventPacket)
    extracted as carrying no bytes, but its source never says StreamCodec.unit

205 / 51 are main's own numbers. That is what this looked like from the outside for the whole version.

C — a packet starts claiming an empty layout (coverage.empty gains an entry). Exit 1:

NOW EMPTY play/clientbound/minecraft:set_health

1 packet(s) now extract as carrying no bytes. That is not a packet getting simpler; it is the
layout being lost, and it leaves coverage.incomplete on the way out so nothing else here would
have called it a regression.

D — a packet stops being empty. Exit 1, NO LONGER EMPTY play/serverbound/minecraft:player_loaded, baseline is looser than the code.

E — a carve-out's phrase stops matching its refusal. Exit 1, four times:

  OPAQUE CARVE-OUT STALE: 4
    configuration/clientbound/minecraft:custom_payload is carved out of the gap list as
    'unmodelled factory SomethingElse.codec', but it is now refused for
    ['unmodelled factory CustomPacketPayload.codec(..)']; the carve-out would hide a real gap

F — a carve-out names a packet that is not refused. Exit 1:

  OPAQUE CARVE-OUT STALE: 1
    play/serverbound/minecraft:no_such_packet is carved out of the gap list but is no longer
    refused at all; delete the OPAQUE entry

All six restored afterwards; the extractor reproduces the committed protocol.json byte for byte and nix build .#checks.aarch64-darwin.minecraft-proto-json passes.

The blind spot this does not close

A layout that recovers some fields and silently drops others is still invisible. Only the zero-field case is now impossible, and only the zero-field case is warranted against the source. Closing the general case means round-tripping every complete: true packet through Mojang's own STREAM_CODEC in nix/java/VanillaEncoder.java and comparing byte for byte — which needs a valid instance of each of 179 packet classes constructed in Java, and that is not affordable in this change. Filed as ENG-10930 with this reasoning. Nothing here should be read as the class being closed; it is one instance fixed and one sub-case made impossible.

Checks

cargo test --workspace green. cargo clippy --workspace --all-targets --all-features -- -D warnings (CI's exact command) green. nix build .#checks.aarch64-darwin.{minecraft-proto-coverage,minecraft-proto-json,tool-paths,minecraft-literals} all green. No generated path moved, so tool-paths had nothing new to catch, but it was run.

`play/clientbound/minecraft:boss_event` has been extracted as carrying no
bytes for this whole protocol version. It writes a UUID, an enum ordinal
and a variable body. Nothing caught it, because a wrong answer and a
correct one are the same shape to every gate we have: `complete: true`,
absent from `coverage.incomplete`, no reason recorded anywhere.

`Analyzer.method_body` resolved `Foo::write` by scanning the class body
text for the first `write(` followed by a block. A class body carries its
nested and anonymous classes inline, and `ClientboundBossEventPacket`
declares an anonymous `Operation` whose `write` is empty *above* its own.
Zero fields became `unit` became `complete`.

Three changes, in increasing order of how much they matter.

`methods()` only sees declarations at brace depth zero, so a member of a
nested class is never mistaken for the outer class's own. This also let
two other layouts through: `level_chunk_with_light` now resolves down to
`Heightmap$Types#STREAM_CODEC`, and `update_recipes` past
`SelectableRecipe.noRecipeCodec` to the dispatch underneath.

An empty layout can no longer be inferred. `read_encode_body` returning
zero fields is now a refusal; the only empty layout is one Mojang spells
`StreamCodec.unit(..)`. No-op on today's data, which is the point: the
silent path is gone rather than currently unoccupied.

`coverage.empty` records every packet claimed to carry no bytes, and each
one is warranted independently -- `StreamCodec.unit(..)` has to appear in
the packet's own source or in the expression it was registered with. A
packet that comes out empty without that fails the extraction by name.

The baseline now holds the *cause* of each refusal rather than only its
id, because the count did not move when this bug was introduced and would
not move if it came back. And the four `custom_payload` entries are carved
out of the gap list into `coverage.opaque`: a channel id followed by bytes
the server has no layout for either is not work left, and counting it as
work left makes 63 mean "59, plus four that are finished".

Gaps: 62 -> 59. Generated packet structs: 180 -> 179, the one that
vanished being the boss bar packet's empty struct, which was never a
packet.
@andrewgazelka
andrewgazelka merged commit cd83f7b into main Jul 28, 2026
11 of 12 checks passed
@andrewgazelka
andrewgazelka deleted the gen-proto branch July 28, 2026 11:51
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  20.0 ns ...  18.6 ns ]      -7.07%*
ray_intersection/aabb_size_1                       [  18.6 ns ...  20.0 ns ]      +7.84%*
ray_intersection/aabb_size_10                      [  18.6 ns ...  20.4 ns ]      +9.29%*
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      -0.11%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      -0.02%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      +0.11%
overlap/no_overlap                                 [  15.5 ns ...  22.7 ns ]     +46.68%*
overlap/partial_overlap                            [  15.5 ns ...  22.6 ns ]     +46.18%*
overlap/full_containment                           [  14.7 ns ...  22.8 ns ]     +55.14%*
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      +0.10%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      +0.32%
point_containment/boundary                         [   5.4 ns ...   5.5 ns ]      +1.05%

Comparing to 035a8ce

@github-actions github-actions Bot added the fix label Jul 28, 2026
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
A Java enum's constant order is a wire table.
`FriendlyByteBuf.writeEnum` sends `ordinal()`; `ByteBufCodecs.idMapper`
sends a field each constant declares. Either way the declaration order
in the jar is as much a definition with a source of truth as a registry
is, and hand-transcribing one is the same defect — with the extra hazard
that an ordinal carries no name on the wire, so a transcription one
constant out is a wrong value rather than a failed lookup.

Eleven of them were typed out in this crate. `EquipmentSlot` twice, once
per module, with different accessor names on the two copies.

## Why exactly these eleven, which is the interesting part

A hand-written ordinal enum here is almost exactly the set whose
enclosing packet the extractor refused.

Where a packet's layout is recovered in full, `build.rs::lower_enum`
already turns the enums it mentions into closed `#[repr]` Rust enums
from `protocol.json` — twenty of them exist in `OUT_DIR` right now, and
nobody typed any of them. `protocol.json` carries 42 extracted enum
definitions with full constant lists; the other 22 never reach Rust
because they sit inside a refused packet or data component.
`AttributeModifier$Operation` is the clean proof: it *is* extracted,
with its three constants, and it is *also* hand-written in
`item/payload.rs`, purely because
`dataComponent/minecraft:attribute_modifiers` is a refusal.

So these eleven are the ones no recovered layout reaches — carried by
`set_player_team`, `set_objective`, `set_equipment`, `commands`,
`level_chunk_with_light`, or by the boss bar packet that until #1038 was
extracted as carrying no bytes at all. `JAVA_ENUMS` in
`nix/extract-protocol.py` reads them out of the decompiled class
directly rather than waiting for those layouts to be recovered.

| generated | Java class | numbered by | n |
|---|---|---|---|
| `BossBarColor` | `world.BossEvent$BossBarColor` | ordinal | 7 |
| `BossBarOverlay` | `world.BossEvent$BossBarOverlay` | ordinal | 5 |
| `ChatTypeParameter` | `network.chat.ChatTypeDecoration$Parameter` |
ordinal | 3 |
| `Direction` | `core.Direction` | ordinal | 6 |
| `DisplaySlot` | `world.scores.DisplaySlot` | `id` | 19 |
| `EquipmentSlot` | `world.entity.EquipmentSlot` | ordinal | 8 |
| `HeightmapKind` | `world.level.levelgen.Heightmap$Types` | `id` | 6 |
| `ObjectiveRenderType` |
`world.scores.criteria.ObjectiveCriteria$RenderType` | ordinal | 2 |
| `TeamCollisionRule` | `world.scores.Team$CollisionRule` | `id` | 4 |
| `TeamColor` | `world.scores.TeamColor` | `id` | 16 |
| `TeamVisibility` | `world.scores.Team$Visibility` | `id` | 4 |

80 constants. Same conventions as #1021: `#[repr(uN)]` with the
discriminant being the number, `id()` an `as` cast, `name()` off a
static table, `from_name` a binary search over a table the generator
sorted, closed with no `Unknown`, and a `const` assertion per file
proving the discriminant folds.

## The two numberings are declared, not guessed

`EquipmentSlot` sends `ordinal()` from `ClientboundSetEquipmentPacket`
(`output.writeByte(slotType.ordinal())`) and `s.id` from its own
`STREAM_CODEC` (`idMapper(BY_ID, s -> s.id)`), and the two disagree:
`OFFHAND` is 1 by ordinal and 5 by id. Which numbering a class uses is
the second element of its `JAVA_ENUMS` entry, resolved against the
source and named in the generated module's own docs. Nothing infers it,
because a value read with the wrong table is a wrong value rather than a
failed lookup, and the two tables are the same shape.

The extractor refuses rather than approximates: a class that has moved,
an id field that has gone, a constant the id table does not mention, or
a set that is not dense `0..n-1` all fail the extraction with the class
named.

## Checked against the server, not against the parse

These constants are read out of decompiled Java by a regex. No test
written from that same reading could catch a misreading of it, so the
numbers are taken from the other side: `nix/java/VanillaEncoder.java`
now emits `java_enum.<Type>.<CONSTANT>` for all 80, and for the six
classes that publish a `StreamCodec` it gets the number by **running
Mojang's encoder and reading the `VarInt` back out** (asserting the
encoding is exactly one `VarInt` and no more). For the five with no
codec it reads `DisplaySlot.id()` or `ordinal()` off the class.
`EquipmentSlot` deliberately uses the ordinal and not its
`STREAM_CODEC`, with the reason at the call site.

`tests/java_enum.rs` then walks `ALL` for every type and compares both
directions: every variant against the server's number for the name it
claims, and the count of `java_enum.<Type>.` fixtures against the number
of variants. The second half is what catches a *dropped* constant, which
the first half cannot see.

## Guards broken, and what they said

Each mutation was checked to have applied (`assert t.count(old) == 1`,
then grep for the replacement in the file) and to parse, before being
run.

**G — a discriminant one out** (`TeamColor::DarkBlue = 1` → `= 2`).
Caught, but **not by the test I was aiming at**:

```
error[E0081]: discriminant value `2` assigned more than once
  --> crates/hyperion-minecraft-proto/src/generated/java_enum/team_color.rs:22:1
```

Reporting it as it happened: this mutation never reached the test, so it
proves the compiler and not the fixture check. Hence H.

**H — two entries swapped in the generated name table**, which compiles
cleanly and is what a sorting bug in the generator would actually look
like. Both tests fire:

```
panicked at tests/java_enum.rs:34:
assertion `left == right` failed: TeamColor::Black disagrees with the server about its number
  left: 0
 right: 1

panicked at tests/java_enum.rs:88:
assertion `left == right` failed
  left: None
 right: Some(Black)
```

**I — the extractor drops a constant**, patched into `scan_java_enums`
so the whole pipeline runs on a realistic misparse (`BossBarColor` loses
`WHITE`). Regenerated, rebuilt, and the count half fires:

```
panicked at tests/java_enum.rs:42:
assertion `left == right` failed: BossBarColor has 6 variants but the server declares 7:
["java_enum.BossBarColor.PINK", ..., "java_enum.BossBarColor.WHITE"]
  left: 6
 right: 7
```

All three restored; `nix run .#sync-minecraft-proto` reproduces the
committed tables and `minecraft-proto-generated` passes.

## Call sites that changed, and one naming decision

`DisplaySlot::to_id()` → `id()` (three tests and one line in
`events/smash/src/adapter.rs`). `HeightmapKind::from_id` returned
`Result`, the generated one returns `Option`, so `world/chunk.rs` does
the `ok_or` itself with the reason for not clamping kept at the site.
The two `EquipmentSlot` copies collapse into one, so
`from_raw`/`from_ordinal` and `to_raw`/`ordinal` become `from_id`/`id`,
plus one private `slot_byte` in `packets/play/entity.rs` because that
packet packs the number into a byte with a continuation bit and the
narrowing should happen once.

`EquipmentSlot::MainHand` becomes `Mainhand`, and `OffHand` becomes
`Offhand`. Mojang's constants are `MAINHAND` and `OFFHAND`, one word
each; `MainHand` needs a dictionary the generator does not have, and
hand-maintaining an exception list is the thing this PR removes. Two
call sites in `crates/hyperion/src/simulation/inventory.rs`.

## Decisions on the hand-written enums this PR does *not* generate

Listed because a hand-written type nobody has decided about is the thing
to avoid, not the existence of hand-written types.

- **`ChatVisiblity`, `ParticleStatus`** — generatable, blocked. Neither
class is in the decompiled tree: `nix/minecraft-data.nix` selects
classes referenced *one hop* from `net/minecraft/network`, and both are
reached only via `net.minecraft.server.level.ClientInformation`, which
is itself one hop out. Filed as ENG-10940. The extractor sees the gap
today — both degrade to a bare `varint` noted `enum ordinal`, with the
class name dropped.
- **`StringArgumentKind`** —
`com.mojang.brigadier.arguments.StringArgumentType$StringType`.
Brigadier is a separate artifact and the decompile covers
`net/minecraft` only. Staying hand-written; the reason is now at the
type.
- **`NamedColor`, `Decoration`** — `ChatFormatting`, but travelling as
JSON/NBT *names* rather than numbers, and `Decoration` is a reordered
subset. Their truth lives in DFU `MapCodec`s, which this extractor
cannot read at all. Staying hand-written; see the note on the text
module in the enumeration.
- **`EntityDataSerializer`** — not a Java enum. `EntityDataSerializers`
is a static registry of fields, numbered by `registerSerializer` call
order, so it needs a different reader. Not attempted.
- **`ComponentType`** — ENG-10872, three enums over one registry,
blocked on a hand-transcribed `shape()` whose variant order is
load-bearing.
- **`HumanoidArm`** — the pure duplicate. `build.rs` already emits it
into `OUT_DIR/types.rs`; the copy in `packets/configuration.rs` exists
because that module was written in parallel with the generator and
landed first. Deliberately left for its own change, because
`packets/mod.rs` documents **eleven packets defined twice for exactly
the same reason** and `HumanoidArm` is one thread of that knot rather
than a separate cleanup. Filed as ENG-10941 with the eleven named.

## Checks

`cargo test --workspace`, `cargo clippy --workspace --all-targets
--all-features -- -D warnings` (CI's exact command) and `cargo fmt --all
--check` all green. `nix build
.#checks.aarch64-darwin.{minecraft-proto-coverage,minecraft-proto-json,minecraft-proto-generated,minecraft-encoder-fixtures,tool-paths}`
all green.

`minecraft-literals` is **red on `main`** and not from this branch:
`events/smash/tests/visuals.rs` gained
`minecraft:entity.player.hurt_on_fire` in #1035, one over the baseline
of 322. Untouched here, reported rather than fixed.

Co-authored-by: agent <agent@ix.dev>
@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 54.08%. Comparing base (5a486fd) to head (1937e18).
⚠️ Report is 20 commits behind head on main.

@@            Coverage Diff             @@
##             main    #1038      +/-   ##
==========================================
+ Coverage   52.61%   54.08%   +1.46%     
==========================================
  Files         337      347      +10     
  Lines       30819    32497    +1678     
  Branches     1194     1234      +40     
==========================================
+ Hits        16216    17576    +1360     
- Misses      14339    14643     +304     
- Partials      264      278      +14     

see 29 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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