Skip to content

feat(proto): generate packet structs from protocol.json at build time - #971

Merged
andrewgazelka merged 4 commits into
mainfrom
feat/proto-codegen
Jul 27, 2026
Merged

andrewgazelka merged 4 commits into
mainfrom
feat/proto-codegen

Conversation

@andrewgazelka

@andrewgazelka andrewgazelka commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

The wire format now has one source of truth. protocol.json is committed
beside the crate and build.rs turns the layouts the extractor recovered in
full into Rust; nothing about a packet body is transcribed by hand any more,
and there is no sync step for the structs to drift from.

What the generated code looks like

/// `minecraft:intention`, sent serverbound as handshake id 0.
///
/// Layout from `net.minecraft.network.protocol.handshake.ClientIntentionPacket#STREAM_CODEC`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Encode, Decode)]
pub struct Intention<'a> {
    pub protocol_version: VarInt,
    #[proto(max_len = 32767)]
    pub host_name: &'a str,
    pub port: i16,
    pub intention: ClientIntent,
}

/// `minecraft:keep_alive`, sent clientbound as configuration id 4 and play id 38.
///
/// Layout from `net.minecraft.network.protocol.common.ClientboundKeepAlivePacket#STREAM_CODEC`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Encode, Decode)]
pub struct KeepAlive(pub i64);

177 of the 180 packet classes the extractor recovered in full are generated.
The three it declines are named, with the reason, in a comment at the top of
the file they would have gone into — a layout that is not fully expressible is
absent, never approximated:

//   minecraft:custom_click_action -- a custom codec inside a combinator needs a hand-written packet
//   minecraft:damage_event -- two fields would both be named `output`
//   minecraft:disguised_chat -- `net.minecraft.network.chat.ChatType$Bound#STREAM_CODEC` contains itself

One Java class is one Rust type: ClientboundKeepAlivePacket serves
configuration and play, so it is defined once in packets::common and
re-exported into each state, and a value built for one is the value the other
accepts.

Committed versus generated

artifact where why
protocol.json committed the input everything derives from, and the diff a version bump is reviewed as
packet structs OUT_DIR, via build.rs the wire contract, where a stale copy silently desynchronises a stream
packet ids, registries, versions, Wire table committed .rs data restated as Rust, greppable, and only wrong in a log line

The line is what a mistake costs, and whether the projection is total. The
tables are a total function of protocol.json and a committed copy can be
checked by diffing it. The packet structs are a partial projection — the
generator refuses what it cannot express — which is exactly the shape that has
bitten this pipeline before, when an earlier extractor silently truncated
eleven packets and reported them complete. Volume settles the rest: moving
registry.rs's 7,700 lines into OUT_DIR would make rg 'minecraft:diamond_sword'
miss, for no gain.

The derive

crates/hyperion-minecraft-proto-derive derives the crate's existing
Encode/Decode traits. A packet body is a fixed sequence of fields with
nothing between them, so the derive is a loop over the fields in declaration
order; anything that is not that is a compile error rather than a guess.

attribute effect
#[proto(max_len = N)] limit on the innermost string or byte slice
#[proto(max_count = N)] limit on the innermost collection's element count
#[proto(with = path)] path::encode and path::decode for this field

Named, tuple and unit structs; borrowed fields; type parameters; fieldless
enums as a VarInt discriminant. Refused, loudly: a variant with fields, more
than one lifetime, a with beside a limit.

The hand-written handshake, status and login codecs are gone — every one of
their packets is generated.

Enums are enums

writeEnum sends ordinal() and ClientIntent sends ids of its own, so an
enum field used to come out as a VarInt. Both are readable off the jar, so
eleven enums are now generated, and two packets that were partial became
complete because the enum was the only thing under them the extractor could
not follow.

/// `net.minecraft.network.protocol.handshake.ClientIntent`, sent as a varint id.
pub enum ClientIntent { Status = 1, Login = 2, Transfer = 3 }

Ids 1, 2, 3 against ordinals 0, 1, 2 — reading one for the other picks the
wrong intent silently, which is why the byId switch is parsed rather than the
position assumed.

Also fixed

The pipeline's rustfmt never saw the repo's rustfmt.toml, so cargo fmt
rewrote every committed table and fmt --check failed on an untouched tree.
It now runs the pinned nightly with --config-path.

Gates

$ nix run .#fmt -- --check   # clean
$ nix run .#lint             # Finished in 16.06s, no warnings
$ nix run .#test             # 258 tests run: 258 passed, 1 skipped   (was 234)
$ nix build --no-link .#checks.aarch64-darwin.minecraft-proto-generated \
                      .#checks.aarch64-darwin.minecraft-proto-json \
                      .#checks.aarch64-darwin.minecraft-protocol

The new protocol.json guard was watched failing before it was trusted:
editing the committed protocol number to 999 fails the check with a diff
naming the line.

Not done

Written up in docs/minecraft-26.2-migration.md, and worth reading before
building on this:

  • Field limits come from the writer; the server enforces them on the reader,
    and the two disagree.
    ClientIntentionPacket writes hostName unbounded
    and reads it with 255. The generated struct is permissive where vanilla is
    strict. It cannot desynchronise a stream, but the fix — parsing the private
    (FriendlyByteBuf) constructor as a second, independent statement of the
    layout and cross-checking it — would also settle port, which is written
    i16 and read u16.
  • damage_event is declined over a defect, not a limit. The extractor
    inlines a static helper without binding its parameter to the caller's
    argument, so two fields are labelled output.
  • No play packet has been round-tripped against a real server; only status.

Rebased onto #968, and what that leaves

#968 landed while this was in flight. It hand-wrote configuration and the
play-login sequence against the same decompiled sources, and eleven of its
packets are now also generated
— select_known_packs, registry_data,
update_enabled_features, keep_alive, ping, pong, disconnect,
finish_configuration, reset_chat, code_of_conduct,
accept_code_of_conduct, plus Login and Respawn in play_login.

They are defined twice for now, once flat in the hand-written module and once
under the state's clientbound/serverbound module. The generated one is the
one to keep — it also closes #968's own stated gap, since the 64-element cap on
ServerboundSelectKnownPacks is enforced here through the Error::ListTooLong
this branch adds. Reconciling means rewriting 725 lines of tests that name the
hand-written types, which is a review of its own, so it is the next change
rather than this one; src/packets/mod.rs says so in the source.

What is not duplicated is the part the generator cannot do:
ClientInformation, CustomPayload and UpdateTags all branch on a runtime
value, and stay hand-written.

One thing found while rebasing

nix flake check's staleness check has been failing on main since #961, which
ran a use_self fix over the committed generated/wire.rs without telling the
generator. The committed copy said &'static Self and the pipeline still
produced &'static Wire. Fixed in the generator, which is the check doing
exactly what it exists for.

@github-actions github-actions Bot added the feat label Jul 27, 2026
The wire format now has one source of truth. `protocol.json` is committed
beside the crate and `build.rs` turns the layouts the extractor recovered
in full into Rust; nothing about a packet body is transcribed by hand any
more, and there is no sync step for the structs to drift from.

176 of the 179 fully-recovered packet classes are generated. The three the
generator declines are named, with the reason, in a comment at the top of
the file they would have been written into: a custom codec inside a
combinator, two fields that would take the same name, and a type that
contains itself. None of them is approximated.

Adds `hyperion-minecraft-proto-derive`, which derives the existing
`Encode`/`Decode` traits from a struct's own fields. `#[proto(max_len)]`,
`#[proto(max_count)]` and `#[proto(with = ...)]` carry the limits and the
one layout the structural rules do not describe.

The tables (packet ids, registries, version) stay committed Rust: they
restate data rather than a wire contract, they are what a reader greps, and
a stale one is a wrong name in a log line rather than a desynchronised
stream.

Also fixes a formatting divergence that predates this change: the
pipeline's rustfmt never saw the repo's rustfmt.toml, so `cargo fmt`
rewrote every committed table and `fmt --check` failed on an untouched
tree.
`writeEnum` sends an ordinal and `ClientIntent` sends ids of its own, so a
`VarInt` field was as far as the extractor went. Both are readable off the
jar: the constants in declaration order, and where the class has one, the
`byId` switch that says which number each constant is sent as.

Eleven enums come out, `ClientIntent` among them, so `intention` is a
`ClientIntent` rather than a number that happens to be 1, 2 or 3. A value
the server would reject is now unrepresentable, and an unknown discriminant
decodes to `Error::InvalidEnum` instead of to a wrong variant.

Two packets that were partial are now complete, because the enum was the
only thing under them the extractor could not follow.

The decompile scope gains one clause: an enum a packet names. `RecipeBookType`
and `RemoteDebugSampleType` carry no `StreamCodec`, so nothing else selected
them, and an enum whose constants are missing cannot be resolved at all.
#961 ran a `use_self` fix over the committed `generated/wire.rs` without
telling the generator, so `nix flake check`'s staleness check has been
failing since: the committed copy says `&'static Self` and the pipeline
still produced `&'static Wire`.

Emitting `Self` inside the enum is what clippy would leave, so the two agree
again. This is the check doing its job -- a hand-edit to a generated file is
exactly what it exists to catch.
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.6 ns ]      -0.11%
ray_intersection/aabb_size_1                       [  18.7 ns ...  18.7 ns ]      -0.06%
ray_intersection/aabb_size_10                      [  18.9 ns ...  18.8 ns ]      -0.18%
ray_intersection/ray_distance_1                    [   0.7 ns ...   0.7 ns ]      +0.25%
ray_intersection/ray_distance_5                    [   0.7 ns ...   0.7 ns ]      +0.25%
ray_intersection/ray_distance_20                   [   0.7 ns ...   0.7 ns ]      +0.04%
overlap/no_overlap                                 [  12.4 ns ...  12.4 ns ]      -0.02%
overlap/partial_overlap                            [  12.1 ns ...  12.1 ns ]      +0.02%
overlap/full_containment                           [  12.0 ns ...  12.0 ns ]      -0.13%
point_containment/inside                           [   5.1 ns ...   5.1 ns ]      +0.34%
point_containment/outside                          [   5.0 ns ...   5.0 ns ]      +0.14%
point_containment/boundary                         [   5.1 ns ...   5.1 ns ]      +0.02%

Comparing to 3a2d475

@andrewgazelka
andrewgazelka merged commit 7f7bf16 into main Jul 27, 2026
11 of 12 checks passed
@andrewgazelka
andrewgazelka deleted the feat/proto-codegen branch July 27, 2026 11:50
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.45671% with 121 lines in your changes missing coverage. Please review.
✅ Project coverage is 34.95%. Comparing base (3a2d475) to head (3386dca).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/hyperion-minecraft-proto/src/types.rs 79.18% 45 Missing and 1 partial ⚠️
...ates/hyperion-minecraft-proto-derive/src/expand.rs 86.59% 27 Missing and 10 partials ⚠️
crates/hyperion-minecraft-proto/src/codec.rs 62.50% 12 Missing and 3 partials ⚠️
crates/hyperion-minecraft-proto-derive/src/attr.rs 60.71% 7 Missing and 4 partials ⚠️
crates/hyperion-minecraft-proto/src/nbt/mod.rs 0.00% 6 Missing ⚠️
crates/hyperion-minecraft-proto/src/error.rs 0.00% 4 Missing ⚠️
...yperion-minecraft-proto/src/generated/packet_id.rs 0.00% 2 Missing ⚠️
@@            Coverage Diff             @@
##             main     #971      +/-   ##
==========================================
+ Coverage   34.01%   34.95%   +0.94%     
==========================================
  Files         218      219       +1     
  Lines       21995    22476     +481     
  Branches      797      835      +38     
==========================================
+ Hits         7481     7856     +375     
- Misses      14350    14438      +88     
- Partials      164      182      +18     
Files with missing lines Coverage Δ
crates/hyperion-minecraft-proto-derive/src/lib.rs 100.00% <100.00%> (ø)
...tes/hyperion-minecraft-proto/src/generated/wire.rs 0.00% <ø> (ø)
...erion-minecraft-proto/src/packets/configuration.rs 99.17% <ø> (ø)
...yperion-minecraft-proto/src/generated/packet_id.rs 0.00% <0.00%> (ø)
crates/hyperion-minecraft-proto/src/error.rs 0.00% <0.00%> (ø)
crates/hyperion-minecraft-proto/src/nbt/mod.rs 84.28% <0.00%> (-1.48%) ⬇️
crates/hyperion-minecraft-proto-derive/src/attr.rs 60.71% <60.71%> (ø)
crates/hyperion-minecraft-proto/src/codec.rs 89.21% <62.50%> (-5.32%) ⬇️
...ates/hyperion-minecraft-proto-derive/src/expand.rs 86.59% <86.59%> (ø)
crates/hyperion-minecraft-proto/src/types.rs 79.18% <79.18%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

andrewgazelka added a commit that referenced this pull request Jul 27, 2026
#971 landed the build-time generator while this was in flight. Login,
handshake and status are now generated from protocol.json, so the
hand-written ClientboundLoginFinishedPacket added here is gone in favour
of the generated one.

Configuration follows the generated types where they exist --
select_known_packs, registry_data, update_enabled_features and
finish_configuration -- rather than the hand-written duplicates, which
shortens the reconciliation that crate's packets/mod.rs describes instead
of lengthening it. client_information, custom_payload and update_tags
stay hand-written; the generator cannot express them.
andrewgazelka added a commit that referenced this pull request Jul 27, 2026
…63 pre-play path (#972)

Serves Minecraft 26.2 (protocol 776) through
`crates/hyperion-minecraft-proto`.
A scripted client reaches play state; a vanilla client does not yet stay
there,
and the reason is spelled out below.

## What changed

The four states before play -- handshake, status, login, and the
configuration
state that did not exist in 1.20.1 -- are now
`crates/hyperion/src/net/protocol/`.
The valence 763 path was **deleted rather than feature-gated**: a branch
that
only one of two protocols ever takes is a branch nobody exercises.

The transport did not change with the protocol. hyperion frames packets
itself
and the proxy forwards bytes without reading them, so `net::encoder`,
`net::decoder` and `packet_channel` are untouched; one adapter turns a
proto-crate
`Encode` plus a packet id into a `PacketBundle` and the existing
compression and
length framing carry it.

**Registry contents did not have to be written.**
`RegistrySynchronization.packRegistry` writes an element with an empty
payload
when the client reported the pack that defines it. Offering
`minecraft:core:26.2` and getting it back means all 397 elements of all
29
synchronised registries go out as names alone, so `registries.rs` is a
name
table. The order is `RegistryDataLoader.SYNCHRONIZED_REGISTRIES` and the
names
are what the vanilla data generator writes; both read off the jar. A
client that
does not report the core pack is refused with a message saying exactly
that.

`ClientboundLoginFinishedPacket` was the one packet the proto crate was
missing.
It was added here by hand and then dropped again when #971 landed
mid-flight and
generated it; this branch is rebased onto that and uses the generated
login,
handshake and status structs. Configuration follows the generated types
where
they exist rather than the hand-written duplicates, which shortens the
reconciliation that crate's `packets/mod.rs` describes rather than
lengthening
it. `client_information`, `custom_payload` and `update_tags` stay
hand-written,
because the generator cannot express them.

## Verified

`nix run .#lint` and `nix run .#test` pass (315 tests). Full transcripts
are in
`docs/minecraft-26.2-migration.md`. The join, against a real running
stack on
dedicated ports:

```
[Prober] <- LoginCompression threshold=256
[Prober] <- LoginFinished profile=c07403a5245bf4e124ad10d435771f55 name=Prober (authenticated, NOT yet in the world)
[Prober] -> LoginAcknowledged
[Prober] <- SelectKnownPacks [('minecraft', 'core', '26.2')]
[Prober] -> SelectKnownPacks (accepting all)
[Prober] <- RegistryData x29 (397 elements): ...
[Prober] <- FinishConfiguration
[Prober] -> FinishConfiguration (ack)
[Prober] <- Login entity_id=67109860  ** REACHED PLAY STATE **
[Prober] RESULT: reached play state (Login received)
```

`tools/client-26.2.py` is the 776 counterpart to `smash-client.py`. It
exists
because a player count is not evidence of a join, and on 776 there are
two more
gates before the world than there were on 763.

## Two bugs this was watched for

- **Authenticated but never in the world.** The join now runs off the
client's
own `finish_configuration` rather than off a skin channel, so a skin
that
never arrives can no longer keep anyone out. `tests/join_world.rs` still
  guards the channel that applies skins.
- **Skin layers.** 1.20.2 moved `ClientInformation` into configuration,
so it is
handled there and `DisplayedSkinParts` is set before the player enters
the
  world.

The test client caught a third. `hyperion-permission` unicast the
command tree
the moment a permission group was set, during login. On 763 that landed
after
`LoginSuccess`, where the client is already reading play packets, so it
worked
by accident; on 776 the same bytes arrive in the configuration state and
are
read as whatever configuration packet shares that id. Now gated on the
player
being in play.

## What a reviewer should not assume works

**A vanilla client disconnects immediately after the join packet.**
Clientbound
play is still valence at 1.20.1 ids -- the same transcript continues
`0x1E x4096, 0x24 x2434, 0x4E x1, 0x65 x154`, which are
`UnloadChunkS2c`,
`ChunkDataS2c`, `ChunkRenderDistanceCenterS2c` and
`PlayerListHeaderS2c`. The
scripted client counts them and carries on, which is what lets it prove
the
join; a real client will not. Serverbound play is likewise still decoded
on
valence ids.

So the next piece is the whole of clientbound play, not just terrain.
`registries.rs` should also be deleted in favour of generated contents
once the
proto crate produces them.
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