Add a protocol version to the schema
CLAUDE.md is the law for how to spell a declaration. This is the workflow. Where they
disagree, CLAUDE.md wins.
The rule of four
A protocol version changes exactly four things. Every one MUST be accounted for before
the work is done - not should, not may. A change you did not look for is a silent wire
break.
- Packet change - a packet added or removed.
- Type change - a field added, removed, renamed or moved, in a type or in a packet,
and a type itself added, removed or renamed.
- Wire change - the same field, encoded differently.
- Enum change - a value added, removed, renamed, shifted, or a sentinel moved.
- DO walk all four explicitly and report what you found in each, including "nothing".
"Run it" and "dry run it" both mean all four, every time - a partial pass is not a
smaller version of this workflow, it is a wrong answer with a confident shape. Reporting
three categories and calling the fourth "not yet walked" is the failure, not a caveat.
- DO fan out rather than defer when the four do not fit one pass. One agent per category,
each given the interval, the four sources and the schema to check against, is the intended
shape - the categories are independent by construction, so they parallelise exactly.
- DO treat a change to a packet the schema does not model as a finding, not a
no-op. It is the most expensive miss there is: an unmodelled
CameraPresetsPacket whose
preset grew two fields relayed a short body and desynchronised a client mid-list, and a
union case appended to an unmodelled ServerboundPackSettingChangePacket had nothing to
be appended to. Model the packet.
- DO NOT stop at the first category with hits. They are independent, and 3 and 4 are
the two a name-level reading cannot see.
The 2168 rule
Below 2168 BDS wrote packets through a mixture of hand-written
write(BinaryStream&) and the cereal auto-codec, and a packet could move between the two
at any release. Hand-written code branches on a type field, picks its own union tag widths,
and encodes a shared type differently per call site.
At and above 2168 everything is cereal: every field flat, in declaration order,
unconditional.
That decides both the shape of the DSL and where the rule of four is discovered.
|
forward (>= 2168) |
back-port (< 2168) |
| evidence |
the protocol-docs dump, complete |
the dump for the cerealised half only, gophertunnel/Cloudburst for the rest |
| DSL body |
bare name: Type lines |
when=, unions, Literal[True], field(type=) |
| section |
§3 |
§4 |
- DO treat annotation clutter in a
>=2168 body as a bug: you are modelling the
pre-cereal shape.
- DO NOT assume a packet's pre-2168 shape from its cerealised one, or the reverse.
2168 is the template
Whichever direction you are going, the shape the schema already models at 2168 is the
baseline and the version you are adding is a delta against it. You are never modelling a
packet from scratch.
- DO start from the declaration in
protocol/*.py and establish only what the target
version does differently. Forward, that delta lands on top of it; a back-port peels one
off it, as until= or as an earlier redeclaration tiling under the same id.
- DO keep 2168's names, field order and type vocabulary except where the evidence names
a change - see Naming.
- DO NOT re-derive a whole packet body from gophertunnel because the target is
pre-cereal. The
Marshal walk answers what differed then, not what the packet is.
- DO NOT let the template stand in for evidence. It is where you start reading and the
spine the names hang on, never a claim about the older wire.
Sources
There are exactly two sources of truth, and everything else is a pointer.
|
source of truth |
| cerealised at that version |
github.com/EndstoneMC/protocol-docs, the era's branch |
| hand-written at that version |
<Packet>::write decompiled in that era's IDA database |
| pointer - tells you where to look, never what is true |
|
github.com/Sandertv/gophertunnel |
which bytes moved, and golden generation |
github.com/CloudburstMC/Protocol, Nukkit-MOT |
per-version dating |
Mojang/bedrock-protocol-docs, legacy_changelogs/ |
which step inside a cycle |
| bedrock-headers, the era's branch |
names, C++ types, member order - see Naming |
- DO finish a pre-cereal reading in the binary. The IDA databases are already built, one per
build, under
bedrock-symbols/<platform>/<build>/; <Packet>::write decompiles in seconds
and BDS labels every field itself, because each write carries its own name string.
- DO NOT take a type from gophertunnel any more than a name. It is a Go codec's
modelling, not BDS's, and it will merge or split members freely.
- DO NOT read one write call as one field. The binary settles the bytes; the header
settles how many members produced them. BDS packs adjacent members into a single call:
BossEventPacket::write at 975 emits one writeUnsignedShort labelled "Darken Screen",
and the header at android/r26_u2 declares byte mDarkenScreen and byte mCreateWorldFog - two members, little-endian, one call. Reading the writer alone concludes
one uint16 and loses a field BDS really has; reading the header alone never sees that they
ship in one call. Use both, every time.
- DO NOT conclude a field is wrong because the era you are back-porting to lacks it. It may
be right at the other era, which makes it two models rather than a fix - the pair above
exists at 975 and is gone from the cereal form at 1001, so
until=1001 carries it and
since=1001 does not.
Commands below assume you have each repo cloned locally; substitute your own paths. If one
is missing, clone it rather than working around its absence - CloudburstMC needs only
--depth 1, since every version's codec directory lives at HEAD.
bedrock-headers is access-restricted. Read it, cite the branch you read, and paste none
of it anywhere - see Naming.
Naming
bedrock-headers is the naming authority, per CLAUDE.md's sources table, and it earns
no # TODO. It is branched per release and read without a checkout -
git show origin/android/r26_u4:<path> - from android/r21_u4 (~786) up to
android/r26_u4 (1.26.40, 2168). Read origin/<branch>: a local branch goes stale, and a
stale header does not read as stale, it reads as a finding.
Header coverage stops at 2168, which is exactly where the forward work starts. So a
back-port is named from the header, and a forward version is named heuristically for
whatever is new above it.
- DO settle a back-port name on the era's branch - a type's shape differs across them -
and say which branch you read in the commit message.
- DO treat a
uN as one update line with one wire shape. The same uN names different
builds in different repos - bedrock-headers' android/r26_u3 is 1.26.32, protocol-docs'
r26_u3 is 1.26.36.1 - and they still agree on the wire, so the era's branch and the
era's binary are valid arbiters for each other.
- DO reach for the newest branch on forward work too. It cannot describe a field added
after 2168, but it settles anything that already existed at it: an enum carries its full
member list there whether or not cereal binds it.
- DO keep the name already in the DSL when the dump labels the same field differently.
The dump's labels are cereal display strings, not member names, and they drift. Renaming
to chase a label churns every consumer for nothing.
- DO convert a genuinely new label into
snake_case that reads like its neighbours -
"Bypass Listener Range Check" becomes bypass_listener_range_check, "FurnaceType"
becomes furnace_type. Enum members take the same treatment in UPPER_CASE.
- DO follow the module's existing vocabulary where the label is awkward. A name that
matches the surrounding declarations beats a literal transcription.
- DO NOT take a name from gophertunnel or CloudburstMC. They date and shape symbols;
they do not name them, and they carry known-wrong names.
- DO NOT rename an existing field as a side effect of a version bump. If a label change
turns out to be a real BDS rename, that is category 2 and lands as a redeclaration on its
own evidence, not because the dump's wording moved.
- DO NOT paste bedrock-headers source into the schema, a commit message or this file.
Reading it to settle a name is the authorized use; reproducing it is not. Cite the branch
instead.
1. Establish the number
Each protocol-docs branch is named r<minor>_u<patch/10> by the dumper, and its README
names the Minecraft build, the channel and the network version. Branches run back past 898.
- DO read the network version out of the dump itself, which is where the dumper got it:
packets/RequestNetworkSettingsPacket.json, fields[0].constraints.minimum.
- DO pin the SHA you diff and quote it in the commit message. A branch is a moving head
that reruns against the newest BDS on its update line, so its README number changes under
you.
- DO record the build, not just the number - 1.26.43 and 1.26.44 both say 2168 and
disagree on the wire.
- DO gate at the first protocol number of the cycle. A release often bumps twice,
beta then release, with one wire shape, and the community codecs carry only the second:
1.21.130 shipped 897 then 898, and the schema gates
since=897.
AnimateSerializer_v898.java is a codec-dir name, not a since= value.
- DO cross-check the number against
raw.githubusercontent.com/EndstoneMC/bedrock-server-data/v2/versions.json and
minecraft.wiki's Protocol version page.
- DO NOT model a number that appears in none of them - stop and ask.
When the new number supersedes a modelled preview
One protocol per update line. The preview channel renumbers repeatedly inside one
update - 2181, then 2187, then 2192 for 1.26.50 - and nobody runs a superseded preview. A
newer number on the same update line replaces the older one; it never becomes a second
snapshot beside it.
- DO renumber every gate the superseded preview carried, then model the new dump's
delta on top. Both halves are one commit.
sed -i 's/\b<old>\b/<new>/g' protocol/*.py
sed -i 's/\b<old>\b/<new>/g; s/v<old>/v<new>/g; s/V<old>/V<new>/g' tests/*.cpp
grep -rn "<old>" protocol/ tests/ README.md # must come back empty
- DO hand-edit
__version__ in protocol/__init__.py and README's "Modelled today"
line; the sed does not reach them meaningfully.
- DO NOT trust
\b<old>\b to catch v<old> - there is no word boundary after a
letter, so namespace spellings survive. Grep for the bare number and v<number>.
- DO NOT keep the superseded number as a snapshot of its own. It buys nothing and costs
a namespace in every versioned type.
2. Snapshots
There is no version registry. _snapshot_points in
src/bedrock_protocol/compiler/descriptor_pool.py takes {0} plus every change point
every versioned type declares, unioned transitively across imports. The first since= /
until= you write materializes the version: base (the floor, claiming validity from 0),
then one vNNNN namespace per change point.
- DO diff a packet's whole transitive type closure before modelling it. A new branch is
not evidence of change - one landed whose network version had moved while packet 122's
entire closure stayed byte-identical.
- DO accept transitive versioning as the price: one field on
ItemUseInventoryTransaction re-emits TransactionData, InventoryTransactionPacket,
PackedItemUseLegacyInventoryTransaction and PlayerAuthInputPacket at the new snapshot.
- DO NOT materialize a snapshot the evidence does not force.
- DO NOT fold a real change into an existing snapshot to avoid the transitive cost.
3. Forward: at or above 2168
The dump is complete here, so the diff between two protocol-docs branches is the whole
rule of four. Read it on GitHub -
github.com/EndstoneMC/protocol-docs/compare/<old>..<new> - or locally:
git fetch origin
git diff --name-status -M origin/<old> origin/<new>
git diff -U1 -M origin/<old> origin/<new>
- DO use the two-dot form
<old>..<new>. The branches are generated independently and
share no merge base, so the three-dot compare GitHub defaults to is meaningless.
- DO pass
-M. Without it a renamed declaration reads as an unrelated delete plus add,
and --diff-filter=AD misses it entirely.
The dump's shape: packets/*.json is {id, name, fields[]}, types/*.json is
{name, fields[]}, enums/*.json is {name, values[{name, value}]}. A field is
{name, type} plus any of enum, repeat, optional, value, constraints.
A constraints block (min_length, max_length, enum_values, minimum, maximum,
description) is not wire and comes and goes in sweeps between builds; the sole
exception is RequestNetworkSettingsPacket's, which is the version.
- DO classify hunk by hunk, and let each land in exactly one of the four categories.
- DO NOT write a diff off as constraint churn in bulk. One version was landed on
exactly that reading and the sub-chunk height map's
repeat change went with it - which
is the byte a 1.26.50 client stops on.
(1) Packet change
Each packets/*.json's own id field is the authority. enums/MinecraftPacketIds.json
is a convenience index and Mojang routinely forgets to bind values in it - r26_u6 ships
ServerboundStonecutterSetRecipePacket (354) and ClientboundStonecutterSetRecipePacket
(355) as dumped packets with no entry in that enum at all.
- DO take the id from the packet file. A packet added arrives as a new
packets/*.json
carrying its id; a packet removed takes its file with it.
- DO diff
MinecraftPacketIds.json too, as a second view - it names ids the dump has no
file for, which are packets that are not cerealised.
- DO reconcile the two as a union, never an intersection. Either source can miss a
packet, and each miss is a packet on the wire that nothing decodes.
- DO treat an id that moved as a break for the packet that took it, not a rename.
- DO model the body in the same commit as the id.
MinecraftPacketIds in
protocol/network.py lists ids the schema does not model, which is a standing backlog -
but an id you gate since=<new> with no @packet body puts that id on the wire with
nothing able to decode it.
- DO NOT treat the id enum as the packet list. It is the source that silently omits.
An id with no packet behind it
BDS keeps an id in MinecraftPacketIds long after the class is gone, so an entry there is
not evidence a packet exists. Ask the question directly before modelling one - and before
concluding a packet is missing from the schema.
- DO answer it from bedrock-headers: a live packet has a
<Name>Packet.h, and the
name in the id enum drops its _DEPRECATED / _deprecated / _Deprecated suffix to give
it. Of the 22 ids the schema left unmodelled at 924, all 22 had no class header at any
branch from r21_u4 to r26_u4, and the binaries agreed.
- DO run live packets through the same probe as controls in the same command. A probe
that quietly matches nothing looks identical to a true negative, and the controls are what
tell the two apart -
DebugDrawer, Text, Login, StartGame, PlayerSkin all resolve.
- DO NOT use
??$make_packet@V<Name>Packet@@ as the packet list. It is instantiated for
one construction path only - 178 of 240 ids at 924 - and omits LoginPacket,
TextPacket and StartGamePacket among others, so reading absence there as "removed"
invents dozens of false deprecations.
- DO NOT read
?getId@<Name>Packet@@ as universal either. It resolves for
DebugDrawerPacket and not for TextPacket in the same stripped 924 build.
- DO NOT date the removal from the enum name.
ScriptCustomEvent (117) carries no
deprecation suffix and has had no class for the entire range these sources cover.
(2) Type change
Field entries appearing or vanishing in a fields[] array, and whole files arriving,
leaving or being renamed.
- DO compare fields as an ordered sequence. Cereal is positional, so a field
inserted mid-struct moves everything after it -
TextDataPayload gained LineGapHeight
in front of DepthTest, not at the end.
- DO read a delete-plus-add at the same position with the same type as a rename,
and model it as a class redeclaration; field gates cannot restate a field under a new
name.
DimensionDefinition renamed Height Maximum/Height Minimum to
Minimum Y/Height Range, which also inverted what the numbers mean - a rename can
carry a semantic change the wire types hide.
- DO read a vanishing
{"type": "bool", "value": true} entry as BDS fixing a cereal
always-true marker. Gate that one Literal[True] field until=<new>; nothing else in
the type moves. They go in waves - one version dropped twelve across
PlayerAuthInputPacket, InventoryTransactionPacket, InventorySource,
InventoryTransaction, ItemStackResponseInfo, ItemStackResponseSlotInfo and
PackedItemUseLegacyInventoryTransaction.
- DO follow a renamed type into every file that references it. BDS versions some type
namespaces, so
SharedTypes::v1_21_90::CameraPreset became
SharedTypes::v1_26_50::CameraPreset - a file rename and a type-string change in
CameraPresets.json, which surfaces under category 3.
- DO NOT read a field's disappearance as a removal without checking the same file for a
matching addition.
(3) Wire change
The field's name is unchanged and everything else about it moved. This is the category
a name-level reading cannot see - compare the whole field object.
- DO read
repeat exactly: an integer is a fixed count (array[T, N]), a
string is that length prefix (list[T]). "repeat": 16 becoming
"repeat": "uvarint32" turned SubChunkPacketPayload::HeightmapData's fixed rows into
length-prefixed lists.
- DO treat a union's
cases array gaining or losing an entry as a wire change - the
uvarint32 discriminator indexes that list in order, so appending is safe and inserting
renumbers every case after it. ServerboundPackSettingChangePacket's PackSettingValue
gained a fourth case.
- DO treat a change of the
type string, optional, or enum binding as a wire
change even when the field name is untouched.
- DO NOT ignore a nested
type object. The change can be one level down, inside the
element type of a repeat.
(4) Enum change
Five signatures in enums/*.json, each needing different DSL:
Appended values - value(N, since=<new>), with a trailing COUNT = auto() following
on its own.
A removed value, tail shifting down - value(N, until=<new>) on the one that went
and auto() for every member after it. The pool re-resolves auto() per snapshot, so
the shift falls out of the single gate. One version dropped
Memory::MemoryCategory::Persona_Textures and shifted fifty-odd members by one.
auto() absorbs an insertion and a removal in the same span, and they can cancel.
Between 944 and 975 that same enum gains Rendering_RenderRegistry = 60 and loses
VR = 68: everything from Rendering_Library to Textures shifts up one, and
WeatherRenderer lands on 69 at both eras. Written as two gates -
value(60, since=975) on the arrival, value(until=975) on the departure - with every
member between them auto(), both eras fall out and nothing else is touched. Reach for a
whole-enum redeclaration only when that fails.
A rename at the same value - wire-invisible for an int-coded enum, a wire change
for a name-coded one (field(type=str)) where the folded member name is the bytes.
BuildPlatform's Nx became Nintendo at 12. Check the encoding, then take the new
spelling at every era rather than versioning the enum: a gate propagates through
every referencing type for a rename, and a name-keyed mapping then fails to match across
the boundary and falls back to a sentinel.
A value gaining a name - not a shift, and not a new enumerator. The test is internal:
if no other member moved, the value was already there and merely unbound, so the dump's
binding changed rather than BDS. persona::AnimatedTextureType gained None = 0 while
Face, Body32x32 and Body128x128 kept 1, 2 and 3. Add it ungated - gating it would
claim the value did not exist before. Settle it in bedrock-headers, not the dump: the
header lists every enumerator whether cereal binds it or not, so
git show origin/android/r26_u3:<path> against r26_u4 says outright whether the value
is new. persona::PieceType gained Unknown = 0 and Unsupported = 28 between u3 and
u4, which reads like a renumber at both ends - but PersonaTypes.h at u3 already carried
the identical 29-value enum through Count = 29, so both were merely unbound and both are
ungated. Carry the sentinel over with them; it is the arithmetic that proves nothing moved.
A sentinel that disagrees with the members - check both arithmetics every time:
Δsentinel must equal added − removed, and the sentinel must equal the last named
member + 1. CurrentCmdVersion's Count went 51 → 53 and Latest 50 → 52 with the last
named member still at 44 and no new names in the diff. The absolute check is the one that
mattered - six had already been unnamed for a version before anyone noticed.
An unbound alias is noise; a bound sentinel on a name-coded enum is a gap. Both look
like "a value the dump has and the DSL does not", and they are not the same finding. Sort
them by how the referencing field encodes. MolangVersion's Latest and HardcodedMolang
are bound at every era but alias 13, and item_stack.py writes the enum
field(type=int16) - numeric, so carrying them changes nothing and they stay out.
CurrentCmdVersion's Latest is bound at every era too, but command.py writes that enum
field(type=str), so the folded name is the bytes: BDS puts "latest" on the wire and a
schema without the member cannot decode it. It is deliberately still absent, because its
value is era-dependent (49 at u3, 50 at u4, 53 at u6) while its name is not, and modelling
it means writing a number that is right for one era only. Revisit when a header branch above
1.26.40 lands. Until then: a name-coded enum missing a bound member is a decode hole, and
worth saying out loud rather than leaving to a later reader to rediscover.
DO give a real member an explicit numeric value.
DO reserve auto() for a count sentinel (Count, Latest, MAX_X, NumX) and for
a run shifting behind a gate. auto() means the previous member's value + 1 -- not the
maximum, and not the member count -- so what it needs is the line immediately above it
holding the right value, which is weaker than the whole run being dense. Rotation is the
case: NONE..ROTATE_270 then the CLOCKWISE_* aliases repeat 1, 2, 3, so the run is not
dense, yet TOTAL = auto() still lands on 4 because COUNTER_CLOCKWISE_90 = 3 precedes it.
Check the neighbour, not the histogram.
DO pin a sentinel with an explicit value(N, since=) and note the gap where the
arithmetic says BDS ships enumerators the dump does not bind. Nothing available to you
will name them, and auto() would silently lie about where the sentinel sits.
DO NOT assume the dump lists every enumerator. It lists the bound ones; a sentinel is
the only witness to the rest.
DO let a std::bitset<N> instantiation in the binary date the enum that sizes it. A
bitset[E.COUNT] field means E's sentinel is a wire width, so the writer's
?_Xoflo@?$bitset@$0HP@@std@@ ($0HP@ = 0x7F) pins ActorFlags::Count at 127 for 944 -
which is how the changelog was caught over-reporting four additions at 126..129 when 126
already existed. The binary outranks the changelog on arithmetic.
DO NOT assume a bound value is the right value. The dump can bind a wrong one, not
merely omit: at r26_u2 EAS::FloatAttributeOperation binds MINIMUM and MAXIMUM both to
4, colliding with MULTIPLY, while the header carries 5 and 6 byte-identically at both
eras. That reads exactly like a renumber and is a dumper bug. Settle a value in the
header before gating anything.
DO NOT treat the C++ enumerator's spelling as cosmetic. The reflected name table is that
spelling lowercased with no separator, and for a name-coded enum that table is the
wire - RebeccaPurple folds to rebeccapurple where REBECCA_PURPLE folded to
rebecca_purple, and BDS writes the former (test_077's golden matches automationplayer).
So changing how the enumerator is derived is a wire change, and enum_cast folds case but
not underscores.
DO NOT read a wholesale respelling as a rename. SharedTypes::Legacy::LevelSoundEvent
respells all ~563 members between two branches (ItemUseOn becomes item.use.on) because
the dumper switched to emitting the bound SoundEventIdentifier strings; the header keeps
the CamelCase spellings at both eras. The answer was a packet redeclaration for the field
that went int-coded to name-coded, not a versioned enum.
DO NOT redeclare an enum unless a wholesale renumbering gives one member two explicit
values.
DO NOT gate an enum member without following it out. The gate versions the enum, so
every test and consumer naming a member must then spell the snapshot it exercises
(bp::ContainerType_<2168>::...) or stop compiling.
Then model it
- DO use inline
field(since=N) / field(until=N) / field(type=) when a minority
of a type's fields moved.
- DO redeclare the whole class over adjacent ranges when the shape shifted - a field's
type changed, fields reordered or were renamed, or nearly all moved. Redeclarations tile:
same id, each
until meets the next since, only the last left open. The tiling is
enforced - a mismatched id, a gap, or an earlier declaration left open is an error, so a
bad tiling fails loudly rather than silently emitting one shape.
- DO land every gate on a materialized snapshot: a field the changelog dates to 2177
gates at the next snapshot at or after it.
- DO update
__version__ in protocol/__init__.py and the "Modelled today" line in
README.md.
- DO place a declaration by its BDS domain.
head -qn 4 protocol/*.py is the index,
grep -n "^Not " protocol/*.py the exclusions; a packet added to a module whose docstring
excludes it changes that docstring in the same commit.
- DO NOT gate a field inside a redeclared class - the resolver rejects it, and merges
every redeclaration onto the latest field order, so a reorder cannot be expressed by
field gates at all. Collapse them into one exact
when=.
- DO NOT spell
field(type=) on a plain enum field. Width, signedness and compression
follow the underlying type and already match the dump. It is earned only by str
(name-coded), a fixed primitive against a wider underlying, or endian="big".
4. Back-port: below 2168
The dump exists here but covers only the cerealised half, so the four are discovered from
three partial diffs instead of one - see Discovering the four. Which half a packet sits
in decides which of those diffs can see it, and presence in the dump at that version is the
test:
| in the dump |
cerealised - the dump is the wire, and a variant tag is always uvarint32 over cases in declaration order |
| absent |
hand-written - the dump cannot describe it and gives no hint. Go to the Marshal history |
| new file between two branches |
usually that packet cerealised there - the highest-risk change in the repo - but confirm it, see below |
A new dump file is not proof of a cerealisation. It can equally be the dumper's
coverage expanding. Mojang's legacy_changelogs names six conversions in the 976..1001
window - SubChunkRequest 979, BossEvent 984, InventoryTransaction 985,
MobArmorEquipment 988, ClientCacheBlobStatus 996, InventoryContent 1001 - while the dump
gained ten packet files there. The four extras (ResourcePackStack 7, UpdateAttributes
29, CommandBlockUpdate 78, UpdateAbilities 187) have no changelog entry at any version.
- DO cross the dump's file-appearance against the changelog before calling something a
cerealisation, and settle a disagreement by reading the era's
Marshal field-by-field
against the newer dump. For those four it comes out byte-identical either way, so the
question is academic there - but the inference is not sound in general.
- DO NOT try to settle it by grepping the binary for the cereal display strings. They are
present at both eras for packets the changelog says converted in between, so the probe
measures string presence, not cerealisation.
979 cerealising SubChunkRequestPacket moved center_pos behind the offsets, swapped the
offset count from fixed uint32 to uvarint32, and reshaped SubChunkPos from varint32
to fixed int32 - three wire breaks in one changelog line.
A cerealisation can also be size-identical. 996 moved each of
ClientCacheBlobStatusPacket's counts next to its own elements; both forms are the same 26
bytes for the same content, and an empty packet is 0x00 0x00 either way. Size proves
nothing about a reorder - see §5.
- DO check whether the two eras actually differ before splitting anything. A
cerealisation usually leaves most of the closure producing identical bytes:
InventoryTransactionPacket's pre-cereal form writes the transaction type and action
list itself, which lands as the same variant tag and leading InventoryTransaction the
cerealised form emits, so both eras share one TransactionData and only four types
needed redeclaring.
- DO NOT gate a type that merely became reachable. Gate what BDS version-stamped:
SharedTypes::v1_26_30::NoiseDescriptor is new and gates, while
SharedTypes::versionless::FloatRange only started being referenced and stays ungated.
Discovering the four
§3 gets all four categories out of one diff. Here no single diff sees everything, so you
run three - and you hold on to what each one can and cannot witness.
One step down, never a skip. The two eras are the target and the nearest snapshot the
schema already models above it - for 1001 that is 2168, never 975. A back-port is a delta
peeled off the step above, so the interval always runs from the target up to its nearest
modelled neighbour.
gophertunnel, between the two eras' commits. Date each era by its CurrentProtocol
bump, then diff the whole protocol tree between those commits, not just the packet you came
for.
git log -S'CurrentProtocol = <old>' --oneline -- minecraft/protocol/info.go
git log -S'CurrentProtocol = <new>' --oneline -- minecraft/protocol/info.go
git diff <old-sha>..<new-sha> -- minecraft/protocol/
-S returns two commits per number, the one that introduced the constant and the one
that removed it. Take the introducing commit from each pair.
That diff carries all four - the id list is category 1, a Marshal body 2 and 3, the
constant tables 4 - but it is the candidate set, not the changelog. The window holds three
kinds of commit and only one of them dates to the new version:
the bump itself - the real delta.
a retro-fix to the OLDER era - a codec correction landing after the old bump, which
describes what the old version always did. ServerboundDataDrivenScreenClosedPacket took
two inside the 1001..2168 window - CloseReason retyped, FormID made non-optional - and
CloudburstMC re-declares no serializer for it at 2168 because nothing changed at 2168.
Dating these by the interval gates a 1001 correction at 2168.
non-wire churn - allocation and codec-performance refactors, transport plumbing, doc
fixes. They touch writer.go and read like wire changes.
DO classify every commit in the window before attributing any of it. In 1001..2168
that is 15 commits: one bump, ~5 retro-fixes, 5 non-wire, 2 doc-only.
DO NOT read the interval diff as the changelog.
CloudburstMC, between the two codec directories. A serializer is re-declared under
codec/v<new>/ only when it changed - it otherwise just inherits ..._v<old> - so the
listing of that directory is the packet-level changelog, and Bedrock_v<new>.java against
Bedrock_v<old>.java gives the id registrations. It is a directory comparison at HEAD, not a
history walk.
Cross-check that listing against gophertunnel's, and mind how you compare:
- DO compare it against gophertunnel's whole
minecraft/protocol/ tree, never
packet/ alone. A Cloudburst serializer inlines the closure where gophertunnel splits it
into minecraft/protocol/*.go, so a packet/-only comparison manufactures phantom
disagreements at exactly the packets whose closure moved - CreativeContent,
ItemStackResponse and DimensionData all read as Cloudburst-only until creative.go,
item_stack.go and world.go are in the set.
- DO normalise the names before diffing the two lists. The refs do not spell packets
the same way, and a literal comparison reports correct code as missing:
- case - gophertunnel writes
ClientBoundMapItemData and ServerBoundDiagnostics,
Cloudburst writes Clientbound and Serverbound.
- vocabulary - Cloudburst says Entity where BDS says Actor, so its
MoveEntityDeltaPacket is MoveActorDeltaPacket; gophertunnel spells MobArmourEquipment
where BDS spells MobArmorEquipment.
Resolve every name to the BDS spelling before comparing anything - to either ref's list,
or to the schema. A raw set difference manufactures phantom "not modelled" findings.
Check the two dumps are the same generation before diffing them at all. The dumper
itself changes, and an older one prints the declared C++ width with no notion of cereal
compression, endianness or enum coding. Across 898 -> 924 that made 195 of 197 field
type diffs and 143 of 162 changed files pure rendering noise - int32->varint32,
uint32->uvarint32, int64->varint64, int16->varint32, int32->int32_be,
repeat: uvarint32->uint32, and string->uint8 on every enum-bound field. Modelling
any one of them is a wire break.
- DO probe the older dump before reading a single hunk: count
"varint32",
"uvarint64", _be, "repeat": "uint32", and enum-bound fields whose type is
"string". At r21_u13 those come back 0, 0, 0, 0, and 98-of-98 against 83, 9, 3, 2
and 11-of-82 at r26_u0 - a generation gap, not a protocol change.
- DO settle anything that survives the probe in bedrock-headers at the two branches, or
in the binary. A control helps:
LegacyTelemetryEventPacketPayload::ItemUsed keeps its
uncompressed int16 at both eras, proving the newer dumper does print a raw width where
cereal genuinely does not compress.
- DO NOT let a community codec talk you into an artifact. Both gophertunnel and
Cloudburst transcribed the 898 dumper's rendering of
TextPacket's enum-value name
bindings as a run of 12 constant strings on the wire; cerealizer<TextPacketPayload>::bind
in the 898 binary shows they are bindConstInternal entries that never reach it.
protocol-docs, as a hint - never as the diff. The dump describes only what is fully
cerealised at that version. A change inside a type BDS still writes by hand is not in it,
so an empty dump diff is not a finding and a dump hunk is not a measurement.
- DO check dump presence first, per packet. A packet present at both eras is
cerealised at both, so the dump is the wire for it and settles the question outright -
no ref adjudication needed.
InventoryContent, InventorySlot, MobEquipment and
MobArmorEquipment are all in the dump at 1001 and 2168, and the dump names the one real
change between them where reading two refs against each other only raised a suspicion.
- DO spend a dump hunk as a pointer where the packet is absent at either era: it says
which type is worth reading, and the reading then happens in bedrock-headers at the era's
branch and in the
Marshal history.
- DO NOT promote a dump hunk into a finding below 2168, and do not read dump silence as
"unchanged". They are the same laziness in opposite directions.
Mojang's own changelog, as a second hint. Mojang/bedrock-protocol-docs carries
legacy_changelogs/changelog_<protocol>_<MM_DD_YY>.md, one file per cycle from 407 to 2168.
It is the only source keyed by protocol number, and its entries are numbered by the
intermediate step, so it says which number inside the cycle a change actually landed on -
984: BossEventPacket : Converted to Cereal, broke binary compatibility dates that
cerealisation to 984, not to 1001. It also names renames in BDS member spelling:
993: LevelSoundEventPacket : mSoundEvent changed from LevelSoundEvent to SoundEventIdentifier.
Only the step-numbered half dates anything. A changelog file holds two things: a list
whose lines open with an intermediate protocol number, and a bare Added X / Removed X
summary. The step list is tight and barely overlaps its neighbours - 893 covers steps
860..897, 924 covers 894..924, 944 covers 925..944 - and it is the dating authority. The
bare summary is cumulative: it re-lists what earlier cycles already shipped, so an entry
appearing in it dates nothing at all. 156 of changelog_944's 178 bare entries (88%)
appear verbatim in changelog_924, and changelog_893 has 159 fewer of them than 924
because it carries none - it is step list only.
That one mechanism explains both standing over-reports. Added Lunge (41) and
Added ROTATION_LOCKED_TO_VEHICLE (126) sit in the bare summary of both the 924 and the
944 files, yet changelog_893's step list already dates them: 863: Added enchantment "Lunge" and 865: Added ActorFlags::ROTATION_LOCKED_TO_VEHICLE. Both predate the whole
1.26 line, and the binaries agree - LungeEnchant is in the 898 build and the 924 build
alike.
- DO read the step-numbered lines to find cerealisations stated outright rather than
inferred from a dump file appearing, and to date a change to its step - which is what "the
changelog dates it to N" means under Then model it, where the gate still lands on the
next materialized snapshot at or after N.
- DO check the step range at the top of the file before trusting any line in it, and
read the previous cycle's file when a step number falls below that range.
- DO NOT read a step number as "shipped in the release that reports N". A step is a
point in the next cycle's development, so it routinely names a number the previous line
already released under.
changelog_924's list opens at 894 and says
898: Added ClientboundDataDrivenUIShowScreenPacket and ClientboundDataDrivenUICloseAllScreensPacket
- yet 898 is the released 1.21.132.3, whose dump carries neither packet, whose header puts
EndId at 333, and whose binary has no class for either. Those ids arrive at 924. The
overlap is the tell: changelog_893 covers steps 860..897 and changelog_924 covers
894..924, so the same step number
…(truncated)
1---2name: add-protocol3description: Add a protocol version to the bedrock-protocol schema - a new version at or above 2168 (all-cereal, modelled from the protocol-docs dumps), a back-port below 2168 (mixed hand-written/cereal, modelled from gophertunnel and CloudburstMC history), or a renumber when a newer preview supersedes the one already modelled. Use when asked to "add protocol N", "support 1.26.x", "model the new network version", "follow 1.26.50.x to protocol N", "back-port 944", "support an older protocol", or when a new protocol-docs branch lands.4---56# Add a protocol version to the schema78`CLAUDE.md` is the law for *how* to spell a declaration. This is the workflow. Where they9disagree, CLAUDE.md wins.1011## The rule of four1213A protocol version changes exactly four things. Every one **MUST** be accounted for before14the work is done - not *should*, not *may*. A change you did not look for is a silent wire15break.16171. **Packet change** - a packet added or removed.182. **Type change** - a field added, removed, renamed or moved, in a type *or in a packet*,19 and a type itself added, removed or renamed.203. **Wire change** - the same field, encoded differently.214. **Enum change** - a value added, removed, renamed, shifted, or a sentinel moved.2223- **DO** walk all four explicitly and report what you found in each, including "nothing".24 **"Run it" and "dry run it" both mean all four**, every time - a partial pass is not a25 smaller version of this workflow, it is a wrong answer with a confident shape. Reporting26 three categories and calling the fourth "not yet walked" is the failure, not a caveat.27- **DO** fan out rather than defer when the four do not fit one pass. One agent per category,28 each given the interval, the four sources and the schema to check against, is the intended29 shape - the categories are independent by construction, so they parallelise exactly.30- **DO** treat a change to a packet the schema does **not** model as a finding, not a31 no-op. It is the most expensive miss there is: an unmodelled `CameraPresetsPacket` whose32 preset grew two fields relayed a short body and desynchronised a client mid-list, and a33 union case appended to an unmodelled `ServerboundPackSettingChangePacket` had nothing to34 be appended to. Model the packet.35- **DO NOT** stop at the first category with hits. They are independent, and 3 and 4 are36 the two a name-level reading cannot see.3738## The 2168 rule3940**Below 2168** BDS wrote packets through a *mixture* of hand-written41`write(BinaryStream&)` and the cereal auto-codec, and a packet could move between the two42at any release. Hand-written code branches on a type field, picks its own union tag widths,43and encodes a *shared* type differently per call site.4445**At and above 2168** everything is cereal: every field flat, in declaration order,46unconditional.4748That decides both the shape of the DSL and where the rule of four is discovered.4950| | forward (>= 2168) | back-port (< 2168) |51| --- | --- | --- |52| evidence | the protocol-docs dump, complete | the dump for the cerealised half only, gophertunnel/Cloudburst for the rest |53| DSL body | bare `name: Type` lines | `when=`, unions, `Literal[True]`, `field(type=)` |54| section | §3 | §4 |5556- **DO** treat annotation clutter in a `>=2168` body as a bug: you are modelling the57 pre-cereal shape.58- **DO NOT** assume a packet's pre-2168 shape from its cerealised one, or the reverse.5960### 2168 is the template6162Whichever direction you are going, the shape the schema already models at 2168 is the63baseline and the version you are adding is a delta against it. You are never modelling a64packet from scratch.6566- **DO** start from the declaration in `protocol/*.py` and establish only what the target67 version does *differently*. Forward, that delta lands on top of it; a back-port peels one68 off it, as `until=` or as an earlier redeclaration tiling under the same id.69- **DO** keep 2168's names, field order and type vocabulary except where the evidence names70 a change - see **Naming**.71- **DO NOT** re-derive a whole packet body from gophertunnel because the target is72 pre-cereal. The `Marshal` walk answers what differed *then*, not what the packet is.73- **DO NOT** let the template stand in for evidence. It is where you start reading and the74 spine the names hang on, never a claim about the older wire.7576## Sources7778**There are exactly two sources of truth, and everything else is a pointer.**7980| | source of truth |81| --- | --- |82| cerealised at that version | `github.com/EndstoneMC/protocol-docs`, the era's branch |83| hand-written at that version | **`<Packet>::write` decompiled in that era's IDA database** |8485| pointer - tells you *where to look*, never what is true | |86| --- | --- |87| `github.com/Sandertv/gophertunnel` | which bytes moved, and golden generation |88| `github.com/CloudburstMC/Protocol`, `Nukkit-MOT` | per-version dating |89| `Mojang/bedrock-protocol-docs`, `legacy_changelogs/` | which step inside a cycle |90| **bedrock-headers**, the era's branch | names, C++ types, member order - see **Naming** |9192- **DO** finish a pre-cereal reading in the binary. The IDA databases are already built, one per93 build, under `bedrock-symbols/<platform>/<build>/`; `<Packet>::write` decompiles in seconds94 and BDS labels every field itself, because each write carries its own name string.95- **DO NOT** take a *type* from gophertunnel any more than a name. It is a Go codec's96 modelling, not BDS's, and it will merge or split members freely.97- **DO NOT** read one write call as one field. **The binary settles the bytes; the header98 settles how many members produced them.** BDS packs adjacent members into a single call:99 `BossEventPacket::write` at 975 emits one `writeUnsignedShort` labelled `"Darken Screen"`,100 and the header at `android/r26_u2` declares `byte mDarkenScreen` **and** `byte101 mCreateWorldFog` - two members, little-endian, one call. Reading the writer alone concludes102 one `uint16` and loses a field BDS really has; reading the header alone never sees that they103 ship in one call. Use both, every time.104- **DO NOT** conclude a field is wrong because the era you are back-porting to lacks it. It may105 be right at the *other* era, which makes it two models rather than a fix - the pair above106 exists at 975 and is gone from the cereal form at 1001, so `until=1001` carries it and107 `since=1001` does not.108109Commands below assume you have each repo cloned locally; substitute your own paths. If one110is missing, **clone it** rather than working around its absence - CloudburstMC needs only111`--depth 1`, since every version's codec directory lives at HEAD.112113`bedrock-headers` is access-restricted. Read it, cite the branch you read, and paste none114of it anywhere - see **Naming**.115116### Naming117118**bedrock-headers** is the naming authority, per `CLAUDE.md`'s sources table, and it earns119no `# TODO`. It is branched per release and read without a checkout -120`git show origin/android/r26_u4:<path>` - from `android/r21_u4` (~786) up to121`android/r26_u4` (1.26.40, 2168). Read `origin/<branch>`: a local branch goes stale, and a122stale header does not read as stale, it reads as a finding.123124**Header coverage stops at 2168**, which is exactly where the forward work starts. So a125back-port is named from the header, and a forward version is named heuristically for126whatever is new above it.127128- **DO** settle a back-port name on the era's branch - a type's shape differs across them -129 and say which branch you read in the commit message.130- **DO** treat a `uN` as one update line with one wire shape. The same `uN` names different131 builds in different repos - bedrock-headers' `android/r26_u3` is 1.26.32, protocol-docs'132 `r26_u3` is 1.26.36.1 - and they still agree on the wire, so the era's branch and the133 era's binary are valid arbiters for each other.134- **DO** reach for the newest branch on forward work too. It cannot describe a field added135 after 2168, but it settles anything that already existed at it: an enum carries its full136 member list there whether or not cereal binds it.137- **DO** keep the name already in the DSL when the dump labels the same field differently.138 The dump's labels are cereal display strings, not member names, and they drift. Renaming139 to chase a label churns every consumer for nothing.140- **DO** convert a genuinely new label into `snake_case` that reads like its neighbours -141 `"Bypass Listener Range Check"` becomes `bypass_listener_range_check`, `"FurnaceType"`142 becomes `furnace_type`. Enum members take the same treatment in `UPPER_CASE`.143- **DO** follow the module's existing vocabulary where the label is awkward. A name that144 matches the surrounding declarations beats a literal transcription.145- **DO NOT** take a name from gophertunnel or CloudburstMC. They date and shape symbols;146 they do not name them, and they carry known-wrong names.147- **DO NOT** rename an existing field as a side effect of a version bump. If a label change148 turns out to be a real BDS rename, that is category 2 and lands as a redeclaration on its149 own evidence, not because the dump's wording moved.150- **DO NOT** paste bedrock-headers source into the schema, a commit message or this file.151 Reading it to settle a name is the authorized use; reproducing it is not. Cite the branch152 instead.153154## 1. Establish the number155156Each protocol-docs branch is named `r<minor>_u<patch/10>` by the dumper, and its README157names the Minecraft build, the channel and the network version. Branches run back past 898.158159- **DO** read the network version out of the dump itself, which is where the dumper got it:160 `packets/RequestNetworkSettingsPacket.json`, `fields[0].constraints.minimum`.161- **DO** pin the SHA you diff and quote it in the commit message. A branch is a moving head162 that reruns against the newest BDS on its update line, so its README number changes under163 you.164- **DO** record the *build*, not just the number - 1.26.43 and 1.26.44 both say 2168 and165 disagree on the wire.166- **DO** gate at the **first** protocol number of the cycle. A release often bumps twice,167 beta then release, with one wire shape, and the community codecs carry only the second:168 1.21.130 shipped 897 then 898, and the schema gates `since=897`.169 `AnimateSerializer_v898.java` is a codec-dir name, not a `since=` value.170- **DO** cross-check the number against171 `raw.githubusercontent.com/EndstoneMC/bedrock-server-data/v2/versions.json` and172 minecraft.wiki's *Protocol version* page.173- **DO NOT** model a number that appears in none of them - stop and ask.174175### When the new number supersedes a modelled preview176177**One protocol per update line.** The preview channel renumbers repeatedly inside one178update - 2181, then 2187, then 2192 for 1.26.50 - and nobody runs a superseded preview. A179newer number on the same update line *replaces* the older one; it never becomes a second180snapshot beside it.181182- **DO** renumber every gate the superseded preview carried, then model the new dump's183 delta on top. Both halves are one commit.184185```shell186sed -i 's/\b<old>\b/<new>/g' protocol/*.py187sed -i 's/\b<old>\b/<new>/g; s/v<old>/v<new>/g; s/V<old>/V<new>/g' tests/*.cpp188grep -rn "<old>" protocol/ tests/ README.md # must come back empty189```190191- **DO** hand-edit `__version__` in `protocol/__init__.py` and README's "Modelled today"192 line; the sed does not reach them meaningfully.193- **DO NOT** trust `\b<old>\b` to catch `v<old>` - there is no word boundary after a194 letter, so namespace spellings survive. Grep for the bare number *and* `v<number>`.195- **DO NOT** keep the superseded number as a snapshot of its own. It buys nothing and costs196 a namespace in every versioned type.197198## 2. Snapshots199200There is no version registry. `_snapshot_points` in201`src/bedrock_protocol/compiler/descriptor_pool.py` takes `{0}` plus every change point202every versioned type declares, unioned transitively across imports. The first `since=` /203`until=` you write materializes the version: `base` (the floor, claiming validity from 0),204then one `vNNNN` namespace per change point.205206- **DO** diff a packet's whole transitive type closure before modelling it. A new branch is207 not evidence of change - one landed whose network version had moved while packet 122's208 entire closure stayed byte-identical.209- **DO** accept transitive versioning as the price: one field on210 `ItemUseInventoryTransaction` re-emits `TransactionData`, `InventoryTransactionPacket`,211 `PackedItemUseLegacyInventoryTransaction` and `PlayerAuthInputPacket` at the new snapshot.212- **DO NOT** materialize a snapshot the evidence does not force.213- **DO NOT** fold a real change into an existing snapshot to avoid the transitive cost.214215## 3. Forward: at or above 2168216217The dump is complete here, so **the diff between two protocol-docs branches is the whole218rule of four**. Read it on GitHub -219`github.com/EndstoneMC/protocol-docs/compare/<old>..<new>` - or locally:220221```shell222git fetch origin223git diff --name-status -M origin/<old> origin/<new>224git diff -U1 -M origin/<old> origin/<new>225```226227- **DO** use the two-dot form `<old>..<new>`. The branches are generated independently and228 share no merge base, so the three-dot compare GitHub defaults to is meaningless.229- **DO** pass `-M`. Without it a renamed declaration reads as an unrelated delete plus add,230 and `--diff-filter=AD` misses it entirely.231232The dump's shape: `packets/*.json` is `{id, name, fields[]}`, `types/*.json` is233`{name, fields[]}`, `enums/*.json` is `{name, values[{name, value}]}`. A field is234`{name, type}` plus any of `enum`, `repeat`, `optional`, `value`, `constraints`.235236A `constraints` block (`min_length`, `max_length`, `enum_values`, `minimum`, `maximum`,237`description`) is **not wire** and comes and goes in sweeps between builds; the sole238exception is `RequestNetworkSettingsPacket`'s, which is the version.239240- **DO** classify hunk by hunk, and let each land in exactly one of the four categories.241- **DO NOT** write a diff off as constraint churn in bulk. One version was landed on242 exactly that reading and the sub-chunk height map's `repeat` change went with it - which243 is the byte a 1.26.50 client stops on.244245### (1) Packet change246247**Each `packets/*.json`'s own `id` field is the authority.** `enums/MinecraftPacketIds.json`248is a convenience index and Mojang routinely forgets to bind values in it - r26_u6 ships249`ServerboundStonecutterSetRecipePacket` (354) and `ClientboundStonecutterSetRecipePacket`250(355) as dumped packets with no entry in that enum at all.251252- **DO** take the id from the packet file. A packet added arrives as a new `packets/*.json`253 carrying its id; a packet removed takes its file with it.254- **DO** diff `MinecraftPacketIds.json` too, as a second view - it names ids the dump has no255 file for, which are packets that are not cerealised.256- **DO** reconcile the two as a **union**, never an intersection. Either source can miss a257 packet, and each miss is a packet on the wire that nothing decodes.258- **DO** treat an id that *moved* as a break for the packet that took it, not a rename.259- **DO** model the body in the same commit as the id. `MinecraftPacketIds` in260 `protocol/network.py` lists ids the schema does not model, which is a standing backlog -261 but an id *you* gate `since=<new>` with no `@packet` body puts that id on the wire with262 nothing able to decode it.263- **DO NOT** treat the id enum as the packet list. It is the source that silently omits.264265### An id with no packet behind it266267BDS keeps an id in `MinecraftPacketIds` long after the class is gone, so an entry there is268not evidence a packet exists. Ask the question directly before modelling one - and before269concluding a packet is missing from the schema.270271- **DO** answer it from **bedrock-headers**: a live packet has a `<Name>Packet.h`, and the272 name in the id enum drops its `_DEPRECATED` / `_deprecated` / `_Deprecated` suffix to give273 it. Of the 22 ids the schema left unmodelled at 924, **all 22** had no class header at any274 branch from `r21_u4` to `r26_u4`, and the binaries agreed.275- **DO** run live packets through the same probe as controls in the same command. A probe276 that quietly matches nothing looks identical to a true negative, and the controls are what277 tell the two apart - `DebugDrawer`, `Text`, `Login`, `StartGame`, `PlayerSkin` all resolve.278- **DO NOT** use `??$make_packet@V<Name>Packet@@` as the packet list. It is instantiated for279 one construction path only - 178 of 240 ids at 924 - and omits `LoginPacket`,280 `TextPacket` and `StartGamePacket` among others, so reading absence there as "removed"281 invents dozens of false deprecations.282- **DO NOT** read `?getId@<Name>Packet@@` as universal either. It resolves for283 `DebugDrawerPacket` and not for `TextPacket` in the same stripped 924 build.284- **DO NOT** date the removal from the enum name. `ScriptCustomEvent` (117) carries no285 deprecation suffix and has had no class for the entire range these sources cover.286287### (2) Type change288289Field entries appearing or vanishing in a `fields[]` array, and whole files arriving,290leaving or being renamed.291292- **DO** compare fields as an **ordered** sequence. Cereal is positional, so a field293 inserted mid-struct moves everything after it - `TextDataPayload` gained `LineGapHeight`294 in front of `DepthTest`, not at the end.295- **DO** read a delete-plus-add at the same position with the same type as a **rename**,296 and model it as a class redeclaration; field gates cannot restate a field under a new297 name. `DimensionDefinition` renamed `Height Maximum`/`Height Minimum` to298 `Minimum Y`/`Height Range`, which also inverted what the numbers mean - a rename can299 carry a semantic change the wire types hide.300- **DO** read a vanishing `{"type": "bool", "value": true}` entry as BDS **fixing a cereal301 always-true marker**. Gate that one `Literal[True]` field `until=<new>`; nothing else in302 the type moves. They go in waves - one version dropped twelve across303 `PlayerAuthInputPacket`, `InventoryTransactionPacket`, `InventorySource`,304 `InventoryTransaction`, `ItemStackResponseInfo`, `ItemStackResponseSlotInfo` and305 `PackedItemUseLegacyInventoryTransaction`.306- **DO** follow a renamed type into every file that references it. BDS versions some type307 namespaces, so `SharedTypes::v1_21_90::CameraPreset` became308 `SharedTypes::v1_26_50::CameraPreset` - a file rename *and* a type-string change in309 `CameraPresets.json`, which surfaces under category 3.310- **DO NOT** read a field's disappearance as a removal without checking the same file for a311 matching addition.312313### (3) Wire change314315The field's `name` is unchanged and everything else about it moved. **This is the category316a name-level reading cannot see** - compare the whole field object.317318- **DO** read `repeat` exactly: an **integer** is a fixed count (`array[T, N]`), a319 **string** is that length prefix (`list[T]`). `"repeat": 16` becoming320 `"repeat": "uvarint32"` turned `SubChunkPacketPayload::HeightmapData`'s fixed rows into321 length-prefixed lists.322- **DO** treat a union's `cases` array gaining or losing an entry as a wire change - the323 `uvarint32` discriminator indexes that list in order, so appending is safe and inserting324 renumbers every case after it. `ServerboundPackSettingChangePacket`'s `PackSettingValue`325 gained a fourth case.326- **DO** treat a change of the `type` string, `optional`, or `enum` binding as a wire327 change even when the field name is untouched.328- **DO NOT** ignore a nested `type` object. The change can be one level down, inside the329 element type of a repeat.330331### (4) Enum change332333Five signatures in `enums/*.json`, each needing different DSL:334335- **Appended values** - `value(N, since=<new>)`, with a trailing `COUNT = auto()` following336 on its own.337- **A removed value, tail shifting down** - `value(N, until=<new>)` on the one that went338 and `auto()` for every member after it. The pool re-resolves `auto()` per snapshot, so339 the shift falls out of the single gate. One version dropped340 `Memory::MemoryCategory::Persona_Textures` and shifted fifty-odd members by one.341 **`auto()` absorbs an insertion and a removal in the same span, and they can cancel.**342 Between 944 and 975 that same enum gains `Rendering_RenderRegistry = 60` *and* loses343 `VR = 68`: everything from `Rendering_Library` to `Textures` shifts up one, and344 `WeatherRenderer` lands on 69 at **both** eras. Written as two gates -345 `value(60, since=975)` on the arrival, `value(until=975)` on the departure - with every346 member between them `auto()`, both eras fall out and nothing else is touched. Reach for a347 whole-enum redeclaration only when that fails.348- **A rename at the same value** - wire-invisible for an int-coded enum, a **wire change**349 for a name-coded one (`field(type=str)`) where the folded member name *is* the bytes.350 `BuildPlatform`'s `Nx` became `Nintendo` at 12. Check the encoding, then take the new351 spelling at **every** era rather than versioning the enum: a gate propagates through352 every referencing type for a rename, and a name-keyed mapping then fails to match across353 the boundary and falls back to a sentinel.354- **A value gaining a name** - not a shift, and not a new enumerator. The test is internal:355 if no other member moved, the value was already there and merely unbound, so the dump's356 *binding* changed rather than BDS. `persona::AnimatedTextureType` gained `None = 0` while357 `Face`, `Body32x32` and `Body128x128` kept 1, 2 and 3. Add it ungated - gating it would358 claim the value did not exist before. **Settle it in bedrock-headers, not the dump**: the359 header lists every enumerator whether cereal binds it or not, so360 `git show origin/android/r26_u3:<path>` against `r26_u4` says outright whether the value361 is new. `persona::PieceType` gained `Unknown = 0` *and* `Unsupported = 28` between u3 and362 u4, which reads like a renumber at both ends - but `PersonaTypes.h` at u3 already carried363 the identical 29-value enum through `Count = 29`, so both were merely unbound and both are364 ungated. Carry the sentinel over with them; it is the arithmetic that proves nothing moved.365- **A sentinel that disagrees with the members** - check both arithmetics every time:366 `Δsentinel` must equal `added − removed`, **and** the sentinel must equal the last named367 member + 1. `CurrentCmdVersion`'s `Count` went 51 → 53 and `Latest` 50 → 52 with the last368 named member still at 44 and no new names in the diff. The absolute check is the one that369 mattered - six had already been unnamed for a version before anyone noticed.370- **An unbound alias is noise; a bound sentinel on a name-coded enum is a gap.** Both look371 like "a value the dump has and the DSL does not", and they are not the same finding. Sort372 them by how the referencing field encodes. `MolangVersion`'s `Latest` and `HardcodedMolang`373 are bound at every era but alias 13, and `item_stack.py` writes the enum374 `field(type=int16)` - numeric, so carrying them changes nothing and they stay out.375 `CurrentCmdVersion`'s `Latest` is bound at every era too, but `command.py` writes that enum376 `field(type=str)`, so the folded name *is* the bytes: BDS puts `"latest"` on the wire and a377 schema without the member cannot decode it. It is deliberately still absent, because its378 value is era-dependent (49 at u3, 50 at u4, 53 at u6) while its name is not, and modelling379 it means writing a number that is right for one era only. Revisit when a header branch above380 1.26.40 lands. Until then: **a name-coded enum missing a bound member is a decode hole, and381 worth saying out loud rather than leaving to a later reader to rediscover.**382383- **DO** give a real member an explicit numeric value.384- **DO** reserve `auto()` for a count sentinel (`Count`, `Latest`, `MAX_X`, `NumX`) and for385 a run shifting behind a gate. `auto()` means *the previous member's value* + 1 -- not the386 maximum, and not the member count -- so what it needs is the line immediately above it387 holding the right value, which is weaker than the whole run being dense. `Rotation` is the388 case: `NONE`..`ROTATE_270` then the `CLOCKWISE_*` aliases repeat 1, 2, 3, so the run is not389 dense, yet `TOTAL = auto()` still lands on 4 because `COUNTER_CLOCKWISE_90 = 3` precedes it.390 Check the neighbour, not the histogram.391- **DO** pin a sentinel with an explicit `value(N, since=)` and note the gap where the392 arithmetic says BDS ships enumerators the dump does not bind. Nothing available to you393 will name them, and `auto()` would silently lie about where the sentinel sits.394- **DO NOT** assume the dump lists every enumerator. It lists the bound ones; a sentinel is395 the only witness to the rest.396- **DO** let a `std::bitset<N>` instantiation in the binary date the enum that sizes it. A397 `bitset[E.COUNT]` field means `E`'s sentinel *is* a wire width, so the writer's398 `?_Xoflo@?$bitset@$0HP@@std@@` (`$0HP@` = 0x7F) pins `ActorFlags::Count` at **127** for 944 -399 which is how the changelog was caught over-reporting four additions at 126..129 when 126400 already existed. The binary outranks the changelog on arithmetic.401- **DO NOT** assume a bound value is the *right* value. The dump can bind a wrong one, not402 merely omit: at r26_u2 `EAS::FloatAttributeOperation` binds `MINIMUM` and `MAXIMUM` both to403 4, colliding with `MULTIPLY`, while the header carries 5 and 6 **byte-identically at both404 eras**. That reads exactly like a renumber and is a dumper bug. Settle a value in the405 header before gating anything.406- **DO NOT** treat the C++ enumerator's spelling as cosmetic. The reflected name table is that407 spelling lowercased with **no separator**, and for a name-coded enum that table *is* the408 wire - `RebeccaPurple` folds to `rebeccapurple` where `REBECCA_PURPLE` folded to409 `rebecca_purple`, and BDS writes the former (`test_077`'s golden matches `automationplayer`).410 So changing how the enumerator is derived is a wire change, and `enum_cast` folds case but411 not underscores.412- **DO NOT** read a wholesale respelling as a rename. `SharedTypes::Legacy::LevelSoundEvent`413 respells all ~563 members between two branches (`ItemUseOn` becomes `item.use.on`) because414 the *dumper* switched to emitting the bound `SoundEventIdentifier` strings; the header keeps415 the CamelCase spellings at both eras. The answer was a packet redeclaration for the field416 that went int-coded to name-coded, not a versioned enum.417- **DO NOT** redeclare an enum unless a wholesale renumbering gives one member two explicit418 values.419- **DO NOT** gate an enum member without following it out. The gate versions the enum, so420 every test and consumer naming a member must then spell the snapshot it exercises421 (`bp::ContainerType_<2168>::...`) or stop compiling.422423### Then model it424425- **DO** use inline `field(since=N)` / `field(until=N)` / `field(type=)` when a *minority*426 of a type's fields moved.427- **DO** redeclare the whole class over adjacent ranges when the shape shifted - a field's428 type changed, fields reordered or were renamed, or nearly all moved. Redeclarations tile:429 same id, each `until` meets the next `since`, only the last left open. The tiling is430 enforced - a mismatched id, a gap, or an earlier declaration left open is an error, so a431 bad tiling fails loudly rather than silently emitting one shape.432- **DO** land every gate on a materialized snapshot: a field the changelog dates to 2177433 gates at the next snapshot at or after it.434- **DO** update `__version__` in `protocol/__init__.py` and the "Modelled today" line in435 `README.md`.436- **DO** place a declaration by its BDS domain. `head -qn 4 protocol/*.py` is the index,437 `grep -n "^Not " protocol/*.py` the exclusions; a packet added to a module whose docstring438 excludes it changes that docstring in the same commit.439- **DO NOT** gate a field inside a redeclared class - the resolver rejects it, and merges440 every redeclaration onto the *latest* field order, so a reorder cannot be expressed by441 field gates at all. Collapse them into one exact `when=`.442- **DO NOT** spell `field(type=)` on a plain enum field. Width, signedness and compression443 follow the underlying type and already match the dump. It is earned only by `str`444 (name-coded), a fixed primitive against a wider underlying, or `endian="big"`.445446## 4. Back-port: below 2168447448The dump exists here but covers only the cerealised half, so the four are discovered from449three partial diffs instead of one - see **Discovering the four**. Which half a packet sits450in decides which of those diffs can see it, and presence in the dump at that version is the451test:452453| in the dump | cerealised - the dump is the wire, and a variant tag is **always `uvarint32`** over cases in declaration order |454| --- | --- |455| **absent** | hand-written - the dump cannot describe it and gives no hint. Go to the Marshal history |456| **new file between two branches** | usually that packet *cerealised* there - the highest-risk change in the repo - but confirm it, see below |457458**A new dump file is not proof of a cerealisation.** It can equally be the *dumper's*459coverage expanding. Mojang's `legacy_changelogs` names six conversions in the 976..1001460window - `SubChunkRequest` 979, `BossEvent` 984, `InventoryTransaction` 985,461`MobArmorEquipment` 988, `ClientCacheBlobStatus` 996, `InventoryContent` 1001 - while the dump462gained **ten** packet files there. The four extras (`ResourcePackStack` 7, `UpdateAttributes`46329, `CommandBlockUpdate` 78, `UpdateAbilities` 187) have no changelog entry at any version.464465- **DO** cross the dump's file-appearance against the changelog before calling something a466 cerealisation, and settle a disagreement by reading the era's `Marshal` field-by-field467 against the newer dump. For those four it comes out byte-identical either way, so the468 question is academic *there* - but the inference is not sound in general.469- **DO NOT** try to settle it by grepping the binary for the cereal display strings. They are470 present at both eras for packets the changelog says converted in between, so the probe471 measures string presence, not cerealisation.472473979 cerealising `SubChunkRequestPacket` moved `center_pos` behind the offsets, swapped the474offset count from fixed `uint32` to `uvarint32`, and reshaped `SubChunkPos` from `varint32`475to fixed `int32` - three wire breaks in one changelog line.476477A cerealisation can also be **size-identical**. 996 moved each of478`ClientCacheBlobStatusPacket`'s counts next to its own elements; both forms are the same 26479bytes for the same content, and an empty packet is `0x00 0x00` either way. Size proves480nothing about a reorder - see §5.481482- **DO** check whether the two eras actually differ before splitting anything. A483 cerealisation usually leaves most of the closure producing identical bytes:484 `InventoryTransactionPacket`'s pre-cereal form writes the transaction type and action485 list itself, which lands as the same variant tag and leading `InventoryTransaction` the486 cerealised form emits, so both eras share one `TransactionData` and only four types487 needed redeclaring.488- **DO NOT** gate a type that merely became *reachable*. Gate what BDS version-stamped:489 `SharedTypes::v1_26_30::NoiseDescriptor` is new and gates, while490 `SharedTypes::versionless::FloatRange` only started being referenced and stays ungated.491492### Discovering the four493494§3 gets all four categories out of one diff. Here no single diff sees everything, so you495run three - and you hold on to what each one can and cannot witness.496497**One step down, never a skip.** The two eras are the target and the nearest snapshot the498schema *already models above it* - for 1001 that is 2168, never 975. A back-port is a delta499peeled off the step above, so the interval always runs from the target up to its nearest500modelled neighbour.501502**gophertunnel, between the two eras' commits.** Date each era by its `CurrentProtocol`503bump, then diff the whole protocol tree between those commits, not just the packet you came504for.505506```shell507git log -S'CurrentProtocol = <old>' --oneline -- minecraft/protocol/info.go508git log -S'CurrentProtocol = <new>' --oneline -- minecraft/protocol/info.go509git diff <old-sha>..<new-sha> -- minecraft/protocol/510```511512`-S` returns **two** commits per number, the one that introduced the constant and the one513that removed it. Take the **introducing** commit from each pair.514515That diff carries all four - the id list is category 1, a `Marshal` body 2 and 3, the516constant tables 4 - but it is the *candidate set*, not the changelog. The window holds three517kinds of commit and only one of them dates to the new version:518519- **the bump itself** - the real delta.520- **a retro-fix to the OLDER era** - a codec correction landing after the old bump, which521 describes what the old version always did. `ServerboundDataDrivenScreenClosedPacket` took522 two inside the 1001..2168 window - `CloseReason` retyped, `FormID` made non-optional - and523 CloudburstMC re-declares no serializer for it at 2168 because nothing changed at 2168.524 Dating these by the interval gates a 1001 correction at 2168.525- **non-wire churn** - allocation and codec-performance refactors, transport plumbing, doc526 fixes. They touch `writer.go` and read like wire changes.527528- **DO** classify every commit in the window before attributing any of it. In 1001..2168529 that is 15 commits: one bump, ~5 retro-fixes, 5 non-wire, 2 doc-only.530- **DO NOT** read the interval diff as the changelog.531532**CloudburstMC, between the two codec directories.** A serializer is re-declared under533`codec/v<new>/` only when it changed - it otherwise just inherits `..._v<old>` - so the534listing of that directory *is* the packet-level changelog, and `Bedrock_v<new>.java` against535`Bedrock_v<old>.java` gives the id registrations. It is a directory comparison at HEAD, not a536history walk.537538Cross-check that listing against gophertunnel's, and mind how you compare:539540- **DO** compare it against gophertunnel's **whole `minecraft/protocol/` tree**, never541 `packet/` alone. A Cloudburst serializer inlines the closure where gophertunnel splits it542 into `minecraft/protocol/*.go`, so a `packet/`-only comparison manufactures phantom543 disagreements at exactly the packets whose *closure* moved - `CreativeContent`,544 `ItemStackResponse` and `DimensionData` all read as Cloudburst-only until `creative.go`,545 `item_stack.go` and `world.go` are in the set.546- **DO** normalise the names before diffing the two lists. **The refs do not spell packets547 the same way**, and a literal comparison reports correct code as missing:548 - case - gophertunnel writes `ClientBoundMapItemData` and `ServerBoundDiagnostics`,549 Cloudburst writes `Clientbound` and `Serverbound`.550 - vocabulary - Cloudburst says **Entity** where BDS says **Actor**, so its551 `MoveEntityDeltaPacket` is `MoveActorDeltaPacket`; gophertunnel spells `MobArmourEquipment`552 where BDS spells `MobArmorEquipment`.553 Resolve every name to the **BDS** spelling before comparing anything - to either ref's list,554 or to the schema. A raw set difference manufactures phantom "not modelled" findings.555556**Check the two dumps are the same generation before diffing them at all.** The dumper557itself changes, and an older one prints the *declared C++ width* with no notion of cereal558compression, endianness or enum coding. Across 898 -> 924 that made **195 of 197** field559`type` diffs and 143 of 162 changed files pure rendering noise - `int32`->`varint32`,560`uint32`->`uvarint32`, `int64`->`varint64`, `int16`->`varint32`, `int32`->`int32_be`,561`repeat: uvarint32`->`uint32`, and `string`->`uint8` on every enum-bound field. Modelling562any one of them is a wire break.563564- **DO** probe the older dump before reading a single hunk: count `"varint32"`,565 `"uvarint64"`, `_be`, `"repeat": "uint32"`, and enum-bound fields whose type is566 `"string"`. At `r21_u13` those come back **0, 0, 0, 0, and 98-of-98** against 83, 9, 3, 2567 and 11-of-82 at `r26_u0` - a generation gap, not a protocol change.568- **DO** settle anything that survives the probe in bedrock-headers at the two branches, or569 in the binary. A control helps: `LegacyTelemetryEventPacketPayload::ItemUsed` keeps its570 uncompressed `int16` at *both* eras, proving the newer dumper does print a raw width where571 cereal genuinely does not compress.572- **DO NOT** let a community codec talk you into an artifact. Both gophertunnel and573 Cloudburst transcribed the 898 dumper's rendering of `TextPacket`'s enum-value name574 bindings as a run of 12 constant strings on the wire; `cerealizer<TextPacketPayload>::bind`575 in the 898 binary shows they are `bindConstInternal` entries that never reach it.576577**protocol-docs, as a hint - never as the diff.** The dump describes only what is fully578cerealised at that version. A change inside a type BDS still writes by hand is not in it,579so an empty dump diff is not a finding and a dump hunk is not a measurement.580581- **DO** check dump presence *first*, per packet. A packet present at **both** eras is582 cerealised at both, so the dump **is** the wire for it and settles the question outright -583 no ref adjudication needed. `InventoryContent`, `InventorySlot`, `MobEquipment` and584 `MobArmorEquipment` are all in the dump at 1001 and 2168, and the dump names the one real585 change between them where reading two refs against each other only raised a suspicion.586- **DO** spend a dump hunk as a *pointer* where the packet is absent at either era: it says587 which type is worth reading, and the reading then happens in bedrock-headers at the era's588 branch and in the `Marshal` history.589- **DO NOT** promote a dump hunk into a finding below 2168, and do not read dump silence as590 "unchanged". They are the same laziness in opposite directions.591592**Mojang's own changelog, as a second hint.** `Mojang/bedrock-protocol-docs` carries593`legacy_changelogs/changelog_<protocol>_<MM_DD_YY>.md`, one file per cycle from 407 to 2168.594It is the only source **keyed by protocol number**, and its entries are numbered by the595*intermediate* step, so it says which number inside the cycle a change actually landed on -596`984: BossEventPacket : Converted to Cereal, broke binary compatibility` dates that597cerealisation to 984, not to 1001. It also names renames in BDS member spelling:598`993: LevelSoundEventPacket : mSoundEvent changed from LevelSoundEvent to SoundEventIdentifier`.599600**Only the step-numbered half dates anything.** A changelog file holds two things: a list601whose lines open with an intermediate protocol number, and a bare `Added X` / `Removed X`602summary. The step list is tight and barely overlaps its neighbours - 893 covers steps603860..897, 924 covers 894..924, 944 covers 925..944 - and it is the dating authority. The604bare summary is **cumulative**: it re-lists what earlier cycles already shipped, so an entry605appearing in it dates nothing at all. 156 of `changelog_944`'s 178 bare entries (**88%**)606appear verbatim in `changelog_924`, and `changelog_893` has 159 fewer of them than `924`607because it carries **none** - it is step list only.608609That one mechanism explains both standing over-reports. `Added Lunge (41)` and610`Added ROTATION_LOCKED_TO_VEHICLE (126)` sit in the bare summary of *both* the 924 and the611944 files, yet `changelog_893`'s step list already dates them: `863: Added enchantment612"Lunge"` and `865: Added ActorFlags::ROTATION_LOCKED_TO_VEHICLE`. Both predate the whole6131.26 line, and the binaries agree - `LungeEnchant` is in the 898 build and the 924 build614alike.615616- **DO** read the step-numbered lines to find cerealisations stated outright rather than617 inferred from a dump file appearing, and to date a change to its step - which is what "the618 changelog dates it to N" means under **Then model it**, where the gate still lands on the619 next materialized snapshot at or after N.620- **DO** check the step range at the top of the file before trusting any line in it, and621 read the *previous* cycle's file when a step number falls below that range.622- **DO NOT** read a step number as "shipped in the release that reports N". A step is a623 point in the *next* cycle's development, so it routinely names a number the previous line624 already released under. `changelog_924`'s list opens at 894 and says625 `898: Added ClientboundDataDrivenUIShowScreenPacket and ClientboundDataDrivenUICloseAllScreensPacket`626 - yet 898 is the released 1.21.132.3, whose dump carries neither packet, whose header puts627 `EndId` at 333, and whose binary has no class for either. Those ids arrive at 924. The628 overlap is the tell: `changelog_893` covers steps 860..897 and `changelog_924` covers629 894..924, so the same step number630631…(truncated)