Entity and tick re-architecture: one entity model, one tick pipeline
Status: in progress. The decisions were taken on 2026-11-10 and this plan was written the same day. Counts in this page are from that day. Slices 0 to 2 have landed on the program branch (t3/subagent-architecture-review, pull request #2948), and slices 3 to 5 were in progress when work moved to another machine on 2026-11-10. The handoff says where each package stands and how to pick it up.
The server runs two simulations of the same world and reconciles them every tick. Every data-driven mob lives once in EntityRuntime and a second time as a legacy body in the world's entity map, and health crosses between the two by comparison. Two entity indexes answer the same question under opposite rules. A tick awaits at 233 points, so a packet handler can change a player in the middle of it. This plan replaces all of that with one entity store, one index, a synchronous tick, and replication that reads a change log.
The decisions are in ADRs 0006 to 0018. This page holds what does not fit an ADR: what the review changed, the order of the work, how each slice is judged, and what is still unknown.
Priorities
- The server feels like Minecraft and mostly is Minecraft, and it keeps up with Mojang's releases. That is why behavior comes from data and not from reimplementation.
- Performance, a close second.
- The program finishes. No slice ends with mobs on one model and players on another, and the slices that create a temporary mix are planned as one commitment.
Decisions
- ADR 0006: a replay gives identical digests at any host speed.
- ADR 0007: one entity index, and a write is visible to the next query.
- ADR 0008: one entity model, with players and items in the same store.
- ADR 0009: external inputs enter once per tick and are recorded, and a handler's lane decides whether its packet is an input.
- ADR 0010: no barrel files, and explicit package exports.
- ADR 0011: a world exchanges only plain data with the outside.
- ADR 0012: the change log is the history, complete and invertible.
- ADR 0013: vetoable actions run as intent, guard, compute and commit.
- ADR 0014: handlers share one shape, and input-lane handlers run in the input step.
- ADR 0015: a tick is a fixed sequence of synchronous phases.
- ADR 0016: replication reads the change log after the tick and keeps a replica.
- ADR 0017: one lifecycle event bus.
- ADR 0018: a save is a snapshot of one tick boundary.
What the review changed
The first version of this plan left ten gaps. Each one is closed below, with the evidence that closed it. Seven of them changed an ADR.
Replication needs more than the current tick's log
A player who walks into range of an entity that has not changed needs its full state, and the current tick's log has no entry for it. Today spawnTo reads position, rotation, metadata, attributes, equipment and effects from the live entity object and encodes a spawn packet for each viewer. Nothing caches replicated state.
Replication now keeps a replica, built only from the log. A membership entry carries an entity's full replicated state, and later entries update it. A first sight is answered from the replica and encoded once for each codec group. Replication still reads nothing from the simulation except the log, which keeps it movable to a worker. The cost is a second copy of the client-visible fields of each loaded entity, counted as replica entries and bytes. ADR 0016 records this.
Invertibility needed a scope and a use
ADR 0012 priced the before-values at three numbers for a moving entity, and authoritative state also includes blocks, inventories and block entities. Nothing in the program read a before-value.
Three things close this. First, the log has three entry kinds with stated inverses, and a chunk that enters the world is named in the log and not carried by it, because it arrived as an input. Second, a unit of work that throws is undone by inverting its span, which gives invertibility a use and a test inside the program and also closes the failure gap below. Third, this program keeps a tick's log only until replication and the save cut have read it. The before-values then cost memory for one tick and nothing on disk. Slice 2 reports deltas and bytes per tick on the replay scenarios as a non-gating count, so the retention work that comes later starts from real figures.
The tick was audited for awaits
The handlers had been audited and the simulation had not. An audit of everything reachable from World.update found 233 awaits and about 20 promises the tick starts without awaiting.
| Class | Awaits | What removes them | Slice |
|---|---|---|---|
| Packet sends | 77 | Sends become entries in the change log | 7 |
| Functions that are async only to pass a wait on | 120 | They become synchronous once the waits below them are gone | 3 to 9 |
| Chunk or block reads that may load a chunk | 21 | Loaded-chunk accessors, chunk tickets and waiting states | 3, 5, 9 |
| Disk reads and writes | 4 | Chunk loads as inputs, saves under ADR 0018 | 8, 9 |
| Awaited events | 3 | Guards under ADR 0013 | 4, 9 |
| Other waits | 8 | Worlds built outside ticks, completion results as inputs | 9 |
Most of the 233 are mechanical. The simulation never reads the result of a send, and WorldSystems.tick, RaidManager.tick, cooking stations, spawners, EntityRuntime.tick and the block-behaviors, engine-systems, lighting, spawning, entities, entity-goals and entity-brains packages contain no asynchronous code today.
Three sites are hard, and each needs a mechanism this plan now names.
- Portal transfer runs inside an entity's update (
Entity.ts:526). It can build a dimension, generate chunks, raise a cancellable event and save player data. It becomes the message of ADR 0011: the source world commits the departure in its tick, and the destination admits the arrival as an input once its chunks are ready. - Respawn search runs inside
onDeath, which runs insidedamage(Player.ts:988). It is why every caller ofdamageis asynchronous, and the runtime startsdamagewithout awaiting it. Death commits in the tick. The respawn point becomes a request to a world service, and its answer returns as an input. - The dragon fight loads its arena, gateway and pillar chunks inline (
DragonFight.ts:390,:512,:565). It takes chunk tickets and gets explicit waiting states.
Unloaded chunks are already handled the right way by most of the simulation. Block updates skip them, fluids and scheduled ticks read air and have their writes refused, and explosions and data-driven mobs read only loaded chunks. The code that loads a chunk from inside a tick is the legacy item entity, the player breath check, the dragon fight, portal travel and World.setBlockRuntimeId.
Each slice removes the awaits in the code it rewrites, and the count is reported from slice 1 on. The test that a tick completes without yielding can only pass at slice 9.
The temporary mix was too long
The first version kept a transitional adapter over legacy entities from slice 2 to slice 8 and deleted the legacy classes at the end. The legacy classes now go in slice 5, when players move, so the adapter lives for slices 2 to 5. Those four slices are one commitment. Slice 2 does not start until the work through slice 5 is planned.
Slice 5 is where the program is most likely to stall, because Player carries the entity, the connection, the saved record and server-wide chat and permissions. A spike before slice 2 puts one player-shaped entity into a prototype store, with an inventory, a network session that the entity references and a save and load round trip. The spike is thrown away. If it shows that a player cannot be expressed in the shape of ADR 0008, that ADR is reopened before any store code lands.
One input step would have throttled chunk streaming
Of the 72 handler classes, 19 write authoritative state, 15 read it without writing, and 38 touch only their own connection. A sub-chunk request is answered in handler context today. Under one input step for every packet it would wait for the next tick and slow down with the tick rate.
ADR 0009 now defines three lanes. The connection lane and the read lane run when the packet arrives, and only input-lane packets are inputs. Four of the 15 readers relay something other players see (ActorEvent, Animate, Emote, LevelSoundEvent), so they belong in the input lane, where the relay becomes a log entry. The read lane is for handlers that reply only to the sender. LevelChunk delivery already runs from its own scheduler outside the tick and stays there.
The read lane narrows the outbound digest in ADR 0006. The digest covers what replication produces from the log, counted toward the tick that produced it. Read-lane replies and chunk streaming are outside it, because no tick decides when they leave. Today's recorder stamps a packet with the tick in which the transport framed it, which slice 7 changes.
Guards have to replace parameters
The first version of ADR 0013 said a scripting before-hook could only pass or veto. In @minecraft/server 2.11.0, ten of the seventeen stable before-events let a script rewrite a parameter, such as the damage of EntityHurtBeforeEvent or the block list of ExplosionBeforeEvent. Bedrock add-ons are the primary way to extend the server, so a pass-or-veto rule would have ruled out a faithful host.
A guard now returns pass, veto or replacement values for the parameters its pipeline declares replaceable, and still writes no state. That matches the documented restricted execution of before-event callbacks. ADR 0017 changed for the same reason. Bedrock runs a system.run callback at the end of the same tick, so the ADR no longer places it in the next input step and leaves the point to the Script API host design.
Saves had no rule
A save pass mixes ticks today. Autosave starts in the middle of a tick, captures every player at that instant and then serializes chunks eight at a time across later ticks. A crash can leave an item in a saved inventory and also in a chest saved later. An entity that changes chunk during the pass can be written twice or not at all.
ADR 0018 says a pass writes the state of one tick boundary and commits it as one atomic write. It was proposed and not accepted, because the commit was a synchronous LevelDB append on the server thread and its size was unknown. B4 measured the bytes in a pass (the measurement), and the ADR was accepted on 2026-11-12 with the changes the measurement asked for: the cut belongs to the level, fresh chunks are written outside it, copy-on-write works per sub chunk, writes outside a pass join the next commit, and the commit leaves the server thread.
A failed update left partial writes
ADR 0015 skipped an entity that throws and assumed the store stays consistent. An update that throws halfway has already written. The store now undoes the span of the unit that threw before it skips the entity, as described under invertibility above.
Slice 1 promised too much, and proxies had no slice
Slice 1 was named "trustworthy replay", and replay is not fully trustworthy until the tick is synchronous. The slice list below says what replay guarantees after each step, and the target table names the slice at which each proxy starts to gate.
Slice order
Every slice lands on master, and a slice deletes what it replaces. The one exception is the adapter over legacy entities, which slice 2 adds and slice 5 removes.
- Barrel ban. Biome's
noBarrelFileandnoReExportAllwith a baseline that only shrinks. No code changes. This slice is independent of the others and can land at any time. - Tick boundaries. Every handler declares a lane. The dispatcher holds input-lane packets, and the ticker applies them at the start of the next tick in arrival order, which is what the replay harness already does when it injects. Capture stamps a packet with the tick that applied it. Chunk completions are recorded as inputs. Counts replace the 20 ms and 25 ms budgets. Worlds tick one after another in a fixed order, and a failing world does not skip the next. Handlers are still asynchronous here, so a tick can still yield, but no handler runs in the middle of one. After this slice, live play and its replay apply the same inputs at the same ticks.
- The entity store. The store, setters that append entries with before-values, spans and their inverses, the one index, and the adapter over legacy entities. The player spike comes first.
- Items and the eye of ender move into the store with built-in definitions. The item ground probe stops loading chunks.
- Staged damage and death. The store owns damage and death under ADR 0013, with replaceable parameters. The mirror and the health comparison are deleted.
damagebecomes synchronous, which removes the promises the runtime starts without awaiting. Respawn search becomes a world service request. - Players move into the store, split into components, a network session and services for chat, permissions and persistence.
Entity,GenericEntity,Human, thePlayerclass and the adapter are deleted. No legacy entity remains after this slice. - Handlers. The shared shape, a narrow context and synchronous input-lane handlers. The client package adopts the shape.
- Replication reads the log. Block and block entity writes go through logged setters, so the log is complete. Replication builds its replica, computes each audience once and encodes once for each codec group. Sends leave the simulation, which removes the largest class of awaits. Outbound packets count toward the tick that produced them.
- Saves at a tick boundary, under ADR 0018.
- The synchronous tick. Dimension travel as messages, chunk tickets and waiting states for the dragon fight, worlds built outside ticks, and tick functions admitted as inputs. After this slice a tick completes without yielding, and ADR 0006 holds in full.
- One lifecycle event bus.
- Explicit exports. Prismarine's barrels and its
"./*"export are replaced.
Players come before replication so the log is complete when replication reads it. Handlers come before replication so they have somewhere to put their output. The cost of that order is that the encode-once change lands at slice 7.
Progress
As of 2026-11-11. The rows through C4f were checked at program head 7265972882, rebased on master 878604e167, where every CI check passed, Client E2E included. Each C4h step records its own checks below.
| Package | State |
|---|---|
| A1 barrel ban, A2 count budgets and world order, A3 handler lanes, A4 change log module, A5 player spike | Landed |
| B1 input admission, with its two follow-up fixes (a held input blocked its connection; a stale move undid a teleport) | Landed |
| B2 chunk completions as recorded inputs, with the follow-up fix for the tick that stalled after the dragon died | Landed |
| B3a one live player per account (#2951) and the persistence service | Landed |
| B3b chat fan-out and respawn search as services | Landed |
| B4 save pass measurement, B5 re-keyed maps | Landed |
| C1a component state on the instance, C1b one entity index, C1c the entity store with logged setters, spans and undo | Landed |
| Replay determinism fixes (settle gaps, wall-clock chunk budget in the harness) | Landed |
| C2 items and the eye of ender in the store | Landed |
| C3 staged damage and death, the mirror deleted | Landed. Damage, death and client display are the store's; the legacy bodies remained for non-network callers until C3b |
| C3b the legacy mob bodies deleted, which meets slice 4's "no mirror class" target | Landed, see below |
| C4c player components with logged setters, and an inventory that logs one entry per slot | Landed |
| C4b the player overlay, client-owned movement and a death that keeps the entity | Landed. The store's rules are ready for a store player; the live player is still legacy, see below |
| C4e player persistence from the store entity, the saved record unchanged | Landed, see below |
| C4g store players shown to clients through the current replicator | Landed. The store path is built and tested; the live player is still legacy, see below |
| C4d player systems: hunger, breath, falls, meals and mining, reading loaded chunks only | Landed. The systems run over store players; the live player calls the same rules, see below |
| C4f the session holds the player's entity id, and the conversions split into four groups | Landed with its four groups, F1 to F4, by program head 7265972882. F3 and F4 left their notes in commit messages, which the C4h plan below folds in |
| C4h live players in the store, and the legacy classes deleted | Done with H-S0 to H-D. No legacy entity remains, which meets slice 5's target, and noLegacyEntity.test.ts gates it; see below |
| Slice 5, players in the store | Done with C4b to C4h |
| D1 the shared handler shape, the narrow context and the proxy test | Landed on the program branch, with four handlers converted; see the D1 notes under phase D |
| D3 block and block entity writes through logged setters | Landed on the program branch, see the D3 notes under phase D |
| D2 the remaining handlers on the context, with its close | Landed on the program branch, with D2a to D2g and the close; see the D2 notes under phase D |
| D4 replication reads the log | Landed on the program branch with D4a to D4f; see the D4 section under phase D |
| Slice 7, replication reads the log | Done with D3 and D4a to D4f. Its three targets gate; see the D4f notes |
| E2 dimension travel as messages, E3 chunk tickets | Landed on the program branch; see the phase E notes |
| E4 tick functions as inputs, and the synchronous tick | Landed on the program branch with E4a to E4f. Slice 9's target gates in SynchronousTick.test.ts; see the E4 notes |
| F1 one lifecycle event bus | Landed on the program branch. Slice 10 is done; see the F1 notes under phase F |
| F2 explicit exports for Prismarine | Landed on the program branch. Slice 11 is done, and noBarrel.test.ts gates its target; see the F2 notes under phase F |
| E1 saves at a tick boundary | E1a, E1b, E1c and E1e landed on the program branch. E1d is left and follows the merge as #3060, because it needs a save format decision, so slice 8 is not whole. See the E1 notes under phase E |
Phases and work packages
A slice is too large to hand to one person or agent, and several slices have parts that do not depend on each other. The phases below cut the slices into work packages. Packages in one phase run side by side, each in its own worktree, and a phase starts when the packages it depends on have landed. Each package commits in small steps and passes the gate below before it is rebased onto the program branch.
Phase A: foundations
Nothing here depends on another package, and no two packages edit the same files.
| Package | Slice | Work | Main files |
|---|---|---|---|
| A1 | 0 | Ban barrels with a baseline that only shrinks | biome.json, scripts/ |
| A2 | 1 | Counts replace the 20 ms and 25 ms budgets. Worlds tick one after another in a fixed order, and a failing world does not skip the next | World.ts, WorldSystems.ts, modules/ticker.ts |
| A3 | 1 | Every handler declares a lane, and a test checks the registry. No handler changes when it runs yet | network/handler/, PacketHandlerRegistry.ts |
| A4 | 2 | The change log as a module of its own: the three entry kinds, spans, inverses and the one-tick lifetime, with nothing wired to it | new module beside world/ |
| A5 | 2 | The player spike. It is thrown away, and its findings are kept as a research note | docs/research/ |
Phase B: admission and preparation
| Package | Slice | Depends on | Work |
|---|---|---|---|
| B1 | 1 | A2, A3 | The dispatcher holds input-lane packets and the ticker applies them at tick start. Capture stamps the applying tick. The replay test compares two runs tick by tick |
| B2 | 1 | A2 | Chunk completions are recorded as inputs |
| B3 | 5 | none | Player persistence, chat fan-out and respawn search move out of Player into services keyed by player identity, while Player is still a legacy entity |
| B4 | 8 | none | Measure the bytes in a save pass and confirm what a saved player record copies, so ADR 0018 can be accepted or changed |
| B5 | 5 | none | Re-key the seven maps keyed by the Player object by player identity or entity id |
B1 and B2 both edit the ticker, so B2 follows B1. B3, B4 and B5 run beside them.
The lane declarations of A3 put 35 handlers in the connection lane, 9 in the read lane and 28 in the input lane, and left B1 these rules.
- An input-lane packet is applied in the input step of the world its sender's entity is in. An effect on another world leaves as a message under ADR 0011 and is admitted at that world's next input step.
- Server-wide state that is not a world's authoritative state, such as the operator list, the default game mode and chat fan-out, changes in memory when the handler runs and persists off the tick.
- A player's removal is an input, whether a
Disconnectpacket or a closed transport caused it. SubChunkRequestcan load a chunk the world evicted. The handler may wait for it, and the loaded chunk is admitted through the completion queue like any other.LoginandClientToServerHandshakestay in the connection lane although they read the default world's spawn and build the player. That work moves in front of the join in slice 6.
Slice 6 inherits a list of what stops an input-lane handler from being synchronous. Eight handlers load chunks or search for a position, among them PlayerAction, InventoryTransaction, ItemStackRequest, Respawn and RequestChunkRadius. Six reach the disk through savePlayerData, the operator file or the config. Text, CommandRequest and SettingsCommand call into commands and chat, which can do anything. Six read-lane handlers are write requests the server drops today, and each moves to the input lane when its write is implemented.
B2 left three things for later slices to remove.
world/TickContext.tsusesAsyncLocalStorageto tell code inside a tick from code between ticks, because the tick still awaits. Slice 9 makes the tick synchronous, and a plain flag set by the ticker replaces it then.- Eight code paths still load a chunk from inside a tick. Each is admitted inline, recorded as
inlineand listed inIN_TICK_CHUNK_LOADS, and a test fails when one is removed without updating the list. Slices 3, 5, 6 and 9 remove them. World.disablewaits for an unload parked on admission, so calling it from inside a tick would never finish. Nothing does today. Slice 9 has to keep it that way or drain admissions first.- A tick that awaits work started outside it, such as from an event or a command between ticks, cannot be told apart from one that waits for its own chunks, and a chunk that work loads waits for the next input step. The ticker therefore admits every held completion inline, recorded as
inline, and logs aTickStalledErroreachTICK_STALL_MSa tick runs with completions held. Slice 9's synchronous tick removes the wait and the watch with it.
C1a and C1b left these for later packages.
- C1a moved component state the entities package hid in
WeakMaps ontoEntityInstance.componentState, with one write path that reports each write's before and after values toEntityEnvironment.componentStateWritten.target_nearby_sensorkeeps a runtime id there, and runtime ids are drawn again each session, so C1c marks that field as not saved until the store has ids that survive a reload. - C1b left one index per dimension (
EntityIndex,World.getEntityIndex()), and chunk membership as a second structure written only byWorld.entityRelocated. Membership follows the client body and the index follows the runtime's position, so the two agree again once C3 deletes the mirror. Until then legacy explosions search four blocks beyond their reach. - Viewer tracking still passes every entity in the world to
EntityTracker.plan. D4 replaces that with audiences computed from the index. - Inside the tick,
World.getEntities()is still copied for spawner counts, the attribute flush and tracking, andEntityRuntime.all()is copied for the entity pass, the despawn cycle and the mirror'ssync. Slice 5's target removes the copies.
C1c left these for later packages.
- The store is
EntityRuntime.store, which took over the runtime's entity map, and it holds the world's change log until block writes join it in D3. A log tick is numbered by the store, not by the level clock, which/time setmoves backwards. - A player's inventory was not logged: a slot changed by mutating the
Itemin place andInventorydid not know its owner. C4c closed this, as the notes below say. Legacy position, health and game mode are logged throughentity/legacyAdapter.ts. - A merchant's offers are saved and hashed but not logged, because a trade changes the
Merchantin place. - An undo restores only what a save or the digest reads. Goal progress, pending deaths and other runtime bookkeeping stay as the throw left them. A review later found that a death or grace period left this way refuses every later blow, so a unit now also undoes the runtime state it registers through
EntityStore.undoWith: the death in progress, the grace period, the damage record and the fuse's start. The same review found writes outside the log that survived an undo in the join, a player's death and a kick. A player's entry now undoes the player map and the store player's hold on the entity throughundoWith. What cannot be put back waits forEntityStore.afterUnit, which runs once the outermost unit has ended and says whether its writes were kept: a player's death sends and its respawn search run only on a kept unit, the join's guard and play phase are set only when its input commits (whenInputCommits), and a leave called inside a unit, as a kick is, runs once the unit ends either way. - A chunk admitted inline inside a unit pins the unit, which then keeps its writes if it throws, until slices 3 to 9 remove the inline loads.
- Writes between ticks, such as tick functions, which run after the worlds seal, and connection-lane handlers, are counted as unlogged and not logged.
- A player whose update throws is not contained yet: legacy updates do not run as units. When players move into the store, the store's failure count disconnects the session instead of removing the entity.
B3b left these for C3 and C4.
- Chat fan-out is
ChatManager. A player joins it as aChatRecipient, keyed bykeyOfPlayer, with areceivecallback, and the manager decides the audience and builds the line. Each recipient is still its ownchatlistener, because a listener that cancels the event stops only the players who joined after it. One listener with one audience pass is the shape D4 wants, and it changes which players such a listener stops, so it belongs with the guards of ADR 0013 or the event bus of slice 10. - Respawn search is
RespawnSearchinworld/RespawnSearch.ts, one for each level (Level.getRespawnSearch).find(request)takes the player's key, saved spawn point and body as plain data. It answers withspawn(ownorworld), the dimension, the three exchange positions andspawnPointLost, and writes nothing. Every world read is behind itsRespawnWorldsport: building the bed's dimension, the bed and feet checks, and the world spawn, which can still run the search for dry land. Player.getRespawnPointis the adapter. It builds the request, appliesspawnPointLostby clearing the point and sendingtile.bed.notValid, and resolves the answer's world.onDeath,respawn,RespawnHandlerandDimensionTravel.leaveEndcall it. For C3,onDeathsends the request instead of awaiting the adapter, and the input that admits the answer appliesspawnPointLostand sends theSearchingForSpawn. The in-tick chunk load entry for the search namesRespawnSearch.tsand slice 4.- The request does not carry the world spawn. Reading it before the search would run the search for dry land even when the player's own point holds, so the service reads it only when it falls back to it.
- For C4, the request's spawn point comes from the store entity's component, and the chat recipient's
receivefinds the session through the session registry by identity.
C2 left these for later packages.
- Dropped stacks reach clients through
entity/data/itemBodies.ts, anItemActorBodyper stack that reads the store entity and goes on the wire as anAddItemActor. It sits apart from the mirror, so whatever replaces the mirror for the legacy tracker can take it over too. The eye of ender has no display code of its own and shows through the mirror like any store mob. - The item bodies still run
Entity.update, which is how a stack reaches a portal. Portal travel adopts a stack that arrives in another world into that world's store from the body's saved form. Slice 9 turns travel into messages and removes this. - A stack's age is component state, so each stack appends one component entry every tick it lies in a ticking chunk, besides its position and speed while it moves. An idle world with stacks on the ground is not an idle log.
- The eye's shatter roll draws on the dimension's random, where the legacy eye drew on the server's.
- A drop now goes straight into the store, so it no longer waits on
World.addEntity.World.ITEM_ENTITY_LIMITcounts the item bodies.
C4c left these for later packages.
- Every write to a slot of a player's main inventory, armor, off hand, ender chest, cursor or crafting grid appends one
inventory-slotentry. Its value is the stack's saved form (inventory/slotLog.ts), the same form the player record keeps, so an undo restores what a save and the digest read. The hand slot is the entity fieldinventory.handSlot.Inventory.observe,HumanInventory.observeCarriedandCraftingInput.observereport the writes, andHumanwires them to the adapter. PlayerComponents(entity/playerComponents.ts) holds vitals (food, saturation, exhaustion, drowning ticks, fall distance), experience, flying, the movement flags (sneaking, sleeping, on the ground), the spawn point, the credits flag and the tick an item use began. Each field is logged as<component>.<field>. Food, saturation, exhaustion and experience stay in the attributes and the component reads them there.Player's private fields are accessors over it, so the death, respawn and damage paths were not edited and are logged too. C4e and C4f can move this object onto the store entity unchanged.- The replay digest hashes
Player.componentSnapshot, so digests of a world with a player changed once, in a commit of their own. The replay suite still finds two replays identical. - Not logged yet, for C4d: air (
metadata.air, whose owner C4d decides), sprinting, the block being mined, the meal in progress, which still holds a closure, and every attribute other than the ones above, such as movement speed and luck. Absorption is on the damage path, which C3 owns. - A stack changed in place is not a write. The inventory keeps each slot's last logged value, so the next write to that slot still carries the right before-value, but the change lands in the tick of that write. Two paths did this and were converted: wearing a tool, now
Item.withDurability, and a partial/clear.ItemStackRequestHandler.itemOrNullstill sets an item's count from its entry in place, which changes no saved form. A slot whose entry count and item count disagree logs the entry's count only. - A stack network id is session state and is not in a slot's value, so an undone write gives the stack a new id. Replication has to send the slot again (slice 7).
- A workstation's input slots and a chest's slots are block entity state, which D3 logs.
Entity.restoreLoggedFieldandrestoreLoggedSlot, whichHumanandPlayeroverride, are how the adapter's inverse writer reaches a legacy player. C4h deletes them with the adapter. Test doubles built fromPlayer.prototypewithout the constructor get their components fromwithPlayerComponentsintesting/playerDouble.test.helper.ts, which C4h deletes with them.Player.getExperiencereplaced six hand-built experience holders, so every experience write goes through the component.- The saved player record is unchanged:
PlayerPersistenceFormat.test.tspasses byte for byte.
C3 left these for later packages.
- Every blow a store entity takes is a
DamageIntentrun byDamagePipeline(entity/data/damagePipeline.ts,damageStages.ts): a mob's bite, an area attack, a projectile, a blast, a player's swing, a status effect, a command, an item action, a component hazard and a dropped stack's fire, lava and cactus. The guards arepresent,invulnerable,damage_resistant(a dropped stack's item),hit_grace,cannot_be_attacked,dragon,damage_sensor, thefireDamagegame rule and the before-hooks a host adds. The modifiers are the grace difference, armor, protection, resistance, absorption, the dragon's scaling and the sensor's multiplier, in that priority order, so the sensor still scales what armor left, as it did when the mirror handed the runtime the body's loss. - A blow from the legacy code keeps the rules the legacy pipeline gave a mob through
DamageSourceFacts: the grace period, the mitigation and the shove. A mob's own attacks keep none.legacyDamage.tsturns aDamageSourceinto an intent and maps the legacy causes to Bedrock names, so a sensor sees a player's swing asentity_attack. - A veto appends one
action-rejectedoccurrence and nothing else. The grace period is a runtime map, not logged and not saved, as the legacy body's was not. An undone unit puts it back throughEntityStore.undoWith. The sensing is memoized for one run inDamageSensingSlot, which the sensor guard, the multiplier and the commit share. - A death runs inside the commit of the blow, queued
minecraft:on_deathevents first, where it waited for the entity's next tick before. - The plugin
entityDamageevent is a before-hook that can cancel a blow or change its amount, andentityDeathand the advancement triggers are raised fromEntityHostFacts.damageCommitted, inside the blow, before a death removes the victim. Thegameplay.combat-advancementsparity family checks that timing. A player'sPlayerSession.diewaits for the blow's unit to keep its writes (EntityStore.afterUnit), so an undone death sends nothing. Slice 7 has to keep it when observers read the log. - Health is the store's alone. The comparison that handed a body's loss back to the runtime is gone, and a body reads its health from the store and heals through it.
- Clients are shown store entities through
StoreActors (entity/data/storeActors.ts), one per store entity, which answer the network layer from store state through the structuralShownActor(world/ShownActor.ts).StoreActors.syncsends what changed since it last sent each entity, which is replication's work until slice 7 reads the log instead. Nothing in it is mob-specific; the eye of ender shows through it. Dropped stacks still show through C2'sItemActorBody, which the actors can take over. - The legacy bodies (
LiveBodies) stayed in each world's entity map, written silently and never announced, because the legacy code still reached a mob through them: commands and selectors, legacy blasts and swings (damageEntity), interactions and trades, chunk saves and the replay digest (body NBT and attributes), portal travel, spawner counts, the dragon fight and riding. C3b moved those to the store actors and deleted the bodies. damageis synchronous for store entities. A legacy player'sEntity.damageis still async, because its sends are awaited, andlive.tsstill starts it without awaiting for a mob's blow on a player. That goes with C4b and slice 7.- A player's death no longer awaits the respawn search.
onDeathfiles it withRespawnRequests(world/RespawnRequests.ts), and the next input step awaits every search filed before it, in order, then appliesspawnPointLostand sendsRespawnandDeathInfo. Awaiting in the input step keeps the applying tick independent of host speed, but the search still loads chunks inside the tick, so itsIN_TICK_CHUNK_LOADSentry moved to slice 9, where such loads become recorded inputs.RespawnHandlerandrespawnstill search as they run.
C3b, which deleted the legacy mob bodies, left these for later packages.
- Every store entity but a dropped stack reaches the legacy world code through its
StoreActor(entity/data/storeActors.ts), the same object that shows it to clients.World.getActorsandWorld.getActorlist and find a world's entities, a store entity as its actor;World.getEntitiesandWorld.getEntityhold only legacy entities, which are players and the bodies of dropped stacks.WorldActor(world/WorldActor.ts) is the union of the two, and it is whatDamageSource's attacker and projectile, theentityDamage,entityDeathandentityPortalEnterplugin events, the advancement triggers, the death messages, the trade window, the save context and the providers take. Slice 5 narrows it to the actor. - The actor answers from the store and keeps no copy of it. A blow on it runs
DamagePipeline, a shove and a teleport write the body, and a tag or a status effect goes into the entity's state, filtered by itsminecraft:mob_effect_immunity. It keeps three things the store does not model yet: the actor record tags a save carries forward (persistentNbt, which a save writes the record into), the portal cooldown and contact, and the clock of the status effects. Slice 9's travel as messages takes the portal state; the other two belong with replication's replica (slice 7) or with a status effect component. - The world updates each actor in its tick, after the legacy entities, which runs the portal clock, a trip once its contact is ready, and the status effects, whose harm and healing go through the store. The bodies did this in
Entity.update, interleaved with the legacy entities in the order the entity map held them.DimensionTravelmoves a store entity by restoring its record in the destination store, with its actor record tags, tags and status effects. - Chunk membership of a store entity is the index. Its cells are sixteen blocks wide and aligned to chunks, so
EntityIndex.chunkChangedreports the chunk each move is in and the chunk a crossing left, and the world marks them for the next save, as the body's position writes did. A chunk that leaves the world takes the store entitiesEntityIndex.storeInChunkfiles in it. C1b's two structures no longer disagree, and a legacy blast no longer searches four blocks past its reach: it asks for the legacy entities (legacyWithin) and the store's (storeWithin) apart. - The replay digests did not change: an actor carries the tags, attributes and metadata the body carried, and the digest hashes the actors in the order it hashed the bodies.
- The in-tick copy of
EntityRuntime.all()for the mirror'ssyncis gone, and spawner caps walk the entity map and the actors in place.World.getActorscopies and sorts for selectors, saves, the digest and the content adapter'sat,nearandcountOfType, which slice 5's target removes. NoIN_TICK_CHUNK_LOADSentry went through a body, so the list is unchanged. - The proxy target "no mirror class and no health comparison" gates now.
entity/data/noMirror.test.tsfails ifliveBodies.tsreturns or another legacy class wraps a store entity, andlive.test.tschecks a running world: a store entity shows through its actor with nothing in the entity map, and a health written onto an actor is not handed back to the store. - C2's
ItemActorBodystays. A dropped stack still reaches a portal throughEntity.update, goes on the wire as anAddItemActor, and merges, is picked up and is unloaded as a legacy body, so folding it into the actor means moving all four paths, which is not small. Slice 9's travel as messages removes the first and makes the rest a display question. /summonhas no command form that passes a name: only the command host'sentities.summondoes, and it now names the entity in the store, where the body's name had reached no client since C3.- In-game checks for the orchestrator's combined pass: name a mob with a name tag, lead it, ride a saddled pig or a horse, trade with a villager, and walk a mob through a nether portal, then save, restart and find each where it was.
C4b left these for later packages.
- The player overlay is code, not a pack file:
entity/playerOverlay.ts, applied inbuildServerContentto whichever pack'sminecraft:playerfile won the stack. A file undervanilla_overlay/entities/would replace the shipped player file whole, where the overlay adds a component only when that file declares none. It addsminecraft:health20 of 20 and nothing else. The raw definitions inServerContent.definitionsstill hold the file as it shipped; the resolved type carries the overlay. Theentities.behaviorparity family checks it asplayer-health. - The store's three player rules key on
clientOwnsMovement(identifier), which is true ofminecraft:playeralone. A player's update in the entity pass stops after its timers, its components and the void check, so it gets no brain, navigation, coast, step or pushes, andisPushableis false for it while it still pushes mobs.MobBody.onImpulseturns everypushon a player's body, a blow's shove included, into animpulseoccurrence and leaves its velocity alone.EntityRuntime.applyClientMovementwrites what the client reported, andMobBody.teleportTostays the server's move.killkeeps a player: it stays in the store and indying, so thepresentguard vetoes further blows, untilEntityRuntime.revivebrings it back at full health with one health entry and no membership entry. No live player is a store entity yet, so these rules run only inentity/playerOverlay.test.ts. The package that spawns one (C4e, C4g or C4h) also has to keep it out of chunk saves, which skip only the legacy class today (ChunkSaveContext.ts), and out of the despawn cycle. - For the live player, every server move is acknowledged.
Player.setPositionalways sends a teleport, where it sent aNormalmove that registered nothing, which chorus fruit used. A move event's reset (PositionMode.Respawn) registers the same acknowledgment a teleport does. A join, a respawn and a world change move the player without aMovePlayerbecause their own exchanges place the client. Every handler whose source moves a player is in the input lane, andLane.test.tschecks that. - A shove on a live player is logged as an
impulseoccurrence beside the motionPlayerSession.sendMotionstill sends, until slice 7 sends it from the log. - A live player's death keeps its runtime id, as the legacy class always did, and appends no membership entry.
respawn()'s attribute reset is now logged: health, and the component fields the attributes hold, throughPlayerComponents.reportingChanges. A respawn into another dimension moves the player between worlds, which is a transfer under ADR 0011 and keeps the id; once players are store entities that is a leave from one store and an enter into another. - A blow on a live player commits before it waits.
Entity.damagestarts its packets instead of awaiting each, andPlayer.onDeathpostsentityDeath, raises its advancement triggers and files the respawn search before its first wait, so the health, the grace period, the death and the request are written when the call returns. What remains: the blow still runs the legacy rules inEntity.damage, notDamagePipeline, so a plugin'sentityDamagehandler and the grace period differ by path; the promise still covers the packets, whichlive.tsstarts without awaiting; andapplySonicBoomwaits for that promise to learn whether the blow landed before it shoves. Running a player's blows throughDamagePipelineneeds the player to be a store entity, which is C4h's. - No client e2e scenario covers riding, because
/rideis unsupported (command/host/entities.ts); a player riding a mob is checked in game. The newplayer-knockbackscenario covers a mob's shove on a player. - Found on the way: chorus fruit puts a player's eyes where their feet belong (#2961). Run alone after
fall-damageanddamage, thedimensionsscenario fails on the health those left, because nothing heals between scenarios. It passes alone, and CI's full suite has passed with it.
C4e left these for later packages.
- The record is a projection with one home,
world/playerRecord.ts.playerRecordFrom(player, services, dimension)builds it andapplyPlayerRecord(player, data, defaultGamemode, content)puts one back, through two structural ports,PlayerRecordSourceandPlayerRecordTarget.recordedPositionreads where a record puts a returning player.PlayerPersistencestill owns where a record is read and written and the key it is filed under (keyOfPlayer). - A live
Playersatisfies both ports.World.savePlayerDatacallsplayerRecordOf, which reads the advancements and the enchantment seed from their keyed services and callsplayerRecordFrom, andPlayer.enablecallsapplyPlayerRecordandrecordedPosition.Player.restoreHealthandPlayer.restoreFlyingare the two silent writes the load needs. Live behavior did not change, andPlayerPersistenceFormat.test.tsstill passes byte for byte. - A store player is
StorePlayer(entity/storePlayer.ts): theminecraft:playerstore entity, which holds the position, the look and the health, and beside it the parts the store does not model yet, in the objects a live player holds them in: the client's attribute table,PlayerComponents,HumanInventory, the game mode, the portal cooldown, the head yaw and the identity. The record holds the eye, soStorePlayeraddsEYE_HEIGHTto the entity's feet andplaceAttakes it off. A y that is not exact in binary can move by its last bit across a save and a load. The record's attribute table takes its health entry from the entity's health and maximum, so a store player saves the table a live player saves. The format test builds a store player the way C4b's tests do, writes both fixtures byte for byte from it, loads each fixture into a fresh one and writes the same bytes again. - What C4h has to switch on:
- Where store players live. Nothing builds a
StorePlayeroutside tests. C4h keeps one per player entity, keyed by entity id, builds it at login from the spawnedminecraft:player, and applies the record before the entity enters the store, so the load appends no entries, as the live load runs before the world holds the player. - Logging. A
StorePlayer's components and inventory log nothing, becauseEntityRuntime.writeFieldthrowsLoggedFieldErroron a field it does not know, and an undo would then stop the world. C4h wires them toEntityStore.writtenandwrittenSlotand adds an inverse writer for the player fields, which replaces the adapter'srestoreLoggedFieldandrestoreLoggedSlot. - The keyed services.
storedAdvancements,loadAdvancements,forgetAdvancements,enchantmentSeedOfandforgetEnchantmentSeedtake aPlayerand use only its key and the server. They need a form that takes the key, and thenWorld.savePlayerData,World.getPlayerDataandlastPlayerRecord, which take aPlayertoday, take the key and the store player. - Copies to delete with
Player.StorePlayer.setSaturationandsetPortalCooldownrepeat the clamps ofPlayer.setSaturationandEntity.setPortalCooldown, andEYE_HEIGHTlives inentity/Human.ts, which C4h deletes. - The maximum health in a record's table is not applied to a store entity, whose type decides it. Both are 20 today.
- The head yaw is kept on the
StorePlayer, becauseMobBodyhas none andEntityRuntime.applyClientMovementtakes only the yaw and the pitch. - C4b's note still applies: a store player has to stay out of chunk saves (
ChunkSaveContext.ts) and out of the despawn cycle.
- Where store players live. Nothing builds a
- A saved player record is a deep copy, which closes the question under "Checks to run". Checked again at this package:
storedInventorybuilds new arrays and builds each stack from primitives, the attribute table, the experience and the position are numbers, and the advancements'toJSONbuilds new objects. The one shared object was the spawn point, which was the component's own value. A write replaces it and never changes it in place, butplayerRecordFromnow copies it, so a record held until a later commit under ADR 0018 shares nothing with the player.inventory/persistence.test.tsasserts it for the inventory, and the format test for a whole store player record. - The client e2e
rejoinscenario is new. It leaves the Nether hurt and logs back in, and checks the dimension, the position and the health. No scenario left and rejoined before.dimensions,spawnpoint-respawnanddeath-respawnpass with it. - In-game checks for the orchestrator's combined pass: log out with a damaged, enchanted and renamed tool in the hand, armor on, a shield in the off hand and items in the ender chest, on part health and food, in the Nether, with a bed set in the Overworld, and log back in. Everything comes back, and dying sends the player to the bed.
C4g, which shows store players to clients, left these for C4h.
- A store player is an entity of type
minecraft:playerwith aStorePlayerActor(entity/data/storePlayers.ts), aStoreActorthat other clients are sent throughPlayerSession.sendAddPlayer: theAddPlayer, then the abilities, the armor and the status effects. A legacy player goes through the same method, and through the samesendAbilities,sendArmorFor,sendGameTypeForandplayerListEntryOf(network/playerListEntry.ts), which read the structuralShownPlayer(world/ShownPlayer.ts).PlayerSession.sendSpawnandsendDespawnare gone. The moves, metadata and equipment changes go out throughStoreActors.sync, one move a tick at most, and removal from the store sends aRemoveActorto each client that tracked it, which covers a leave and a dimension change. A death sends nothing, because the player stays in the store.entity/data/storePlayers.test.tscounts these packets with real sessions and the real replicator. - What the store does not hold yet the actor reads from C4e's
StorePlayer: the identity, the head yaw, the game mode, flight, and the held item and armor from itsHumanInventory(HumanInventory.getEquipment, whichHuman.getEquipmentnow calls too).StorePlayergained the skin, the device and the platform chat id.StorePlayers, one for the server inEntityRuntimes.players, files each store player by entity id, which is the registry C4e's first switch item asks for, so a player that changes dimension is still found if it keeps its id. Aminecraft:playerentity with no filed store player is shown as a plain actor. - To switch, C4h spawns each live player with
spawnStorePlayer(runtime, entityRuntimes.players, feet, identity, fill). It builds theStorePlayerand files it before the entity is announced, because the firstAddPlayeris built in the same call, andfillis where the saved record (applyPlayerRecord) and the login's skin and device go. It writes the name as the entity's name tag, as a legacy player's name is its metadata's. The position is the feet;StorePlayer.getPositiongives the eyes the wire carries. A dimension change restores the player into the destination store with the same id (EntityRuntime.restore(record, at, id)), andplayers.delete(id)runs when the player leaves the server. A skin change writesStorePlayer.skin. - The viewers are still
World.getPlayers(), legacy players with sessions, influshMovement, the observer path,getViewersandentityAdded. Six methods key onentity instanceof Playerand have to find a store player's own session by entity id instead:entityAdded, which shows an arriving player the world, and the own-client copies inactorEffect,entityAttributesChanged,statusEffectChanged,entityMetadataChangedandentityEquipmentChanged. The observer path now skips the client whose entity changed by runtime id, as the movement flush did, so the own client gets no second copy. - The head yaw on the wire is
StorePlayer.headYaw, which C4h's input step writes besideEntityRuntime.applyClientMovement, since that takes only the yaw and the pitch (C4e's sixth item). Observers are sent equipment changes from the inventory by comparing it each tick, which builds one equipment set for each store player a tick until slice 7 reads the slot entries instead. - Metadata is the components' projection plus the name tag. The legacy player writes can-fly from the game mode, sprinting, breath and air into its metadata, and each needs a logged home that the projection reads; air and sprinting are C4d's. No server code writes the sneaking flag for either kind of player, so other clients may not see a player crouch; the combined pass checks it. Observers are sent no attributes for a store player, as for a legacy one, and the player's own client gets its attributes from its session.
- The held item reaches viewers twice once
MobEquipmentHandleraddresses a store player: its relay, with the hotbar slot, andStoreActors.sync's equipment change, with the slots at zero and the armor beside it. C4h deletes the relay. Armor and off-hand changes then reach viewers at once, which they do not for a legacy player (#2965). - Game mode, abilities, skin and the player list are still triggered by legacy code:
Player.setGamemode,Player.enable,PlayerSkinHandlerandResourcePackClientResponseHandler. Each calls a method that takes aShownPlayernow, so C4h passes the actor.StoreActors.syncdoes not notice a game mode change, so the code that changes it sendssendGameTypeForand the abilities, assetGamemodedoes. - Animations, sounds and actor events relay by runtime id through
PlayerSession.tracks, so they reach a store player's viewers once their handlers address it. - The store player is not kept out of chunk saves or the despawn cycle yet, which C4b's note gives to the package that spawns live players.
- In-game checks for the orchestrator's combined pass, with two vanilla clients once C4h lands: the second player sees the first appear with its skin and name, walk, look around, swing, change its held item and armor, sneak, change game mode, die and respawn, change dimension and leave, and the player list shows both throughout. Until C4h, the same checks on a live server confirm that nothing changed for legacy players.
C4d, the player systems, left these for C4h.
- Each survival rule has one home, over a
SurvivalPlayer(entity/survivalPlayer.ts) that hands it a player'sPlayerComponents: hunger, exhaustion, saturation, natural regeneration and starvation inentity/playerHunger.ts, breath and drowning inplayerBreath.ts, fall distance and landing damage inplayerFall.ts, the meal in progress inplayerMeal.ts, crack animation inplayerMining.ts, and what a death and a respawn reset inplayerRespawn.ts. A rule writes through the components and returns the damage or the heal for its caller to deal, because a legacy blow still waits on its packets. The legacyPlayercalls the rules for its own components, so live behavior is unchanged but for the two fixes below. PlayerSystems(entity/data/playerSystems.ts,EntityRuntime.playerSystems) runs them over every store entity the client moves. The entity pass ticks breath and hunger for a player they reach, then the meal and the mining;applyClientMovementadds the move to the fall, charges the walk and pays for a landing; the blow that kills a player ends its drowning and its mining, andreviveresets its attributes, lungs and movement flags. Blows go throughDamagePipelinewith the facts the legacy path gives the cause (route: 'component', graced and mitigated). Each write is logged throughEntityStore.writtenand undone throughEntityRuntime.writeField, which hands a player component field toPlayerSystems.restore.entity/data/playerSystems.test.tstests each system against a store player.- One store player has one set of components:
new StorePlayer(entity, identity, runtime.playerSystems)builds them throughPlayerSystems.attachover its own attribute table, which closes C4e's second switch item for the components (the inventory still logs nothing) and its fourth for the saturation clamp. Built without the systems, as nothing outside tests does, aStorePlayerkeeps unlogged components. - Everything C4c left unlogged is a component field now:
vitals.air,vitals.breathingandmovement.sprinting, kept in the actor metadata as food is kept in the attributes,mining.blockandconsumption.mealas plain data, andattributes.<name>for absorption, knockback resistance, the three movement speeds, follow range, attack damage and luck. A legacy write to one of those attributes goes throughEntity.setAttributeValue. The meal is data: what a finished meal does is the held item'sonConsume, resolved when it finishes, and a store player reaches its hand and the finish through the host factsheldItemOfandfinishMeal, which no live host supplies yet. - Breath has one owner, the breath rule. A store player's shipped
minecraft:breathablekeeps no air:PlayerSystems.beforeComponentsclears its state before every component tick, so its own tick does nothing. That appends one component entry on a player's first tick. The rule keeps today's numbers, which differ from the shipped component's (five ticks of air back a tick, not four; two damage every twenty ticks, not every ten). - Every system reads loaded chunks only, and
IN_TICK_CHUNK_LOADSlost the breath and landing checks. A head in an unloaded chunk waits: no air is spent or regained. A crack tick on an unloaded block sends nothing and the mining goes on, where it stopped the cracks. A landing on a block nobody can read costs nothing, as before. - Fixed on the way: the landing check read the blocks under a legacy player's eyes, which the client reports, so hay, slime or a bed under the feet never broke a fall. The
fall-damageclient scenario lands on hay now. - The replay digest hashes the new component fields, so absolute digests of a world with a player changed. Two replays still match, and
SessionReplay.test.tspasses unchanged. player-survivalin theentities.behaviorparity family compares the rules with Java and the shipped player file: a lungful of 300 ticks, two damage twenty ticks after the lungs empty, the fall damage formula, and the exhaustion costs inminecraft:exhaustion_values. Bedrock ships a walk cost of 0 and the server charges 0.01 a meter, which the scenario leaves out.- What C4h has to switch on:
- Inputs. The input step calls
PlayerSystems.beginMealandcancelMealwhereInventoryTransactionHandlercallsbeginConsumingandcancelConsuming,setMiningwherePlayerActionHandlersets the mining (afterWorld.startBlockCracking, which sends the start), and the sharedaddJumpExhaustionrule and themovement.sprintingfield wherePlayerActionHandlerhandles a jump and a sprint. A server move of a store player (MobBody.teleportTo) has to zerovitals.fallDistance, as a legacy teleport does; nothing does that yet. - Game mode. Every store player counts as a survival player (
SurvivalPlayer.vulnerableis always true) until its game mode is a component the systems read. A creative store player would starve, drown and take falls. - Ground state. The fall rule reads a store player's ground from the body's
on_groundcondition, whichapplyClientMovementwrites, and themovement.onGroundcomponent field stays stale. One of the two has to go. - The meal clock. A store meal counts out on the level clock, a legacy one on the server tick. A live host has to supply
heldItemOfandfinishMeal, or no store meal finishes. - Metadata. C4g's note asks for logged homes for air, breathing and sprinting: they are
vitals.air,vitals.breathingandmovement.sprintingof the store player's components, held in the components' ownMetadata.StorePlayerActor's projection does not read them yet, and the breath packet throttle (Player.syncBreath) has no store counterpart; replication sends them from the log in slice 7. - The mining runtime id. A store player's crack tick reads
ContentBlockAccess.runtimeIdAt, which the live adapter supplies. A host without it never stops a store player's cracks.
- Inputs. The input step calls
- Found on the way: starvation ignores the difficulty (#2968), and a player inside a solid block takes no suffocation damage (#2969). Hay, honey and beds cancel a fall outright where Java and Bedrock take most of it, which
entity/Environment.tsrecords as a known simplification. - In-game checks for the orchestrator's combined pass: sprint until the food bar drops, starve on Hard and heal on a full bar, swim under until the bubbles run out and drown, surface and watch the bar refill, fall eleven blocks onto stone and onto a hay bale, eat a steak and switch items halfway through a second one, mine a block slowly and have another player break it, and die while mining underwater, then respawn with full food, air and a standing body.
C4f, the session's entity id and the split of the conversions, as of its first run.
- A
PlayerSessionholds its player's runtime id (runtimeId) and login identity (identity, aPlayerIdentity), and noPlayer.getPlayer()is the one accessor from a session to its player's state: it finds the player by id in aSessionPlayersdirectory (network/sessionPlayers.ts). Live players are filed inLegacyPlayers, one for the server, fromSessionManager.getLegacyPlayers().createConnectedPlayerfiles the player, andPlayerSession.disableforgets it, after whichgetPlayer()throws andisConnected()answers false.SessionManager.claimIdentityreads the held identity. - The world's entity map cannot be the lookup: a player is not in it from the login to
SetLocalPlayerAsInitialized, nor between the two worlds of a dimension change, and the session speaks for it the whole time. The directory has theofanddeleteofStorePlayers, so C4h's switch is the directorycreateConnectedPlayerhands the session,EntityRuntimes.playersin place ofLegacyPlayers, and the accessor's return type. C4h then deletesLegacyPlayersandsessionPlayerRefOf. - Session methods that await read the player once before their first await, so a player that leaves during a send behaves as before. Tests build sessions with
sessionForandsessionPlayerFields, and test servers get a real directory fromsessionsWith(testing/playerSession.test.helper.ts).
Converting a file means replacing each Player it names with the narrowest of these: session.runtimeId or session.identity (and playerKeyOf(identity) for a keyed service), a ShownPlayer, a structural port the file declares for what it reads (as SurvivalPlayer and PlayerRecordSource do), or, for state the store does not hold yet, session.getPlayer() narrowed to such a port. git grep -lP "\bPlayer\b" -- 'packages/**/*.ts' finds 86 Prismarine source files at this run, and 60 more reach the player through getPlayer() or getNetworkSession() without naming it, 146 in all. The other packages' 49 hits are Bedrock's own word (MovePlayerPacket, the Player command origin), not the class. The 127 test files and test helpers that name it (the spike counted 117) are C4h's, except the tests beside a converted file, which its group updates.
| Group | Files | What the files need |
|---|---|---|
| F1, handlers that only name the player | 42 | The id and the identity: a log line's name, a comparison with the sender's runtime id. Six pass the player to blockTargetRejection, and BlockTarget.ts narrows that parameter to the position and world it reads |
| F2, handlers that change the player | 28 | The identity for logs, and the player's state through session.getPlayer() narrowed to a port per use: inventory and crafting input (ItemStackRequest, ContainerClose, BookEdit, MobEquipment), position and world (Interact, LevelSoundEvent, ActorEvent), game mode, flight and skin (SetPlayerGameType, SetDefaultGameType, RequestAbility, PlayerSkin, a ShownPlayer for the skin), the join (Login, ResourcePackClientResponse, SetLocalPlayerAsInitialized), movement and survival (MovePlayer, PlayerAction, InventoryTransaction, Respawn), and a kick or a disconnect as a session operation (Disconnect, ResourcePackChunkRequest, PlayerAuthInput). CommandRequest, SettingsCommand, Text, RequestPermissions and BossEvent hand the player to F3's or F4's code and keep passing session.getPlayer() until those take something narrower |
| F3, sessions, replication and worlds | 16 | PlayerSession's remaining 40 accessor reads (view distance becomes session state; position and world for chunk streaming, inventory and attributes for its own sends), NetworkWorldReplicator finding a viewer's session by entity id (C4g's six instanceof Player methods), BossBar keyed by runtime id, SessionManager's lookups by id and identity, World's player map, viewers and savePlayerData keyed by keyOfPlayer (C4e's third item), playerRecord, PlayerRecords, Level, WorldChangeSink, DragonFight, DimensionTravel, spawnPosition, startGameData, NetworkDispatcher, and entity/data/live.ts and Entities.ts, whose host facts already answer by id |
| F4, services, commands, items and events | 30 | Keyed services: advancement progress, raising and triggers, and the enchantment seed take the key and the server, the ender chest store takes the key, bans and permissions read the identity. A port for state: anvil, item/actorPort, item/interaction and advancement/subject. CommandManager, command/host/* (7) and ParseTargetSelector take a sender port and reach a target's session by id. The ten events/player classes carry the id and the identity, plus what each reports |
| Nothing until C4h | 30 | Player.ts, Human.ts, Entity.ts, sessionPlayers.ts, createConnectedPlayer.ts, ClientConnection.ts (its teardown calls Player.disable) and index.ts are Player itself or its wiring. 23 files name it only in prose or a {@link}, among them storePlayer.ts, storePlayers.ts, playerSystems.ts, ShownPlayer.ts, RespawnSearch.ts and jsprismarineCommands.ts; C4h rewords them |
F1 is the files ActorPickRequestHandler, AnimateHandler, AnvilDamageHandler, BlockPickRequestHandler, BlockTarget, ClientCacheBlobStatusHandler, ClientCacheStatusHandler, ClientMovementPredictionSyncHandler, CommandBlockUpdateHandler, EmoteHandler, EmoteListHandler, LabTableHandler, LecternUpdateHandler, MapCreateLockedCopyHandler, MapInfoRequestHandler, ModalFormResponseHandler, MultiplayerSettingsHandler, NetworkStackLatencyHandler, NpcRequestHandler, PacketViolationWarningHandler, PartyChangedHandler, PhotoTransferHandler, PlayerLocationHandler, PlayerToggleCrafterSlotRequestHandler, PositionTrackingDBClientRequestHandler, PurchaseReceiptHandler, RequestChunkRadiusHandler, ResourcePacksReadyForValidationHandler, ServerSettingsRequestHandler, ServerboundDiagnosticsHandler, ServerboundLoadingScreenHandler, ServerboundPackSettingChangeHandler, SetActorLinkHandler, SetDifficultyHandler, SetPlayerFurnaceOptionsHandler, ShowCreditsHandler, SimpleEventHandler, StructureBlockUpdateHandler, StructureTemplateDataRequestHandler, SubChunkRequestHandler, SubClientLoginHandler, UpdateClientOptionsHandler.
All four groups run side by side, each in its own worktree, because no file is in two groups. They keep to these rules so that none breaks another.
- Narrow a parameter freely: a
Playerstill satisfies any port drawn from it, so a caller in another group keeps compiling. Narrow a return type or an exported field only when every reader in the repository is in the group, and leave it for C4h otherwise. - A caller whose callee is in another group keeps passing
session.getPlayer(). - Only F3 edits
PlayerSession.tsandSessionManager.ts. A group that needs a new session member asks F3 for it or waits for C4h. - No group edits
Player.ts,Human.ts,Entity.tsor an existing file undertesting/. A group that needs a test helper adds a file of its own beside the others. - Each group adds its own Changeset. F4's events and command host change plugin-facing types, which the versioning policy makes
minor.
F2, the handlers that change the player, left these for C4h.
- Every F2 handler logs
session.identity.nameand compares the runtime id a packet names withsession.runtimeId. The rest of what it reads or changes of the player goes throughsession.getPlayer()typed as a port for that use. The ports two handlers share are innetwork/handler/playerPorts.ts:KickedPlayer,PlacedPlayer(the position and the world),InventoryHolder,CraftingHolderandFlyingPlayer. The others live in their handler:GamemodePlayerinSetPlayerGameTypeHandler,ReskinnedPlayerinPlayerSkinHandler,JoiningPlayerinResourcePackClientResponseHandler,SpawningPlayerinSetLocalPlayerAsInitializedHandler,MovedPlayerinMovePlayerHandler,RespawningPlayerinRespawnHandler,ActingPlayerinPlayerActionHandlerandTraderintradeScreen.ts. The rest are picks ofShownPlayer(isOp,gamemode) andShownActor(getType, and the whole actor forActorEventHandler's relay). - Session operations:
DisconnectHandlerreads the address from the connection, which is the address the player was built with, andPlayerAuthInputHandlerandInventoryTransactionHandlerasksession.isConnected()in place ofPlayer.isOnline(). After the player has left the server, aDisconnectstill closes the connection and an input or a transaction is dropped, wheregetPlayer()threw before.PlayerSkinHandlermatches the packet's UUID withsession.identity.uuidand leaves the sender out of the relay by runtime id, asMobEquipmentHandlerdoes. - What still takes
session.getPlayer(), because the callee is another group's or C4h's:PlayerMoveEvent,PlayerSpawnEvent,PlayerSession.sendMove,createStartGameData,playerSpawnPositionPacket,World.addEntity,hitBlock,breakBlock,dropFromPlayerand an audience'sexclude, the content interactions,meleeAttack,loose,consumeTicksOf, the enchanting and anvil codeworkstation.tscalls,advancementItemCrafted,containerAnchorOfandopenContainerSlots, the command manager, the chat, the permission check andBossBar.answerQuery.workstation.tsdoes nothing with its player but pass it to the enchanting code, so it is unchanged.LoginHandlerstill builds thePlayer, which is C4h's. - What a
StorePlayerlacks, by port. Each is a method a legacyPlayerhas; the store half named after it is where C4h can find the state.PlacedPlayer.getWorld, which twelve of the handlers read. A store player's world is the runtime it is in, which it does not hold.KickedPlayer.kick. The session can own it, see below.CraftingHolder.getCraftingInput: no crafting grid.FlyingPlayer.setFlying: onlyrestoreFlying, with no event and no abilities sent.GamemodePlayer:isOp,getPermissionsandsetGamemode; only thegamemodefield.StorePlayerActor.isOpanswers theShownPlayerpicks.JoiningPlayer:getJoinSpawnandsendSettings.SpawningPlayer:completeSpawn.MovedPlayer:isOnGround,setPlayerPositionandsetOnGround, whichEntityRuntime.applyClientMovementand theon_groundcondition cover.RespawningPlayer:isAliveandgetRespawnPoint.EntityRuntime.reviveand the level'sRespawnSearchcover them.ActingPlayer:getMiningandsetMining(PlayerSystems.setMining),respawn(EntityRuntime.revive),addJumpExhaustion(the shared rule over the components),setSprinting(movement.sprinting),setSneaking,setSleepingandsetFlying.Trader.getExperience: private, though itsPlayerComponents.experience()has it.Pick<ShownActor, 'getType'>: none onStorePlayer; the entity's identifier has it.ActorEventHandlerrelays aShownActor, which for a store player is itsStorePlayerActor, not theStorePlayer.
- Session members F2 needs from F3 or C4h, used through
getPlayer()until then:PlayerSession.kick(reason, code).Player.kickis the connection'sdisconnect(reason, false, code)and a verbose log line, so the session can own it andPlayer.kickcan call it.KickedPlayerthen goes.- A way from the session to the player's world, which F3's chunk streaming needs too.
PlacedPlayer.getWorldthen reads it.
- Tests: the handler tests give their session doubles
runtimeIdandidentity, the latter fromidentityNamedintesting/sessionIdentity.test.helper.ts.sessionNearBlocksintesting/blockTarget.test.helper.tsbuilds a session without either, soBookEditHandler.test.tsadds the identity to the session it returns, and F1's handlers that use the helper may need the same. - In-game checks for the orchestrator's combined pass: none beyond C4f's, since no handler's behavior changed.
C4h: live players in the store, and the legacy classes deleted
Planned on 2026-11-11 at program head 7265972882, after F1 to F4 landed. Its steps carry an H- prefix, because phases A to D already use the names A1, B1, C1 and D1. Counts are from that head. F3 and F4 wrote no notes on this page; their commit messages are the record, and what they left is folded in below.
1. The switch list. Every item the C4b to C4g and F1 to F4 notes left for C4h, deduplicated. "Done" means a later package closed it, and the step that spawns live players only adds a regression test. "Found" means no note listed it. The last column names the step in part 2.
| # | Item | Files | Asked by | Step |
|---|---|---|---|---|
| 1 | Spawn each live player as a minecraft:player store entity, its StorePlayer filed in EntityRuntimes.players. Apply the saved record before the entity enters the store, so the load appends no entries | LoginHandler.ts, createConnectedPlayer.ts, Player.enable, storePlayers.ts | C4e 1, C4g | H-B2 |
| 2 | A store player with no entity, from the login to SetLocalPlayerAsInitialized and between the two stores of a dimension change. StorePlayer's constructor takes a DataEntity | storePlayer.ts, storePlayers.ts, PlayerSystems.attach | C4f states the gap; found, since no note designs it | H-A1 |
| 3 | One id allocator outside Entity. Entity.runtimeIdCount is the counter both the legacy constructor and LiveEntityRuntime's nextId draw from | Entity.ts:103, live.ts:571, new entity/entityIds.ts | adapter item 1; found | H-S0 |
| 4 | The session's directory becomes EntityRuntimes.players, and getPlayer() returns the store player. Delete LegacyPlayers, sessionPlayerRefOf and SessionManager.getLegacyPlayers | sessionPlayers.ts, createConnectedPlayer.ts, PlayerSession.ts, SessionManager.ts, testing/playerSession.test.helper.ts | C4f | H-D |
| 5 | Leaving removes the entity from its store and runs players.delete(id). ClientConnection's teardown calls session.getPlayer().disable() | ClientConnection.ts:90, PlayerSession.disable, Player.disable | C4f, C4g | H-B2, H-C2 |
| 6 | A dimension change, and a respawn into another dimension, leave one store and enter the other through EntityRuntime.restore(record, at, id) with the same id | PlayerSession.changeWorld, the addEntity and removeEntity calls for players | C4b, C4g | H-S0 (entry points), H-B2 |
| 7 | The five instanceof Player in DimensionTravel: isMidDimensionChange, transfer, nether portal creation, the End arrival height and leaveEnd | world/portal/DimensionTravel.ts:101,151,295,333,349 | F3 | H-S0 |
| 8 | Inventory logging: a store player's Inventory.observe, HumanInventory.observeCarried and CraftingInput.observe report to EntityStore.writtenSlot, and the runtime undoes its inventory-slot entries, which replaces restoreLoggedSlot | storePlayer.ts, playerSystems.ts, EntityRuntime.writeField | C4c, C4e 2 | H-A1 |
| 9 | The inverse writer for player fields. Partly done: C4d's PlayerSystems.restore undoes component fields. gamemode and inventory.handSlot still go through the adapter | playerSystems.ts, legacyAdapter.ts | C4e 2 | H-A1 |
| 10 | Game mode as a logged component field that SurvivalPlayer.vulnerable reads. Without it a creative store player starves, drowns and takes falls | playerComponents.ts, survivalPlayer.ts, playerSystems.ts:311, storePlayer.ts | C4d 2 | H-A1 |
| 11 | One ground state: the body's on_ground condition or movement.onGround | playerFall.ts, playerComponents.ts | C4d 3 | H-A1 |
| 12 | The meal: one clock, and a live host that supplies heldItemOf and finishMeal, which no host does | playerMeal.ts, host.ts:130, new entity/data/playerHostFacts.ts, wired into live.ts | C4d 4 | H-A1, H-B2 |
| 13 | The mining runtime id on a live host. Done: world/contentAdapter.ts:250 supplies runtimeIdAt | none | C4d 6 | done |
| 14 | The input step drives the systems: beginMeal and cancelMeal where InventoryTransactionHandler calls beginConsuming and cancelConsuming, setMining after World.startBlockCracking, addJumpExhaustion and movement.sprinting on a jump and a sprint, the head yaw beside applyClientMovement, and a server move (MobBody.teleportTo) that zeroes vitals.fallDistance | playerSystems.ts (H-A1), the Player methods the handlers call (H-B2) | C4d 1, C4e 6, C4g | H-A1, H-B2 |
| 15 | StorePlayerActor's metadata reads vitals.air, vitals.breathing, movement.sprinting and can-fly from the game mode, and Player.syncBreath's throttle gets a store counterpart | storePlayers.ts, playerBreath.ts | C4d 5, C4g | H-A1 |
| 16 | The record's maximum health is not applied, because the type decides it. Both are 20. No change | none | C4e 5 | none |
| 17 | Copies. EYE_HEIGHT and eyePositionAt live in entity/Human.ts, with 9 importers. StorePlayer.setPortalCooldown repeats Entity.setPortalCooldown's clamp. The saturation clamp is done (C4d) | new entity/playerEyes.ts, storePlayer.ts | C4e 4 | H-S0 |
| 18 | Out of chunk saves and the despawn cycle. Done: ChunkSaveContext.ts:29 skips minecraft:player by type, and runDespawnCycle skips an entity with no spawn pool, which a spawned player never has | none | C4b, C4e 7, C4g | done, H-B2 tests it |
| 19 | Out of chunk unloads. World removes every store entity storeInChunk files in an unloading chunk (World.ts:3318), and markEntitiesInChunkChanged (:3571) marks a chunk changed for any of them, players included | World.ts | found | H-S0 |
| 20 | Ticking anywhere. The legacy loop exempts a player from the ticking-chunk check (World.ts:1153); EntityRuntime.tickEntities and the actor update (World.ts:1159) apply it to every store entity | EntityRuntime.ts, World.ts | found | H-A1 (store), H-S0 (world) |
| 21 | A player unit that throws disconnects its session instead of removing the entity | EntityRuntime.ts, EntityStore.ts, live.ts | C1c; missing from the H-C4 notes | H-A1 (hook), H-B2 (wire) |
| 22 | A player's blows run DamagePipeline: a mob's blow on a player (live.ts:385,395,442 start target.damage without awaiting), a player's swing, a PvP blow, the entityDamage hook and the grace period on one path, and applySonicBoom without a promise | live.ts, Combat.ts, legacyDamage.ts | H-C3, C4b | H-B2 |
| 23 | The PvP rule tests instanceof Human | Combat.ts:111 | found | H-S0 |
| 24 | Death and respawn through the store: kill keeps the player in dying, EntityRuntime.revive brings it back, and the respawn request reads the spawn point from the component | Player.onDeath, Player.respawn, RespawnRequests.ts, playerRespawn.ts | B3b, C4b | H-B2 |
| 25 | Delete MobEquipmentHandler's relay. StoreActors.sync sends the equipment, and armor and off-hand changes then reach viewers at once (#2965) | MobEquipmentHandler.ts | C4g | H-B2 |
| 26 | Game mode, abilities, skin and the player list pass the actor to sendGameTypeFor, sendAbilities and playerListEntryOf. A skin change writes StorePlayer.skin. A game mode change sends the game type and abilities itself, since StoreActors.sync does not notice it | Player.setGamemode, Player.enable, PlayerSkinHandler.ts, ResourcePackClientResponseHandler.ts | C4g | H-B2 |
| 27 | The replicator's own-client copies by session. Done by F3: World.viewerOf (072a0f5029), and viewers are sessions (World.getViewers, 9c4ec138a2) | none | C4g | done |
| 28 | Keyed services. Done: advancements and the enchantment seed take a key (c594bb33f3), and World.getPlayerData and lastPlayerRecord read the key and the world (93f0942ff9, 2130d83f79). Left: World.savePlayerData and playerRecordOf need a KeyedPlayer, because enchantmentSeedOf takes getServer() from a legacy player | World.ts:4183, playerRecord.ts:155 | C4e 3 | H-B3 |
| 29 | The legacy-player overloads kept beside the key forms: PlayerKeySource and KeyedPlayerSource, BossBar's methods, the ban check and PermissionManager | keyedPlayer.ts, BossBar.ts, ban/, permission/ | F3, F4 | H-C2 |
| 30 | The chat recipient's receive finds its session by identity | ChatManager.ts, Player.ts | B3b | H-B3 |
| 31 | Player.restoreHealth, restoreFlying and Player as a PlayerRecordTarget go. StorePlayer is the record's one target | Player.ts, playerRecord.ts | C4e | H-D |
| 32 | Players found by class: asPlayer (item/actorPort.ts:69), playerSessionOf (command/host/support.ts:181), the trade, filter view and swing in live.ts:852,914,934, and World.ts:1153,3160,3487,3543,3575,3805. Each finds the session by id (World.viewerOf) or tests the type | those files | F4, C3b, C4g | H-S0 |
| 33 | WorldActor narrows to StoreActor. World's entity map, players map, getEntities and getEntity go, and so do EntityIndex's legacy half (addLegacy, refileLegacy, removeLegacy, legacyWithin) and Explosion's legacy query | WorldActor.ts, World.ts, entityIndex.ts, Explosion.ts, the 19 Entity importers | C1b, C3b | H-C1 |
| 34 | No array copy. EntityRuntime.all() spreads the store for the entity pass, the despawn cycle and curing. World.getActors copies and sorts for selectors, saves, the digest and the content adapter's at, near and countOfType, and shownActors concatenates | EntityRuntime.ts:717, World.ts:3660,3698 | slice 5 target, C1b, C3b | H-C4 (all()), H-C1 (getActors) |
| 35 | The replay digest's player branch (World.ts:3805) hashes a legacy player's slot counts and componentSnapshot. It moves to the store player, so digests of a world with a player change once | World.ts | C4c | H-B2, re-baselined in its own commit |
| 36 | A world without an entity store. Seven getEntityRuntime?. sites and five hasEntityStore() fallbacks in World.ts keep a path for a world that runs the written entity classes. A world needs a store to hold its players | World.ts, legacyAdapter.ts:52 | found | H-C1 |
| 37 | ItemActorBody becomes a StoreActor subclass: portal travel through StoreActor.update and transferStoreEntity, AddItemActor through spawnTo, and merge, pickup and unload through the store | itemBodies.ts (deleted), storeActors.ts, live.ts, EntitySerializer.ts, World.ts:3152, PlayerSession.sendAddItemActor, DimensionTravel.transfer | H-C2, C3b | H-A2 |
| 38 | GenericEntity is the load path for every stored actor. EntitySerializer.fromNBT builds one, World.createStoredEntities and the level snapshot (World.ts:761) hand it to the store, and an actor whose type no pack declares stays in the world as a live GenericEntity | EntitySerializer.ts, World.ts:761,1712,3148, live.ts:1104, Entities.ts (writtenEntities) | found | H-A2 |
| 39 | World.ITEM_ENTITY_LIMIT counts item bodies | World.ts:2927 | H-C2 | H-A2 |
| 40 | Delete Entity.ts, GenericEntity.ts, Human.ts, Player.ts, legacyAdapter.ts, Entities.ts's Human and its deprecated Player alias, Entity.restoreLoggedField and restoreLoggedSlot, testing/playerDouble.test.helper.ts, and Player.viewDistance, which nothing has written since F3 | those files | C4c, C4f, F3, F4 | H-B2, H-D |
| 41 | The public API: the root Player export (index.ts:27), Entity through entity/index.ts's export *, Entities.Human and Entities.Player, and the "./*" subpath to each deleted module. Prismarine is 0.20.0, so the Changeset is minor | index.ts, entity/index.ts, .changeset/ | found | H-B2 (Entities), H-D |
| 42 | Reword prose. 43 source files name Player without importing it; C4f counted 23 | each file's owner | C4f | H-C1 to H-C4 |
| 43 | The slice 5 target as a proxy test beside noMirror.test.ts: no Entity, GenericEntity or Human class, no subclass of StoreActor but the store's own, and no copy from all() | new entity/data/noLegacyEntity.test.ts | slice 5 target | H-C4, gating at H-D |
Three items are done (13, 18, 27) and three partly (9, 17, 28). Eight were found in the code: 2, 3, 19, 20, 23, 36, 38 and 41, and item 21 was in C1c's notes but no H-C4 note.
2. The sequence. Every step keeps the server working and lands on the program branch before the next one starts. "Unit tests" means pnpm exec vitest run --since-last-run and the Prismarine suite in full once. "The replay suite" is packages/client/src/SessionReplay.test.ts.
| Step | Work | Gate | Risks |
|---|---|---|---|
| H-S0 seams | Items 3, 7, 17, 19, 20 (world side), 23 and 32, and World.addPlayer and removePlayer, which call addEntity and removeEntity for now and which SetLocalPlayerAsInitializedHandler, PlayerSession.changeWorld and Player.disable call (item 6). No behavior changes. Afterwards only Player.ts, Human.ts, Entities.ts and the session directory name the player class | Unit tests; the replay suite with unchanged digests; parity blocks.portals, protocol.dimension-change, gameplay.combat-advancements; client e2e spawn, rejoin, dimensions, death-respawn, attack-entity, item-drop-pickup | A lookup by id that misses a player outside a world's player map. F3 made World.viewerOf answer for a player joined or not; a unit test covers the join window |
| Wave H-A | H-A1, H-A2 and H-A3 side by side, in part 3. Live players are still legacy | Per package, below | Per package |
| H-B1 Player stops being an entity | Player holds a body instead of being one: a LegacyPlayerBody extends Human, which the world's maps, the index and the replicator hold, and Player delegates to it. Code that hands a player to entity code (a blow's attacker, the portal touch, the events, the triggers) passes the body. A refactor with no behavior change | Unit tests; the replay suite with unchanged digests; every parity family; the full client e2e suite | Identity checks that compared the Player object itself, such as attacker === target and world.getEntity(id) === player |
| H-B2 the body is the store entity | LegacyPlayerBody becomes the StorePlayerActor of a spawned store player. Items 1, 5 (store side), 6, 12 (wired), 14 (routed through Player's methods), 21 (wired), 22, 24, 25, 26 and 35. Deletes Human.ts with Entities.Human and Entities.Player, the player half of legacyAdapter.ts (logLegacySlot, logLegacyImpulse, the player fields), restoreLoggedField, restoreLoggedSlot and playerDouble. Player keeps no entity state | Unit tests; the replay suite, with the digest re-baseline in a commit of its own that says only player hashing changed; every parity family; the full client e2e suite, reporting spawn, rejoin, duplicate-login, movement, movement-fanout, movement-rotation, look-direction, jump, gait, teleport, teleport-while-dead, damage, fall-damage, player-knockback, death-respawn, spawnpoint-respawn, dimensions, ender-dragon, eating, mining-drops, villager-trade and attack-entity by name; the in-game checks the C4d, C4e and C4g notes list, with two vanilla clients | The largest step. What differs by path today becomes one path: the grace period and entityDamage on a player, the breath numbers (C4d), the meal timing and the metadata. A player not yet spawned must be neither targetable nor shown (question 1) |
| H-B3 name the homes | Each Player method moves its body to its home and leaves a one-line delegate. Client operations go to PlayerSession: completeSpawn, sendSettings, getJoinSpawn, sendInitialSpawnChunks, sendMessage, and the sends of setGamemode and setFlying. Simulation goes to PlayerSystems and EntityRuntime: setMining, beginConsuming, respawn, setSprinting, setSneaking, and the writes of setGamemode and setFlying. Items 28 and 30 | Unit tests; the replay suite with unchanged digests; client e2e spawn, chat, eating, mining-drops, teleport, death-respawn, dimensions | Low. The delegates keep every caller compiling |
| Wave H-C | H-C1 to H-C4 side by side, in part 3. Each moves callers off Player, Entity and WorldActor | Per package, below | A caller in another package's file; the rules below |
| H-D delete | Items 4, 31, 40 and 41, and what wave C left: Player.ts, Entity.ts, the rest of legacyAdapter.ts, LegacyPlayers, World.getPlayers (its callers use getViewers), the WorldActor alias, the root and Entities exports. Lower the barrel baseline (node scripts/check-barrels.mts --update). The noLegacyEntity test gates. A minor Changeset for @jsprismarine/prismarine. Update docs/data-driven/entities.md, this page's progress and server-rearchitecture.md | pnpm run build, typecheck, lint, lint:custom and test; the parity suite; the replay suite; the full client e2e suite, and pnpm run e2e:client:dist for the built package | A plugin-facing type change; question 2 |
3. Side by side. Two waves of packages, each package in its own worktree. No file is in two packages of one wave, and a file not listed belongs to no package of that wave. The rules are C4f's: narrow a parameter freely, narrow a return type or an exported member only when every reader is in the package, and leave the rest for the next serial step. Each package converts the tests beside its files and the tests part 4 gives it.
Wave H-A, after H-S0:
| Package | Items | Owns |
|---|---|---|
| H-A1 the store player, complete | 2, 8, 9, 10, 11, 12 (the facts module), 14 (the system calls), 15, 20 (the store pass), 21 (the hook) | entity/storePlayer.ts, entity/data/storePlayers.ts, entity/data/playerSystems.ts, entity/playerComponents.ts, entity/survivalPlayer.ts, entity/playerHunger.ts, playerBreath.ts, playerFall.ts, playerMeal.ts, playerMining.ts, playerRespawn.ts, entity/data/EntityRuntime.ts, entity/data/EntityStore.ts, world/playerRecord.ts, new entity/data/playerHostFacts.ts, testing/storePlayer.test.helper.ts. Gate: unit tests, PlayerPersistenceFormat.test.ts byte for byte, parity entities.behavior (player-health, player-survival). No live change, so no e2e |
| H-A2 the bodies that are not players | 37, 38, 39 | entity/data/itemBodies.ts, entity/data/storeActors.ts, new entity/data/itemActor.ts, entity/data/live.ts, entity/EntitySerializer.ts, entity/GenericEntity.ts, entity/Entities.ts, world/World.ts, world/Level.ts, world/portal/DimensionTravel.ts, network/PlayerSession.ts (sendAddItemActor only). Gate: unit tests; the replay suite, re-baselined in its own commit if the item actors hash in a new order; parity mapping.entities, entities.addactor, blocks.portals; client e2e item-drop-pickup, mining-drops, dimensions, natural-spawn-placement; in game, a stack thrown through a nether portal and a world reloaded with an actor no pack declares. After H-A2 the only subclass of Entity is the player's |
| H-A3 handler tests on ports | none, tests only | The 29 network/handler/*.test.ts files: each stub<Player> becomes a stub of the port its handler takes since F1 and F2. Gate: unit tests |
H-A1 does not edit live.ts, which is H-A2's; H-B2 wires H-A1's facts module. H-A3 edits no source and no existing file under testing/.
Wave H-C, after H-B3:
| Package | Items | Owns |
|---|---|---|
| H-C1 world and entity side | 33, 34 (getActors), 36, 42 for its files | world/World.ts, world/WorldActor.ts, world/Level.ts, world/end/DragonFight.ts, world/providers/, entity/data/entityIndex.ts, entity/data/live.ts, entity/Explosion.ts, entity/Combat.ts, entity/Archery.ts, entity/EntitySerializer.ts, entity/worldContentAdapter.ts, command/CommandExecutor.ts, utils/ParseTargetSelector.ts, utils/ParseTildeCaretNotation.ts, events/entity/, advancement/triggers.ts, content/world/serverSource.ts. Gate: unit tests, the replay suite, every parity family, client e2e attack-entity, summon-entity, ender-dragon, natural-spawn-placement |
| H-C2 sessions, services and the plugin API | 5 (teardown), 29, the preparation for 4, 42 for its files | network/PlayerSession.ts, network/ClientConnection.ts, network/BossBar.ts, network/NetworkWorldReplicator.ts, network/startGameData.ts, network/spawnPosition.ts, network/sessionPlayers.ts, SessionManager.ts, keyedPlayer.ts, events/player/ (11 files), command/ except CommandExecutor.ts, permission/, ban/, console/, advancement/progress.ts, advancement/raise.ts, enchantment/, item/, testing/playerSession.test.helper.ts, testing/blockTarget.test.helper.ts, testing/namedBlockTarget.test.helper.ts. Gate: unit tests, parity protocol.dimension-change, client e2e spawn, rejoin, duplicate-login, chat, dimensions, title |
| H-C3 handlers | 42 for its files | Every source file under network/handler/: the 33 that call session.getPlayer() and the modules beside them, such as workstation.ts and tradeScreen.ts. Each handler reads state from the store player and calls the homes H-B3 named. Gate: unit tests, client e2e movement, jump, eating, mining-drops, block-placement, villager-trade, recipe-book, teleport, death-respawn |
| H-C4 copies, benchmarks and the proxy test | 34 (all()), 43 | entity/data/EntityRuntime.ts, entity/data/storeActors.ts, new entity/data/noLegacyEntity.test.ts, packages/prismarine/benchmarks/ (8 files), packages/parity/src/protocol.dimension-change.test.ts, the docs pages H-D updates. Gate: unit tests, the replay suite with unchanged digests (question 9) |
H-C1 moves the World.getPlayers() callers it owns to getViewers(), and H-D deletes getPlayers. Only H-C2 edits PlayerSession.ts; a package that needs a new session member waits for H-D. H-C4 is the smallest, so it also takes the prose of item 42 in files no other package owns.
4. The test conversion. 126 files name a legacy class, build a legacy player or use a helper that does: 112 tests, 5 helpers under testing/, 8 benchmarks and 1 parity test. 117 of them import a legacy module. By use, and a file can have several: 66 stub Player (stub<Player>, StubMembers<Player>), 25 stub Entity, 20 call fakePlayer(), 15 build or subclass Entity, GenericEntity or Human, 8 use ItemActorBody, 7 call new Player, 6 build from Player.prototype, 6 test instanceof, and 7 only import a type.
| Group | Files | When |
|---|---|---|
| H-A2 | 12: EntitySerializer, Explosion, live, blockBreak, worldDestruction, WorldDrops, WorldPersistence, WorldEntityIndex, WorldAutoSave, WorldReplayState, LevelDB, and benchmarks/entityOnlyChunkSave.ts | wave A |
| H-A3 | 29: every network/handler/*.test.ts | wave A |
| H-B2 | 14: testing/fakePlayer.test.helper.ts, testing/playerDouble.test.helper.ts (deleted), Player, PlayerChangeLog, FallDamage, PlayerPersistence, PlayerPersistenceFormat, SpawnPoint, WorldChangeTick, DimensionTravel, inventory/blockContainer, item/actorPort, item/interaction, enchantment/enchantingTable, and the Player.prototype doubles left in live and blockBreak | H-B2 |
| H-C1 | 27: advancement/triggers, chat/ConsoleText, entity/Armor, Combat, Damage.pipeline, Entity, damageTypes, data/actorMetadata, data/storePlayers, worldContentAdapter, utils/ParseTargetSelector, ParseTildeCaretNotation, and in world/ Level, RespawnSearch, WorldEntityAddition, WorldGeneratedEntities, WorldTickPhases, EngineWorld, PlayerRecords, WorldBreakBlock, WorldHitBlock, WorldPlayerChunkCounts, WorldPortalIndex, WorldViewers, cooking, spawners, end/DragonFight | wave C |
| H-C2 | 36: SessionManager, advancement/progress, command/CommandManager, commandStartup, host/host, host/support, jsprismarineCommands, console/RemoteConsole, events/player/PlayerEvent, permission/PermissionManager, the three testing/ helpers it owns, and 23 in network/: BossBar, ClientConnection, MobEquipment, MovementBatching, NetworkWorldReplicatorEntities, the 14 PlayerSession* files, packet/AvailableCommandsPacket, sessionPlayers, spawnPosition, startGameData | wave C |
| H-C4 | 8: 7 benchmarks and protocol.dimension-change | wave C |
The helpers that have to exist first:
- H-B2 rebuilds
fakePlayer()over a store player in a realEntityRuntime(fromstorePlayerFixture) and keeps its return shape, so its 20 callers change only where they reach legacy-only members. It is the one helper every later group leans on. - H-B2 deletes
withPlayerComponents. Its 6 callers build a store player instead. - H-A2 replaces
new GenericEntityandextends Entitystand-ins with store entities fromliveTestEntities(testing/entityServer.ts), and addstesting/storeActor.test.helper.tsonly if that is not enough. - H-C2 moves
sessionFor,sessionPlayerFieldsandsessionsWithtoSessionPlayers<StorePlayer>, and H-D drops theirPlayerdefaults. Entity.test.tsand theLegacyPlayersblock ofsessionPlayers.test.tsare deleted;Entity.test.ts's id overflow assertion moves toentityIds.ts's test in H-S0.Damage.pipeline.test.ts,PlayerChangeLog.test.tsandFallDamage.test.tstest behavior a player still has, so their assertions move onto a store player and each file goes only when every assertion has a home.
5. What C4h does not do.
- Slice 6 (D1, D2): the handler shape and narrow context,
Serverout of handlers, synchronous input-lane handlers, the eight handlers that load chunks or search,RespawnHandler's search, and building the player before the join. H-C3 changes only what a handler reads the player through. - Slice 7 (D3, D4): replication from the log.
StoreActors.sync's comparisons, the per-tick equipment comparison, the breath and motion sends, a stack network id after an undo, the status effect clock and actor record tags, block and block entity logging, and merchant offers. - Slice 8 (E1): saves at a tick boundary.
- Slice 9 (E2 to E4):
DimensionTravelas messages, worlds built outside ticks, the respawn search's and the dragon fight's in-tick chunk loads, andTickContext'sAsyncLocalStorage. C4h keepsDimensionTravel's awaited transfer and removes only its class checks. - Slice 10: one
chatlistener and the event bus. Slice 11: explicit exports; H-D removes only the deleted modules' exports. - Bugs found earlier stay their own: #2961, #2968, #2969. #2965 closes with item 25.
- H-A2 and H-C4 depend on no player work and can be named packages of their own if the program wants them out of C4h's commitment. Slice 5's target needs both.
6. Open questions.
- Where does a store player live before it spawns and between two worlds? Recommendation:
StorePlayeroutlives its entity. It is filed by an id drawn at the login from H-S0's allocator, holds the record's position, look and health while detached, and attaches an entity throughEntityRuntime.restore(record, at, id)atSetLocalPlayerAsInitializedand on arrival. This matches today, where a joining player is in no world's entity map and no mob can target it, and it is ADR 0011's leave and enter. The other choice, an entity spawned at the login and hidden, needs every query to skip it. - What replaces
Playerfor callers and plugins? Recommendation:session.getPlayer()returns theStorePlayer, operations live where H-B3 puts them, and the ten player events dropgetPlayer()for thegetRuntimeId()andgetIdentity()F4 added plus a newgetSession(). The rootPlayerexport goes. Prismarine is below 1.0.0, so this is aminorChangeset. - Does C4h fold
ItemActorBodyintoStoreActor? Recommendation: yes, as H-A2. It extendsEntity, so slice 5's target cannot pass while it exists. Two of C3b's four paths already have a store home:StoreActor.updateruns the portal clock, andtransferStoreEntitymoves a store entity between stores. - What happens to a saved actor whose type no pack declares? Today it loads as a
GenericEntityand is shown with its identifier. Without the Bedrock pack every saved mob is such an actor. Recommendation: an inert built-in store definition, as H-C2 added for items, that keeps the identifier, the record andpersistentNbt, shows the actor and runs nothing. Dropping the actors or keeping their NBT unseen would make a world loaded without the pack look empty. - Does the switch split into H-B1 and H-B2? Recommendation: yes. H-B1 changes only types and holds the digests, so H-B2's diff is behavior alone and its digest change has one cause.
- Where does the game mode live? Recommendation: a logged component field, which
SurvivalPlayer.vulnerablereads and the store undoes, replacing the adapter'sgamemodefield.StorePlayer.gamemodebecomes an accessor over it. - Which ground state stays? Recommendation: the body's
on_groundcondition, whichapplyClientMovementwrites. Deletemovement.onGround; the digest change lands in H-B2's re-baseline. - Which clock does a meal count on? Recommendation: the store's tick, which the change log and the entity pass already count by. The level clock moves backwards under
/time set. - How does the entity pass walk the store without a copy? Recommendation: iterate the store's map in insertion order and skip an entity added after the pass began, by a sequence number the store stamps on insertion. The copy left out the same entities, so the digests do not change.
- What replaces
World.getActors' sorted copy? Recommendation: the content adapter'sat,nearandcountOfTypequery the index, which runs inside the tick. Selectors and the save context iterate the store and sort only what they return, and the digest sorts, as it runs outside the tick.
H-S0, the seams, left these for the later steps. Items 3, 7, 17, 19, 20 (world side), 23 and 32 are done, and so is the entry point of item 6.
- One id pool:
entityIdsinentity/entityIds.ts, anEntityIdAllocator. The legacy constructor andLiveEntityRuntime'snextIddraw from it, andEntity.runtimeIdCountis gone. The overflow guard the legacy constructor had now covers the store's ids too.Entity.test.ts's overflow assertion moved toentityIds.test.ts. EYE_HEIGHTandeyePositionAtlive inentity/playerEyes.ts. The portal cooldown clamp had a third copy, inStoreActor;portalCooldownOfinPortalContact.tsis the one home.- A player enters a world through
World.addPlayerand leaves throughWorld.removePlayer.addPlayerfiles the player in the player map and then callsaddEntity, which no longer files anything there;removePlayercallsremoveEntity, which still deletes the id from the player map, so the content host'sremovebehaves as before.SetLocalPlayerAsInitializedHandler,PlayerSession.changeWorldandPlayer.disablecall them. H-B2 points both at the store. - Two rules replaced the class tests. Where the code needs the player's state, it finds the session by entity id with
World.viewerOfand readssession.getPlayer():DimensionTravel's mid-change check, transfer and End exit, the command host'splayerSessionOf, the item actor port, and the live host's trade screen, filter view and swing relay. Where it needs only to know that an entity is a player, it tests the type againstPLAYER_IDENTIFIER: the PvP rule, nether portal creation, the End arrival height, andWorld's tick exemption, attribute flush and chunk membership. The replay digest reads the player from the world's player map by id. The live host's filter view reads a structuralFilteredPlayer. - The store side of items 19 and 20: a store entity of type
minecraft:playerticks outside the ticking chunks, stays in the store when its chunk unloads, and does not makemarkEntitiesInChunkChangedmark the chunk. No live player is a store entity yet, so nothing changed at run time. - Not closed: the entity index's
chunkChangedcallback (World.storeEntityChunkChanged, wired inlive.ts) marks a chunk changed when any store entity crosses into it, a store player included. A changed chunk is saved before it can unload, and the save skips players, so the cost is an extra save. It gets only the chunk coordinates, so the fix belongs beside the index: H-B2, which spawns store players and tests that they stay out of chunk saves. viewerOfanswers only for a player in the world's player map, which the plan named as the risk. Every production caller reaches a player through that world's portal pass, entity update, entity lookup or tick, so each one is in the map. The test doubles that built a player outside it now put it there or answerviewerOf:DimensionTravel,WorldTickPhases,item/interaction,item/actorPort,world/blockBreak,advancement/triggers,command/host/host,CommandManager, the trade double inlive,fakePlayer, the parity familyprotocol.dimension-change, and the tests that calledaddEntitywith a player.WorldViewers.test.tschecks that a player added throughaddPlayeris found beforeentityAddedruns and not afterremovePlayer.- One edge case remains, as with F3's lookups: if
Player.disablethrows beforeremovePlayer,ClientConnectionstill runsPlayerSession.disable. The player then stays in its world's map with a forgotten session, andsession.getPlayer()throws whereinstanceof Playerpassed. - What still names the class. As a value:
Player.ts,Entities.ts, the root export inindex.ts(H-D) andLoginHandler.ts, which builds the player (H-B2). As a type only: the session directory (sessionPlayers.ts,createConnectedPlayer.ts,PlayerSession.ts,SessionManager.ts), the elevenevents/playerclasses (H-C2),InventoryTransactionHandler,ItemStackRequestHandlerandworkstation.ts(H-C3), andWorld.ts's player map,getPlayers,breakBlockandaddPlayer(H-C1, H-D). Nothing outside tests usesinstanceof Playerorinstanceof Human. - Copies found on the way, for item 42's packages: the literal
'minecraft:player'is repeated in fourteen source files besidePLAYER_IDENTIFIER(among themDeathMessages.ts,worldContentAdapter.ts,mobLoot.ts,advancement/triggers.ts,command/host/support.ts,goalWorld.ts,item/interaction.ts,ParseTargetSelector.tsandChunkSaveContext.ts), andExplosion.tsandentity/data/explosion.tseach define an eye height fraction of 0.85 thatENTITY_EYE_HEIGHT_FRACTIONinentity/data/position.tsalready holds. - Checks at program head
d2c8ec355f: the Prismarine suite withJSP_CONTENT_VANILLA=1, the replay suite unchanged, the whole parity suite, and client e2espawn,rejoin,dimensions,death-respawn,attack-entityanditem-drop-pickuppass.
H-A1, the store player, left these for H-B2. Items 2, 8, 9, 10, 12 (the facts module), 14 (the system calls), 15 (the projection), 20 (the store pass) and 21 (the hook) are done. Item 11 is done for the store player and leaves one field for H-B2.
- A
StorePlayeroutlives its entity (question 1). It is built from an id and the identity,new StorePlayer(id, identity), and holds its position, look and health while it is in no store, health in its attribute table's entry.enterStore(runtime, players, player)files it and gives it an entity under its id:EntityRuntime.spawnPlayerthe first time, andEntityRuntime.restore(record, at, id, prepare)from the record its last entity left with after that. The player attaches in the prepare step, before the entity enters, so what it held is part of the entering entry and the load appends nothing else.leaveStore(runtime, player, reason)keeps the record, the tags and the status effects and removes the entity, and the player stays filed.StorePlayers.deleteis still the server leave.spawnStorePlayerbuilds, fills and enters in one call, drawing the id fromEntityRuntime.nextEntityId; H-B2's login draws it fromentityIdsinstead and enters atSetLocalPlayerAsInitialized. StorePlayerActortakes the entity beside the player, andStorePlayers.actorOfbuilds one only for the entity the player is attached to.- Logging (items 8 and 9). The player owns one
PlayerComponents, oneHumanInventoryand now aCraftingInputfrom the login on.PlayerSystems.attach(entity, parts)takes the components and two writers, and returns thePlayerLogthe player writes through while attached: component fields andinventory.handSlotthroughEntityStore.written, and slots throughEntityStore.writtenSlotonce the entity has entered.EntityRuntime.writeInverseundoes aninventory-slotentry andinventory.handSlotthrough the player's writers, which is whatHuman.restoreLoggedSlotandrestoreLoggedFielddo for a legacy player.StorePlayer.restoreSlotrepeatsHuman.restoreLoggedSlot's body, becauseHuman.tswas outside this package; the copy goes when H-B2 deletesHuman.ts. - Game mode (item 10, question 6) is the component field
abilities.gamemode, andStorePlayer.gamemodeis an accessor over it. A fresh player's components start in survival, where the old field started atWORLD_DEFAULT, so a rawminecraft:playerspawned in a test still starves.survivalReaches(survivalPlayer.ts) andmayFlyIn(playerComponents.ts) are the rules' homes;playerRecord.tsreadsmayFlyIn.Playerkeeps its own copies of both and itsgamemodefield, which the legacy adapter logs asgamemode. The legacy player never writesabilities.gamemode, so itscomponentSnapshotgained one constant entry and the absolute digest of a world with a player changed. The replay suite compares two replays and passes unchanged. - Ground (item 11, question 7). A store player's ground is its body's
on_groundcondition, whichStorePlayer.isOnGroundreads, and nothing reads or writesmovement.onGroundfor one. Deleting the field needsPlayer.ts, which reads it, so H-B2 deletes it, with the digest change in its re-baseline. - The meal (item 12, question 8) counts on
EntityStore.tick, the change log's tick, which the systems reach through thetickport.storePlayerHostFacts(players, finish)(entity/data/playerHostFacts.ts) answersheldItemOfandfinishMealfromStorePlayers. Finishing is handed in, becauseconsumeHeldItemtakes anInteractingPlayer, which a store player does not satisfy; H-B2 supplies it when it spreads the facts into the live host. - The system calls (item 14):
PlayerSystems.setSprinting,setSneakingandjumped, beside the existingsetMining,beginMealandcancelMeal;EntityRuntime.teleportPlayer, the server's move, which teleports the body and ends the fall; andmoveStorePlayer, which callsapplyClientMovementand writes the head yaw. Nothing calls them yet: H-B2 routes the handlers'Playermethods to them. - Metadata (item 15).
StorePlayerActor.showPlayerStatewrites air, breathing, sprinting and can-fly from the components into the actor's metadata and answers whether a send is due, with the legacy throttle, nowbreathWorthSendinginplayerBreath.ts. The constructor calls it, so the firstAddPlayercarries them.StoreActors.syncdoes not call it, becausestoreActors.tsis H-A2's. H-B2 calls it after the component projection, which writes its own can-fly flag, and sends the metadata when it answers true.Player.syncBreathkeeps its copy of the throttle until the class goes. - The store pass (item 20) ticks a
minecraft:playerwherever it stands, as the world's loops do. - The hook (item 21) is
EntityRuntime.onPlayerFailed, a settable field likerides.onChange, because the fixture inentity/data/testing.tspasses no new options and that file was outside this package. AfterENTITY_FAILURE_LIMITfailures in a row the runtime calls it in place of removing a player. It fires again on every later failure while the player stays in the store, so H-B2's disconnect has to tolerate a second call. Left unset, the player is removed as a mob is. - What is not done: no live code builds, enters or ticks a store player, and
live.tsis untouched. H-B2 wiresonPlayerFailed,storePlayerHostFacts,showPlayerState, the system calls and the login, and deletesmovement.onGround,Player.syncBreath's throttle and the copies of the game mode rules. - Checks at H-A1's head: the Prismarine suite with
JSP_CONTENT_VANILLA=1(428 files),PlayerPersistenceFormat.test.tsbyte for byte, the whole parity suite withentities.behavior(player-health,player-survival),SessionReplay.test.ts,pnpm run typecheck,pnpm run lintandpnpm run lint:custompass.
H-A2, the bodies that are not players, left these for the later steps. Items 37, 38 and 39 are done, and so are questions 3 and 4.
- A dropped stack (item 37) has an
ItemActor(entity/data/itemActor.ts), aStoreActorthatStoreActorsbuilds through a newbuiltInActoroption besideplayerActor. It keeps what its legacy body sent: anAddItemActorfromspawnTo, a tracking range of six chunks, never on the ground, no metadata (StoreActor.projectMetadataanswers an empty projection) and the default attributes, never sent (StoreActor.showHealthdoes nothing for it). A blow goes toDroppedItems.hurt, and theentityDamagehook anddamageCommittedskip an item actor, because a stack's body raised no plugin event or trigger. - Its portal clock runs in
StoreActor.update.DimensionTravel.transferStoreEntitymoves it withLiveEntityRuntime.adoptItem(actor, at, cooldown), which spawns the stack in the destination fromsavedItem, as the legacy transfer did, rather than from the data entity record, so the motion carries over and the stack's saved NBT gains no record.persistandsavedRecordsskip item and inert actors for the same reason. A stack leaves an unloading chunk with the chunk's other store entities.ItemBodies,LiveEntityRuntime.unloadandremoveBodyare gone, and so isWorld.addEntity's adoption of a body. World.ITEM_ENTITY_LIMIT(item 39) countsStoreActors.droppedItemCount, kept as actors are added and removed.EntitySerializer.fromNBT(item 38) reads a plainStoredActorand builds no entity.World.restoreDataEntitytakes it to the item path, to the record orrestoreExisting, and then toLiveEntityRuntime.restoreInert(question 4). The inert type is the entity overlay'sjsprismarine:inert_actor, which has health, the twenty a legacy body defaulted to, and nothing else. Its actor is anInertActor(storeActors.ts) that reports the saved identifier throughgetTypeandgetBox; the live runtime files the identifier by entity id in the spawn's prepare step, before the actor is built. It keeps the actor record tags, a record of an undeclared type among them, the tags, the status effects, the portal cooldown and a TNT's fuse, and a save writes it back unchanged. Through a portal it is restored inert in the destination; its health does not carry over, where a legacyGenericEntitykept its own.- The level snapshot builds a
StoredActorand skipsminecraft:player, whichLevel.entitySnapshotsnever writes. A snapshot of a dropped stack, which has no stack, no longer becomes a body in the world. - The player body is the one subclass of
Entityin the server's source, whichnoMirror.test.tsnow checks.GenericEntitymoved totesting/genericEntity.test.helper.tsfor the tests that still hand a legacy mob to code that takes anEntity:Combat,ParseTargetSelector,WorldChangeTick,DimensionTravel's legacy mobs,WorldEntityIndex's legacy-half tests, and, of H-A2's own group,Explosion,WorldAutoSave,WorldReplayStateandbenchmarks/entityOnlyChunkSave.ts. Each goes when H-C1 or H-B2 narrows the code it tests.Entities.writtenEntitiesis gone; the prose ofSpawnableEntityClassinEntity.tsstill names it, for H-D. - Outside the file list, for the inert type and the test conversion: the overlay file,
content/testing.ts's built-in list,content/packStack.test.ts, which allows the one non-minecraft:type, the prose ofcontent/README.mdanddocs/data-driven/entities.md,inventory/blockContainer.test.ts,world/WorldBreakBlock.test.ts, and two helpers,testing/savedActor.test.helper.tsandtesting/storeWorld.test.helper.ts. - No digest changed. An item actor hashes the legacy body's attributes, and
World.getActorssorts by runtime id, so the stacks hash in the same order. The replay suite needed no re-baseline. - Found:
/summonignoresis_summonable, so it also spawnsjsprismarine:inert_actor, as an invisible entity (#2975), and a saved actor without a record comes back with its health capped at twenty, whichrestoreExistingkeeps from the legacy body (#2976). - In-game checks for the orchestrator's combined pass: a stack thrown into a nether portal arrives in the Nether with its count and can be picked up there; and a world saved with mobs, loaded by a server started without the Bedrock pack, shows each mob where it stood, standing still, and after a restart with the pack the mobs come back alive.
- Checks at H-A2's head, on program head
13f30f1407: the Prismarine suite withJSP_CONTENT_VANILLA=1(429 files),SessionReplay.test.ts, the whole parity suite withmapping.entities,entities.addactorandblocks.portals, the entities package, client e2eitem-drop-pickup,mining-drops,dimensionsandnatural-spawn-placement,pnpm run typecheck,pnpm run lintandpnpm run lint:custompass.
H-B1, the player body, left these for H-B2. No behavior changed and no digest moved.
- The shape.
Playerno longer extendsHuman. It builds aLegacyPlayerBody(entity/LegacyPlayerBody.ts) in its constructor and returns it fromPlayer.getBody(). The body is aHumanthat implementsShownPlayerand holdspublic readonly player, asStorePlayerActordoes, and answers the profile members (getUUID,xuid,skin,device,gamemode,isOp,isFlying,getName,getFormattedUsername) from the player. H-B2's swap isPlayer.bodyandgetBody()'s return type, from the body to the spawned store player's actor and itsStorePlayer. - What holds the body: the world's entity map (
World.addPlayerandremovePlayerpass it toaddEntityandremoveEntity), the entity index, the chunk membership writes, the replicator, the change log'slegacyStoreOfand the legacy inverse writer. The world's player map still holds thePlayer, becausegetPlayersandviewerOfhand out players and sessions; H-D deletesgetPlayers. - What the body keeps: the entity state, the inventory and crafting grid, and the
PlayerComponentswith the survival view (getComponents,getSurvival).Playerreads them through two private accessors,componentsandsurvival, which H-B2 points at theStorePlayer. The body's overrides are the player's entity rules: the damage gate, knockback as an impulse (Player.applyImpulse), the hunger effects, the logged attributes, component undo, the portal delays and the held cooldown, the tracking range and interval,spawnToandisOnGround. Each has a store counterpart already, named in items 9, 14, 15 and 22, exceptprocessPortalCooldown's hold during a dimension change, whichStoreActor's portal clock does not have yet. - The tick and the death go back to the player. The world ticks the body;
LegacyPlayerBody.updaterunsEntity.updateand thenPlayer.updatePlayer, the session, vitals, meal, void, mining, attribute sync and trigger part.Player.updateruns the body's. The body'sonDeathcallsPlayer.die(source, dieAsEntity), which runsEntity.onDeathat the point the oldsuper.onDeathcall stood. Once the store pass ticks the player, something has to callupdatePlayer, or its parts move toPlayerSystemsand the session, as H-B3 plans. Playerdelegates what its callers read: the id, type, position, world, server, box, inventory, crafting grid, health,setHealth,damage,heal, feet and eye height, equipment, tags, status effects, portal cooldown, andmetadata,attributes,pitch,yawandheadYawas accessors. It does not implementspawnToorgetTrackingRange, so it is noShownActor, and the compiler finds every show. These delegates are the second half of H-B2's swap.- Where source passes the body: a swing (
meleeAttack) and an arrow (loose) inInventoryTransactionHandler, the move broadcasts and resets inMovePlayerHandler,ActorEventHandler's relay,RequestPermissionsHandler's abilities, the death message and animation, the change log writes inPlayer, the content adapter'splayers, the command host's@a,@pand@rcandidates, and every show inPlayer(moves, abilities, game type, status effects). The session shows its own player throughPlayerSession.shown(), which readsgetPlayer().getBody();OwnPlayerlost itsShownPlayerhalf.item/actorPort.tshandsinteractWithEntitythe entity the world answers for the source rather than the player. Of these,addEntity,removeEntity,meleeAttack,looseand thelogLegacy*writers take a nominalEntityand need retyping in H-B2; the rest take portsStorePlayerActorsatisfies. - The identity checks. Every comparison entity code makes now compares bodies, because the world answers the body for the player's id and every caller above passes it:
legacyStoreOf'sworld.getEntity(id) !== entity,canHarm'sattacker === target,deathMessage'skiller !== victim,World.entityRelocated's map check andEntityIndex's. Two comparisons stay between players, because both sides come from the session manager orgetPlayers:Player.sendSettings'target !== thisand the shared-container check inItemStackRequestHandler.entity/LegacyPlayerBody.test.tscovers the world's answer, a swing at the player's own id and a self-inflicted death;WorldViewers.test.tsandPlayerChangeLog.test.tsnow assert the body where they asserted the player, and the change log test's world double answered the player, which the identity check would have refused. - Unchecked narrowing.
advancement/triggers.tsnarrowed an actor of typeminecraft:playertoAdvancementPlayerwith a type guard, which a body would not satisfy. It now checks the type and whether anything listens, then finds the player throughWorld.viewerOfby entity id, which also serves a store player.ParseTargetSelector's guard reads onlygamemode, and the inverse writer's'gamemode' in entitywrites through the body'sgamemodesetter. - The plugin surface.
entityDamageandentityDeathcarry the body for a player; the player events still carry thePlayer. The Changeset (player-holds-a-body.md,minor) says so and lists what moved to the body. - Test doubles.
playerOnPrototype(members, body)intesting/playerDouble.test.helper.tsbuilds a prototype player with a prototype body, putsmetadata,attributesand the rotation on the body, andwithPlayerComponentsnow writes the body's components. 16 test files call it; H-B2 deletes the file and its callers build store players.stubPlayerPort's doubles answergetBodywith a plain object of the same members. A test that hands a player to entity code passesgetBody(), and a double the world holds is astubOf(LegacyPlayerBody, ...). - Checks at H-B1's head, on program head
baea11e568: the Prismarine suite withJSP_CONTENT_VANILLA=1(430 files),SessionReplay.test.tswith no baseline touched, the whole parity suite (53 files), all 43 client e2e scenarios in fifteen batches with none failing,pnpm run typecheck,pnpm run lintandpnpm run lint:custompass. No adjacent bug was confirmed.
H-B2, the body is the store entity, left these for H-B3. Items 1, 5 (store side), 6, 12, 14 (through Player's methods), 21, 22, 24, 25, 26 and 35 are done, and so is the regression test item 18 asked for.
- The shape. A
Playerholds aStorePlayerfrom its constructor on, under an id fromentityIds, and keeps no entity state. It keeps three references the store player does not: the world it is in or about to enter, the server, and aDetachedPlayerView.World.addPlayerfiles the player in the world's player map and callsenterStorewithServer.getStorePlayers()(EntityRuntimes.players).World.removePlayertakes it out of the map first and then callsleaveStore.Player.disablecallsStorePlayers.delete. A dimension change and a respawn into another dimension are a leave and an enter with the same id.LegacyPlayerBodyis deleted. - Two accessors replace the body.
Player.getActor()returns theStorePlayerActorwhile the entity is in the store of the player's world, and undefined before the spawn and between two worlds. Entity code takes the actor: a swing, an arrow, the content adapter's players, the command selectors andActorEventHandler.Player.getBody()returns the actor or the detached view, which is aShownPlayerover the store player that the session shows its own player through before the spawn. Question 1 holds: no world holds a player that has not spawned, so nothing targets or shows it.entity/playerActor.test.tschecks that, and thatPlayer.damagedoes nothing to such a player. - One damage path (item 22). A blow on a player runs
DamagePipelinethrough its actor.intentOfLegacyBlowgives a player a mob's rules, since the shipped player is of theplayerfamily alone. A newgame_modeguard (PlayerSystems.reaches) turns aside every blow in creative but the void and/kill, and every blow in spectator. That isLegacyPlayerBody.damage's rule. One change:/killon a spectator, which the legacysetHealthreached, is now refused. A mob's bite, area attack and projectile hit a store player as a store target, solive.ts'shurt,hurtByProjectileandapplySonicBoomno longer meet a player. The live host reads a player's armor from its inventory. The pipeline spends a player's absorption fromattributes.absorption, which its entity has no attribute for. A shove on a player logs theimpulseoccurrence and the new host factplayerImpulsesends it to the client. - Death and respawn (item 24). The store keeps a dead player
dying.damageCommittedinlive.tscallsPlayer.die(source)after theentityDeathevent and the kill triggers, for the death message, the respawn request and the death screen.Player.respawncallsEntityRuntime.revive, which now also revives a player restored with no health into another store on its way to a spawn point in another dimension. - The tick.
StorePlayerActor.updateruns the actor's part and thenPlayer.updatePlayer, which is now the session, the copy of the entity's health into the client's attribute table, the attribute sync, the advancement check and the time. Breath, hunger, the void, the meal on the store's tick and the crack animation run inPlayerSystems. A player keeps its one actor update a tick inStorePlayer.claimUpdate, because a dimension change mid-tick gives it a new actor in a world the level ticks later. The client sees a health change one tick after the store makes it. - The inputs (item 14).
setMining,beginConsuming,cancelConsuming,setSprinting,setSneaking,addJumpExhaustionandaddExhaustioncall the systems. A client move goes throughmoveStorePlayer,setOnGroundreports a move to where the player stands, and a server move goes throughEntityRuntime.teleportPlayer. Every move broadcasts the actor, so a head-only turn still reaches viewers.Player.isSprinting()replaces readingmetadata.sprinting. - Metadata.
StoreActors.synccallsshowPlayerStateafter the projection through aStoreActormethod a plain actor answers false to.setGamemodewritesabilities.gamemode, callsshowPlayerStateand sends the metadata at once. The client's own metadata is the actor's while the player is in a store and the store player'smetadatawhile it is in none.Player.syncBreath, its throttle copy,Player's game mode rule copies andmovement.onGroundare gone. - Equipment (item 25).
MobEquipmentHandleronly moves the hand.NetworkWorldReplicator.entityEquipmentChangedno longer sends the owner a copy, which named hotbar slot zero and would have moved the client's selection. - Scans that read the store and then the host's players (
brainWorld.livingNear,goalWorld.mobs, the area attack, the entity sensor, the projectile hit) skip a client-owned store entity, so they meet a player once, through the view that knows its game mode. Before this, a piglin and a warden targeted a creative player inlive.test.ts. A store blast takes its candidates from the index, which now holds the player, so it hits a player through the store. The entity index reports no chunk for a player's move, which closes the gap H-S0 left. - A pack stack with no
minecraft:playerfile getsFALLBACK_PLAYER_FILE(playerOverlay.ts), so a server without the Bedrock pack still spawns players, and the fixture content has a player. A portal arrival's offset is now worked out over the player's 0.6 box where the fixture's default box was a block wide. - Deleted:
Human.ts,Entities.tswithEntities.HumanandEntities.Playerand the rootEntitiesexport, the player half oflegacyAdapter.ts(logLegacySlot,logLegacyImpulse, the player fields and the inventory-slot undo),Entity.restoreLoggedFieldandrestoreLoggedSlot,testing/playerDouble.test.helper.tsandwithPlayerComponents. Nothing subclassesEntityin source, whichnoMirror.test.tsnow asserts. The barrel baseline lost one entry. - The digest (item 35). The player branch reads the
StorePlayerActor's store player, and the component snapshot lostmovement.onGround, in a commit of its own. The replay suite compares two replays and passed unchanged. - Test doubles.
fakePlayer()enters a store player into a realLiveEntityRuntimeof the fixture content and returnsliveandclockbeside its old fields; its world'sremovePlayerleaves the store.playerOnPrototype(members, { runtimeId, position, actor })intesting/storePlayer.test.helper.tsbuilds a prototype player over a realStorePlayer, and itsactoris aStorePlayerActordouble.stubPlayerPortanswersgetActor. Tests of rules that moved toPlayerSystems(breath, hunger over time, meals, crack animation, the void, the walking cost) lost theirPlayer.test.tscopies, andFallDamage.test.tsruns a store player.Level.test.ts's cross-world tick case moved tostorePlayers.test.ts.
What H-B3 must know:
- The homes are still on
Player. Most simulation methods already callPlayerSystemsorEntityRuntimethroughPlayer.placed(), so moving them is moving one call each. The sends (completeSpawn,sendSettings,setGamemode's sends,setFlying's, the death screen indie) are the session's. Playerkeepsworldandserver, which aStorePlayerhas no way to answer. Either the session keeps them, or the store player learns its runtime.- The session directory is still
LegacyPlayersofPlayer(item 4, H-D), andPlayerSession.shown()readsgetBody(). ParseTargetSelectorand thegetEntity/getEntitieshalf ofWorldstill typeEntity. Nothing live is one, so H-C1 can narrow them freely.Player.getPosition()builds aPositionover the player's world on every call. A caller in a loop should read the store player.- Not done: no parity family covers the
game_modeguard, because it keeps the legacy rule, and the spectator half of Java's answer for the void was not confirmed against Java.
In-game checks for the orchestrator's combined pass, with two vanilla clients A and B on one server:
- A joins. B joins and sees A appear with its skin and name in the world and in the player list.
- A walks, sprints, jumps, sneaks and turns its head only. B sees each, the head turn included.
- A changes the held hotbar slot, puts on a helmet and holds a shield in the off hand. B sees each change at once. A's own hotbar selection does not jump to slot one.
- A switches to creative. B sees the game mode change. A is not attacked by a zombie or a piglin, and arrows pass it. A switches back to survival and the zombie attacks.
- A eats a golden apple, then takes a zombie's bite. The yellow hearts go first.
- A sprints until the food bar drops, swims under water until the bubbles go and drowns, and B sees the death animation. A respawns with full food and air.
- A falls eleven blocks onto stone and onto a hay bale. Only the stone costs health.
- A mines a block slowly while B breaks it. A's cracks stop.
- A walks through a nether portal and back. B sees A leave and come back. A's cooldown holds while its loading screen is up.
- A sets a bed in the Overworld, goes to the Nether and dies there. A respawns at the bed.
- A leaves. B sees A leave the world and the player list. A logs back in where it left, with its inventory and health.
- Checks at H-B2's head,
8b8e0e51f7on program headd4c6a9a1f2: the Prismarine suite withJSP_CONTENT_VANILLA=1(431 files, 4348 tests),PlayerPersistenceFormat.test.tsbyte for byte,SessionReplay.test.ts, the whole parity suite (53 files), all 41 client e2e scenarios in fourteen batches with none failing or flaky,pnpm run typecheck,pnpm run lintandpnpm run lint:custompass.
H-B3, the homes, left these for waves H-C and H-D. Items 28 and 30 are done, and every method the step named has its home. No behavior changed and no digest moved.
- The client operations are the session's.
PlayerSession.completeSpawn,announceSettings(whatPlayer.sendSettingssends, each half to its audience),getJoinSpawnandsetJoinSpawn,sendInitialSpawnChunks,sendDeathScreen(theRespawnandDeathInfoa death sends once the respawn search answers), and the two halves of a game mode change's sends,sendGameTypeChangeandsendAbilityChange. The session's own join and world change callsendInitialSpawnChunkson themselves, soOwnPlayerlost it.announceSettingsfans the abilities out overSessionManager.getSessions, which lists the sessions of the playersgetAllPlayersdid, in the same order.Player.sendMessagewas already one call intoPlayerSession.sendMessage; it only turns its positional arguments into the delivery, so it moved nothing. - The simulation is the entity runtime's.
EntityRuntimetakes a store player's inputs:setPlayerMining,beginPlayerMeal,cancelPlayerMeal,setPlayerSprinting,setPlayerSneaking,setPlayerGamemode,setPlayerFlying,exhaustPlayer,playerJumpedandrevivePlayer. Each takes aPlayerInputTarget(the store player'sentityandgetComponents), writes throughPlayerSystemswhileentityOfPlayerfinds the entity in this store, and onto the components, or not at all, while it is in none. That is whatPlayer.placed()decided, andplaced()now readsentityOfPlayer.PlayerSystemsgainedsetGamemodeandsetFlying. The rules stay inPlayerSystems; the runtime methods only choose between the two branches. - What stays on
Playerbesides one-line delegates: the plugin events and the guards before them (setGamemode,setFlyingandsetSprintingpost their events,setSneakingandsetSprintingskip a write that changes nothing), and the order of the parts where one method spans both homes.setGamemodeis the event,stopMining,setPlayerGamemode,sendGameTypeChange,setFlying(false)for a mode that may not fly, thensendAbilityChange.respawnisrevivePlayer, the own-health copy,showPlayerState,getRespawnPointandsetPlayerPosition, then the session'ssendRespawnedandreleaseChangeHeldOverDeath. The revival has to come before the respawn search's wait, so the two cannot become one runtime call without changing when the player is alive.diekeeps the death message, the respawn request and the death animation and chat line; its death screen issendDeathScreen.Playeralso still holdsworld,server, the detached view, the address, the permissions, the timer,connectedand the chat recipient, andenable,disable,getRespawnPoint,forgetLostSpawnPoint,setPlayerPosition,setOnGround,updatePlayerand the item-use clock. - Item 28.
playerRecordOf(player, keyed, dimension)takes aKeyedPlayer, andWorld.savePlayerDatatakes anyPlayerRecordSourcewith a name and an XUID, aStorePlayeramong them, building the key and taking the server from the world.LivePlayerRecordSourceis gone.PlayerPersistenceFormat.test.tsnow writes the store player's record throughWorld.savePlayerData, byte for byte against the fixtures. - Item 30.
chatRecipientOf({ runtimeId, identity }, server)inchat/ChatManager.tsbuilds the recipient from what a session holds: the key and name from the identity, the operator check from the permission list by UUID, and each line sent toSessionManager.sessionOf(runtimeId). It matches by entity id rather than by name or XUID, because a replaced login can briefly leave two sessions of one account connected. A line for a player who has left reaches nobody, wheregetNetworkSession()asserted.Playerbuilds its recipient with it, so H-C2 or H-D can build it from the session. - For H-C3: a handler that called one of these methods on
session.getPlayer()calls the home instead: the session's method onsession, orsession.getWorld().dataEntities().runtime.setPlayerX(storePlayer, ...)for an input. The runtime methods readworld.dataEntities()even for a player in no store, which builds the dimension's content adapter if nothing had; a live player's world always has one by then. A handler that posts a player event keeps calling thePlayermethod until H-C2 settles what the events carry.fakePlayer's session manager now answersgetSessions. - For H-D:
Player.getJoinSpawnand the join spawn writes inPlayer.enablego whenenablemoves; the session field is already the home.sendGameTypeChangeandsendAbilityChangereadthis.getPlayer().getActor()andgetWorld(), which H-D answers from the store player and the session. - Checks at H-B3's head, on program head
3426f22bdb: the Prismarine suite withJSP_CONTENT_VANILLA=1(431 files, 4355 tests),SessionReplay.test.tswith no baseline touched, the whole parity suite (53 files), client e2espawn,chat,eating,mining-drops,teleport,death-respawnanddimensionsin three batches,pnpm run typecheck,pnpm run lintandpnpm run lint:custompass. No adjacent bug was confirmed.
H-C3, the handlers, left these for H-D. Item 42 is done for network/handler/, and no handler's behavior changed.
- The shape.
playerPorts.tsholds two helpers and no ports:storePlayerOf(session)is the session'sStorePlayer,session.getPlayer().getStorePlayer()for now, andplayerInputsOf(session)issession.getWorld().dataEntities().runtime.KickedPlayer,PlacedPlayer,InventoryHolder,CraftingHolderandFlyingPlayerare gone, and so are the one-handler portsJoiningPlayer,SpawningPlayer,MovedPlayer,RespawningPlayer,ActingPlayerandReskinnedPlayer.GamemodePlayerstays, becauseSetPlayerGameTypeHandlerreads only what the permission check andsetGamemodetake. H-D's switch in the handlers is the body ofstorePlayerOf, which becomessession.getPlayer(). - What a handler reads of the store player: the inventory, the crafting grid, the eyes, the look, the ground state, the health, the game mode, the flight, the skin and the components (
mining.block,movement.sleeping,movement.sprinting,vitals.fallDistanceand the experience a trade pays). The world is the session's everywhere, and so are the kick, the join spawn,announceSettingsandcompleteSpawn. The inputs go to the runtime:setPlayerMining,setPlayerSneaking,playerJumped,exhaustPlayer,beginPlayerMealandcancelPlayerMeal.movement.sleepinghas no runtime input, soPlayerActionHandlerwrites it on the components, asPlayer.setSleepingdid. - Session lookups in place of player lookups. The arm swing, the emote and the shared-container fan-out walk
World.getViewers(), the skin relaySessionManager.getSessions(), and the permission target isSessionManager.sessionOf. A sender's op status isPermissionManager.isOp(session.identity.uuid).blockTargetRejectiontakes the session and readsgetViewPositionandgetWorld. A sound excludes the sender bysession.runtimeId, and its actor type is compared withPLAYER_IDENTIFIER. - What still calls
session.getPlayer(), and why:- Methods that post a player event:
setSprintingandsetFlying(PlayerActionHandler,RequestAbilityHandler),setGamemode(SetPlayerGameTypeHandler,SetDefaultGameTypeHandler), and thePlayerthatPlayerMoveEventandPlayerSpawnEventcarry. - What the player still keeps, which H-B3 left there:
respawn,getRespawnPoint, a move'ssetPlayerPositionandsetOnGround,getBody,getActor, and the item-use clock (beginItemUse,takeItemUseTicks). - Callees in other packages that take a
Player, or a port the store player does not satisfy: the content interactions (InteractingPlayer),World.breakBlock,World.hitBlock's striker,World.addPlayer,createStartGameData,playerSpawnPositionPacket,CommandManager.dispatchCommand, the chat sender,PermissionManager.can,advancementItemCrafted,containerAnchorOfandopenContainerSlots, and the enchanting and anvil codeworkstation.tscalls, which now takes aWorkstationPlayerport. LoginHandlerstill builds thePlayer, which is H-D's.
- Methods that post a player event:
- Doubles.
storePlayerPort(store, port)(network/handler/storePlayerPort.test.helper.ts) builds aPlayerdouble whosegetStorePlayeranswers a store player double, andsessionSeeingBlocks(blockTargetSession.test.helper.ts) addsgetViewPositionandgetWorldtosessionNamedNearBlocks' session. Tests of the input handlers give the world adataEntitieswhose runtime double records the inputs, over a realPlayerComponents. The four handler tests H-A3 left onPlayer(ItemStackRequestHandler, its replay,workstationandBossEventHandler) build ports or a session instead;BossEventHandler.test.tsstill takes its session fromfakePlayer(). - For H-D:
testing/playerPort.test.helper.ts' prose names the deletedKickedPlayerandPlacedPlayer, and no wave H-C package owns that file.PlayerActionHandlermaps betweenPlayerMiningandBlockMiningitself wherePlayer.getMiningandsetMiningdid, becauseworld/BlockMining.tswas H-C1's; oncePlayergoes, a null-tolerant pair besideblockMiningOfis the one home. - Checks at H-C3's head, on program head
48e6a23ee1: the Prismarine suite withJSP_CONTENT_VANILLA=1(429 files and 4355 tests passed, 2 files skipped),SessionReplay.test.tswith no baseline touched, client e2emovement,jump,eating,mining-drops,block-placement,villager-trade,recipe-book,teleportanddeath-respawnin three batches,pnpm run typecheck,pnpm run lintandpnpm run lint:custompass. No adjacent bug was confirmed.
H-C2, the sessions, services and the plugin API, left these for H-D. Item 5's teardown and question 2 are done. Of item 29, only the boss bar's legacy form is gone; every other one still has a caller outside H-C2's files. Item 4 is prepared, and item 42 is done for H-C2's files. No behavior changed but the plugin API: question 2's events, and the boss bar.
- The player events (question 2).
PlayerEvent.getPlayer()is gone, andgetSession()returns the player'sPlayerSessionbesidegetRuntimeId()andgetIdentity(), which now read the session. The constructors take aPlayerEventSubject: the session, or anything whosegetNetworkSession()returns one. The session is found the first time a listener asks, as the ref was, because the handler tests (H-C3's) pass player doubles with no session.PlayerMoveEventstarts from the position of the player it is given, or of the session's player when it is given the session.Player.ts(setGamemode,setSprinting,setFlying) andDimensionTravelstill pass the player.MovePlayerHandlerandResourcePackClientResponseHandlerpasssession.getPlayer()and can passsessionas it is, which changes nothing a listener sees. H-D moves the threePlayermethods' events with their bodies, passes the session everywhere and drops the second form. The Changeset isplayer-events-carry-the-session.md(minor), anddocs/guide/extending.md's example readsevent.getIdentity().name.playerConnect,playerDisconnectandplayerDespawnare declared but never posted (#2972). - The teardown (item 5).
ClientConnection.closePlayerSessioncallsPlayerSession.leave(), which runsgetPlayer().disable()and thendisable()whether or not the first part threw, as the connection did. H-D replaces the first part with the session's own leave. H-S0's edge case holds: whenPlayer.disablethrows beforeremovePlayer, the player stays in its world's map with a forgotten session. - Item 29.
BossBar.addPlayer,removePlayerandanswerQuerytake aPlayerSessiononly, sinceBossEventHandlerpasses the session after H-C3; the Changeset isboss-bar-takes-the-session.md(minor). Each other legacy form names its remaining callers in its comment.PlayerKeySourceandKeyedPlayerSource:Player.enable(loadAdvancements(this),enchantmentSeedOf(this)),raiseAdvancementand the enchanting table, which take the portsadvancement/triggers.tsdeclares (H-C1),workstation.ts(H-C3), andtriggers.test.tsandPlayerPersistenceFormat.test.ts.BanSubject:LoginHandler, which passes{ getName }with the display name.PermissionHolder:Player.enable. Narrowing any of them breaks a file no H-C package of this wave owns, so H-D drops them once those callers pass the key, the session or the identity. - Item 4, prepared.
SessionPlayerinsessionPlayers.tsis whatgetPlayer()returns and what the session directory,SessionManager's player lookups andsessionForhold. It isPlayertoday, and H-D points it atStorePlayer. The session tests' doubles for a session's player stubSessionPlayer, so they follow the switch, and break only where they reach members a store player lacks.sessionPlayerRefOflost the events as a caller;createConnectedPlayeris the last.getAllPlayerskeeps its callers inServer.ts,modules/network.ts, the handlers and the benchmarks; the ones in H-C2's files readgetSessions(). - The session as the way in.
PermissionManager.setOpfinds the session withfindSessionand sends through it. The remote console's status, theAvailableCommandssoft enum,kickAllPlayersand/banread each session's identity and kick through the session. The command host's verbs that only send takesessionsOf(server, refs); the ones that change the player and then tell the client takeresolveTarget, which hands back the session and the player.CommandPlayerdroppedgetNetworkSession,kickandsendMessage./msg,/sayand the rest send throughPlayerSession.sendMessagewith the defaultsPlayer.sendMessagegave them. - Ports in the session.
sendMoveActorandqueueMoveActortake aMovedActorinstead of anEntity, andOpenTrade.traderis aTradingActorinstead of aWorldActor.PlayerSession.tsimports neither class. - What H-C2's files still name, and why:
Player:sessionPlayers.ts(SessionPlayer,LegacyPlayers,sessionPlayerRefOf), which is H-D's switch.Entity:CommandManager.dispatchCommand's target. Its guardisCommandExecutoris inCommandExecutor.ts, which is H-C1's.WorldActor:command/host/entities.ts,command/host/support.tsanditem/actorPort.ts. They hold whatWorld.getActorreturns and follow H-C1's narrowing of the alias.entities.ts's/tpkeeps theentity.setPositionbranch for a legacy entity after theStoreActorone. Once the alias isStoreActor, the narrowing leavesneverthere and the branch no longer compiles, so H-C1's narrowing has to come with deleting it.host.test.tsbuilds its mobs and a dropped item withstubOf(Entity, ...)for the same code.getNetworkSession()through a port:enchantment/enchantingTable.tsanditem/interaction.ts, whose ports the content interactions andworkstation.tshand aPlayer.commandStartup.test.ts,CommandManager.test.ts,host.test.tsandsupport.test.tsuseplayerOnPrototypefor players the command host reads, andPermissionManager.test.tsstubsPlayeras a permission subject. The rest of part 4's H-C2 tests no longer import a legacy class.
- Checks at H-C2's head, rebased on program head
35e5656efc: the Prismarine suite withJSP_CONTENT_VANILLA=1(432 files, 4365 tests), the commands package (11 files), the whole parity suite (53 files,protocol.dimension-changeamong them), client e2espawn,rejoinandduplicate-login, thenchat,dimensionsandtitle,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass.rejoinpassed on a second run: the first time the client exited before its control endpoint came up, with "macOS did not apply the hidden rendering activation policy", before any join. The adjacent gap found was already #2972.
H-C1, the world and entity side, left these for H-D. Item 36 is done but for one call, item 34 is done for H-C1's callers of getActors, item 33 is done for the entity index and the blast, and item 42 is done for H-C1's files. No behavior changed and no digest moved.
- Item 33, the index.
EntityIndexlostaddLegacy,refileLegacy,removeLegacy,legacyWithinand the body attachment, andWorldno longer files a legacy entity in it. A blast inentity/Explosion.tstakes store actors fromstoreWithinalone, and reads its eye height fraction fromENTITY_EYE_HEIGHT_FRACTION.Explosion.test.tscatches store entities through actor doubles over a real store, andWorldEntityIndex.test.tslost the cases that only exercised the legacy half. - Item 33, what is left.
World's legacy half stays whole: the entity map,addEntity,removeEntity,getEntity,getEntities,entityRelocatedand the chunk membership that serves them. Their callers are outside H-C1's files:DimensionTravel.transfer's legacy branch,/killincommand/host/entities.ts,Entity.ts(getNearestEntity,relocate),legacyAdapter.ts, and the testsSessionReplay.test.ts,DimensionTravel.test.ts,PlayerPersistence.test.ts,WorldChangeTick.test.ts,live.test.ts,serverSource.test.ts,WorldAutoSave.test.tsandbenchmarks/entityOnlyChunkSave.ts. The player map stays typed byPlayer, becauseitem/interaction.test.tsandbenchmarks/worldUpdateChunkWork.bench.tsfill it with players, andgetPlayers,addPlayerandremovePlayerread it. - Item 33, the alias.
WorldActorcannot narrow toStoreActoryet. Narrowing it breaks more than the/tpbranch H-C2 named:command/host/entities.tsatfamiliesOf,/kill's two legacy branches and/damageas well,item/actorPort.ts's teleport,DimensionTravel.transfer, andEntity.ts, which handsthistoEntityDamageEvent,EntityDeathEvent,advancementDamage,advancementKilland the portal travel. H-D narrows it withEntity.tsgone and those branches deleted. - Item 34.
World.actors()walks the legacy map and the store's actors in place, in no order. The chunk save context, the provider'sEntitySource(now any iterable), the level's entity snapshot, the stored actor restore and the end crystal's overlap check walk it, andcreateChunkSaveContextsorts each chunk's lists by runtime id and gives a duplicate actor id to the lower runtime id, which is what the sorted input gave. The content adapter'sat,nearandcountOfTypeask the index for store entities and dropped stacks within a horizontal radius, merge the two answers by id, and apply the old exact test (question 10).getActorskeeps its sorted copy for the replay digest and forcommand/host/entities.tsandjsprismarineCommands.ts, outside H-C1's files.shownActorsstill concatenates, becauseWorldChangeSink.reconcileand the session's reconcile take an array. - After H-C4's note:
LiveEntityRuntime.savedRecordsand the dragon fight's eight store reads walkEntityRuntime.entities(). The one caller ofall()left in source isentity/contentAdapter.ts, with the tests, sonoLegacyEntity.test.ts'it.todoforall()waits on those. - Item 36.
Worldreads its store and runtime without asking:update, the change tick,runInput, chunk entry and exit, the stored actor restore, the chunk change mark,shownActorsandgetActors.getActorkeepsgetEntityRuntime?., becauseitem/interaction.test.tsbuilds a world whose server has no runtime.hasEntityStorestays forlegacyAdapter.ts.inertEntityRuntimeand three world doubles gained a real, emptyEntityStore. - Players through the viewers.
World's spawner proximity check, raid host and both saves walkgetViewers; the spawner measures fromPlayerSession.getViewPositionless the eye height. The content adapter'splayersand the dragon fight's boss bar and killer readgetViewers.breakBlocktakes aBlockBreakerand the chunk coverage aCoveringPlayerport. Two doubles outside H-C1's files followed:worldDestruction.test.ts' world answers a viewer, andfakePlayer's world, which already listed its player as a viewer, answersgetKnownSpawnPosition, which the spawn cycle asks once the adapter sees that player. - Narrowed to ports.
ParseTargetSelectoris generic over aSelectorEntityand finds@pitself withgetNearestEntity's rule;ParseTildeCaretNotationreads only the source's position;isCommandExecutortakes aCommandTarget.CommandManager.dispatchCommandstill annotates its targetCommandExecutor | Entity, which can now readCommandTarget.looseandLiveEntityRuntime.killEntitytake aStoreActor. After H-C3's note,playerMiningOfandblockMiningOftake and answer null through overloads, andPlayerActionHandlercalls them directly;Player.tskeeps its own null checks. - What H-C1's files still name, and why:
Player:World.ts's player map,getPlayers,addPlayerandremovePlayer, and the prose of the legacy methods.Entity:World.ts's legacy half above.WorldActor: the alias itself,World.ts(actors,getActors,getActor, the digest),ChunkSaveContext.ts,BaseProvider.ts,EntitySerializer.toNBT, the threeevents/entityclasses andadvancement/triggers.ts, whichEntity.tshandsthis;Combat.ts's targets, whichInventoryTransactionHandlertakes fromWorld.getActor; andlive.ts'sdamageEntity, the naming, dyeing, projectile and item interaction methods, whichitem/actorPort.tscalls with aWorldActor, andsavedRecords, whichWorldReplayState.test.tskeys with legacy bodies.Level.tsimports onlybyRuntimeIdfromWorldActor.ts.- Tests:
Combat.test.tsstill swingsGenericEntitydoubles, because its attacker and target stayWorldActor;WorldEntityIndex.test.tskeeps two cases for the legacy chunk membership;WorldGeneratedEntities.test.ts,WorldEntityAddition.test.ts,WorldTickPhases.test.ts,WorldReplayState.test.tsandtriggers.test.tsstill build or stub a legacy entity for the code above. They go with the legacy half.
- The Changeset is
world-entities-from-the-store.md(minor): the index's legacy methods are gone and two parameters narrowed. - Found: neither blast path destroys a dropped stack, because both read the index's mob cells and a stack is filed only in its item cells (#2981).
- Checks at H-C1's head, on program head
6c24c5a6f1:pnpm run typecheck, the Prismarine suite withJSP_CONTENT_VANILLA=1(432 files, 4370 tests),SessionReplay.test.tswith no baseline touched, the whole parity suite (53 files), client e2eattack-entityandsummon-entity, thenender-dragonandnatural-spawn-placement,pnpm run lint:customandpnpm run lintpass.
H-D, the deletion, closed C4h and slice 5. Items 4, 31, 40, 41 and 43 are done, and so are what wave H-C left of items 29, 33, 34, 36 and 42. No behavior changed but the plugin API, and no digest moved.
- The switch (item 4).
LoginHandlerbuilds aStorePlayerwith an id fromentityIdsand the login's identity, skin and device, standingEYE_HEIGHTbelow the spawn position, andcreateConnectedPlayerfiles it inServer.getStorePlayers(), which isEntityRuntimes.playersand the one session directory.session.getPlayer()returns it.LegacyPlayers,sessionPlayerRefOf,SessionManager.getLegacyPlayersand the handler helpersstorePlayerOfandplayerInputsOfare gone; a handler readssession.getPlayer()andsession.inputs().SessionPlayerisStorePlayer. - What
Playerheld moved to the session: the world, the detached view, the permissions, the joined flag (isConnected()), the chat recipient and the timer.join()andleave()are whatPlayer.enableandPlayer.disabledid, with the join spawn writes H-B3 named. The session ownssetGamemode,setFlyingandsetSprintingwith their events,respawn,die,getRespawnPoint,setPlayerPosition,setOnGround,setWorld,updatePlayer,stopMining,getActor,shown()andkeyed(), and one-line state reads so it satisfies the mixed ports the content interactions, advancements, workstations and commands take. The store player took the state the class only forwarded: the item-use clock, the sprint, sneak and sleep flags, the fall distance, the experience, the equipment, the tags and status effects, andsetAttributeValue.StorePlayeris the record's onePlayerRecordTarget(item 31). - The world (items 33 and 36).
World's player map holds sessions,getViewersreads it, andgetPlayersis gone;addPlayerandremovePlayertake the session. The entity map,addEntity,removeEntity,getEntity,getEntities,entityRelocated,hasEntityStoreand the chunk membership are gone.actors(),getActors()andgetActor()answerStoreActors, and theWorldActoralias is gone:byRuntimeIdlives inworld/runtimeIdOrder.ts.getActorkeepsgetEntityRuntime?.for the world doubles whose server has no runtime. The legacy branches after a store one are deleted:/kill,/damage,/tpandfamiliesOfin the command host, the item actor port's teleport andDimensionTravel.transfer, which always moves a store entity throughtransferStoreEntity.CommandManager.dispatchCommand's target is aCommandTarget. - The copies (item 34).
EntityRuntime.all()is gone. The content adapter'satwalksentities()in place,EntityScenedeclaresentities(), and the attribute flush walksWorld.actors().getActorskeeps its sorted copy for callers outside the tick: the replay digest and the commands. - The keyed services (item 29).
PlayerKeySourceis a key or aKeyedPlayer;KeyedPlayerSourceandkeyedPlayerOfare gone, and a session answerskeyed().BanSubjectis{ name }andPermissionHolderis{ uuid }. The player events take the session only. - The deletions (items 40 and 41).
Player.ts,entity/Entity.ts,legacyAdapter.ts,network/handler/playerPorts.ts, the rootPlayerexport andentity/index.ts'sexport * from './Entity', which became a namedDamageCauseexport and lowered the barrel baseline from 5 to 4. The JSDoc baseline lost the deleted classes' entries. The Changeset isplayer-is-the-store-player.md(minor) and lists each plugin-facing change. - The proxy test (item 43).
noLegacyEntity.test.ts' twoit.todoare real: noEntityorPlayerclass is declared and neither file exists, the runtime has noall()and no source callsgetEntities(), and no source copies the store throughall(). Its eight cases pass. - Tests.
fakePlayerreturns the session beside its store player,sessionOnPrototypereplacesplayerOnPrototype,connectedSessionbuilds a login's pair, andPlayer.test.tsbecamenetwork/PlayerSessionPlayer.test.ts.testing/genericEntity.test.helper.ts,storePlayerPort.test.helper.ts,Entity.test.ts,WorldEntityAddition.test.tsandDamage.pipeline.test.tsare gone with the code they covered.Damage.pipeline.test.tsdroveEntity.damage. The store's versions of its rules are covered elsewhere: the grace period, the knockback and the void indamagePipeline.test.ts, which gained a block for the rules a swing keeps; absorption inplayerSystems.test.ts; knockback resistance inEntityRuntime.test.ts; and the hurt animation and the plugin events inlive.test.ts.Combat.test.tsswings at store cows. It lost its grace-period case, because the fixture cow is not of themobfamily, which the grace needs;damagePipeline.test.tscovers the grace.PlayerPersistence.test.ts' cross-level digest case lost its mob, because a store mob'spersistentIdentityis a fresh UUID each spawn;WorldReplayState.test.tscovers mob hashing. - What still names a deleted class, and why: Java references such as
Entity.checkInsideBlocks, which name Mojang's classes; the comments instorePlayers.ts,playerSystems.ts,live.ts,itemActor.tsandEntityRuntime.tsthat compare a store rule with what a legacy player or body did, which is the parity they keep;legacyDamage.ts, whoseDamageSourcethe plugin events and the advancement triggers still take;EntityStore.writtenLegacy, which logs a rider's vehicle link when the rider is in no store; and the test names and prose ofnoMirror.test.tsandnoLegacyEntity.test.ts. - Flaky, not caused by H-D: in the full client e2e run, the batch of
teleport,teleport-while-deadandtitlefailed once at the suite reset ("the client never stood at 0.5, 64, 0.5; at 0.5, -60, 0.5"), with the server log repeating "Teleported 1 entities" and "Dropped a position ... sent before its teleport". The batch then passed three times, each scenario passed alone, and the batch passed three times on the program head. In the rootpnpm run test, run at a load average near 12,modules/ticker.test.ts' "uses a timeout for a tick that finishes before its fixed deadline" andHttpSignaling.loopback.test.ts' WebRTC join (a 20-second timeout) failed once and passed three times alone; neither file changed. - Checks at H-D's head, on program head
7f8b1e99f0:pnpm run build,pnpm run typecheck, the Prismarine suite withJSP_CONTENT_VANILLA=1(430 files, 4334 tests),SessionReplay.test.tswith no baseline touched, the whole parity suite, the full client e2e suite in batches of two or three,pnpm run e2e:client:dist,pnpm run lint:customandpnpm run lintpass.pnpm run testpassed but for the two flaky cases above. No adjacent bug was confirmed.
Phase C: the store
| Package | Slice | Depends on | Work |
|---|---|---|---|
| C1 | 2 | A4, A5, B1 | The entity store, logged setters, the one index and the adapter over legacy entities. Component state the entities package keeps in WeakMaps moves onto the instance, where the store can log and save it |
| C2 | 3 | C1 | Items and the eye of ender move into the store |
| C3 | 4 | C1 | Staged damage and death, the mirror deleted, damage synchronous |
| C4 | 5 | C2, C3, B3, B5 | Players move into the store. The legacy entity classes and the adapter are deleted. Split into the packages below |
C2 and C3 run side by side if C1 leaves item code and damage code in separate files. Otherwise C3 follows C2.
The player spike found that a player fits the store with three host rules, which ADR 0008 now records, and that C4 is too large for one package. 79 source files and 117 test files name Player. C4 splits as follows.
| Package | Depends on | Work |
|---|---|---|
| C4b | C1, C3 | The player overlay, the rule that the client owns a player's movement, and a death that keeps the entity |
| C4c | C1 | Player components with logged setters, and an inventory that logs one entry for each slot |
| C4d | C4b, C4c | Player systems: hunger, breath, fall damage, consumption and mining, reading loaded chunks only |
| C4e | C4c, B3 | Persistence from the store entity, with the saved record unchanged |
| C4f | C4c | The session holds the entity id. Convert the files that name Player, split by directory |
| C4g | C4b, C4c | Show store players to clients through the current replicator until slice 7 |
| C4h | C4b to C4g | Convert the tests, then delete the legacy entity classes and the adapter |
C4b and C4c run side by side. C4d, C4e, C4f and C4g run side by side after C4c. The spike's note lists what the adapter of C1 must provide while players are still legacy, and that list is part of C1's brief. One item needs an answer when C3 lands: the mirror is what shows store mobs to legacy sessions today, so from the slice that deletes it until slice 5 something else has to encode them.
The change log module of A4 left questions for C1, answered here.
- A chunk entering the world is a unit of its own. The input step admits a chunk as one input, and no other write shares its span, so a span that would have to be undone never holds a chunk entry.
- A membership entry carries one record, the entity's saved form, and that form has to contain everything replication sends on first sight. ADR 0012 and ADR 0016 describe the same record.
- Inverses are written through setters that do not log. If writing an inverse throws, the store can no longer be trusted, so the world stops ticking and reports the failure. It does not go on with a half-undone unit.
- A chest's slots are fields of its block entity, addressed by block position. An entity's inventory slots are addressed by entity id.
Phase D: handlers and replication
| Package | Slice | Depends on | Work |
|---|---|---|---|
| D1 | 6 | C4 | The shared handler shape in @jsprismarine/protocol and the narrow server context |
| D2 | 6 | D1 | Convert the input-lane handlers to the shape, split into groups that run side by side. The client adopts the shape |
| D3 | 7 | C4 | Block and block entity writes go through logged setters |
| D4 | 7 | D2, D3 | The replica, one audience per change, one encode per codec group, and sends out of the simulation |
D3 runs beside D1 and D2.
D1, the handler shape and the narrow context, left these for D2. Counts are from program head d2d36c1c2f.
- The shared shape is
@jsprismarine/protocol/handler/PacketHandler.PacketHandler<P, C, Phase>hasphasesandhandle(packet, context), which may return a promise.LaneHandler<P, C, L, Phase>adds thelane.InputHandler<P, C, Phase>is the input-lane form, and itshandlereturnsundefined, notvoid: a function that returns a promise is assignable to one that returnsvoid, so onlyundefinedmakes an asynchronous input-lane handler fail to typecheck.ServerboundHandler<P, Contexts, Phase>is the union, with each lane's context taken fromContexts. The packet, the context and the phases are the side's own, so the client's decoded payloads fit as they are. The lanes (LANES,Lane,strictestLane) moved to@jsprismarine/protocol/handler/Lane, and Prismarine'snetwork/handler/Lane.tsis gone. The protocol root exports neither module (ADR 0010). - The server's context is
network/handler/HandlerContext.ts. A connection-lane or read-lane handler gets aSessionContext: thesession, alog(the logger's levels) and aconfigpicked to the six settings handlers read. An input-lane handler gets anInputContext, which addsservicesandstep.HandlerServicesis every server-wide service a handler calls today, each aPickof the members it calls: the content, the content world'sactorsAt, the creative inventory, the sessions, the permissions, the commands, the chat, the console sender, the plugin events and the default game mode. It covers all 72 handlers, so a D2 package does not need to add one.InputStephas thetick,output(send), which takes a send the handler started without awaiting it, anddefer(work), which takes work that still has to wait. - The server's handler shapes are
network/handler/SessionHandler.ts:ConnectionLaneHandler,ReadLaneHandlerandInputLaneHandler, each withtakes: 'context', which is howtakesContexttells them from the handlers that take theServer. The field goes when the last handler that takes the server goes. - The adapter is
network/handlerContexts.ts, the one module that turns aServerinto a context, outsidenetwork/handler/.HandlerContexts.of(server).run(handler, packet, session)awaits a connection-lane or read-lane handler. It applies an input-lane one insideWorld.runInputNow, a synchronous change log unit (EntityStore.unit) reached throughapplyInputNowinworld/inputUnit.ts. Then it runs the deferred work in order, each piece throughapplyInputas a unit of its own, and last it awaits the outputs, so the next input still starts after all of it. The context reads the server's parts when a handler uses them, so the content is not read before the pack stack is built.NetworkDispatcher.runHandlersends a handler withtakesthere and every other handler down the path it took before.appendHandlermerges either shape with the other into a handler that takes the server. - What changed for a converted input handler: its writes close as one unit before its sends are awaited, so a failed send no longer undoes them, and a deferred piece that throws undoes only its own writes. The order of effects is unchanged.
- Converted, one of each kind:
NetworkStackLatencyHandler(connection),SubChunkRequestHandler(read),AnimateHandler(an input that only relays, throughstep.output) andMobEquipmentHandler(an input that writes the hand slot). Their tests build contexts withsessionContextandinputContextintesting/handlerContext.test.helper.ts;inputContextkeeps the outputs and the deferred work andsettled()runs them as the step does. Each converted handler destructures its context ({ session, log, step }: InputContext), which keeps the diff of a conversion to the signature and the log calls. Lane.test.ts' check that connection-lane handlers keep out of the world now reads a context parameter: thesessiondestructured from aSessionContextorInputContext, or read ascontext.session, is checked as aPlayerSession.- The proxy test is
network/handler/noServerAccess.test.ts, an explicit allowlist that D2 empties, not a script baseline in the style ofcheck-barrels.mts. Each module either reaches the server or does not, so there is no count to lower; the test lives beside the handlers likenoMirror.test.tsandnoLegacyEntity.test.tsand runs in every Prismarine test run; and the list is grouped by D2 package, so packages that run side by side delete lines in their own block. A module reachesServerwhen it imports anything fromsrc/Server, imports a binding namedServerfrom the package root, or callsgetServer()on something it holds, whichPlayerSession.getServer()andWorld.getServer()still allow. The test fails when an unlisted module reaches the server and when a listed one has stopped. 9 of the 80 modules undernetwork/handler/reach no server. The target gates once the list is empty. - Not narrowed: the context's
sessionis the wholePlayerSession, which still answersgetServer()andgetWorld(). The test catches the call in a handler module, not in the code a handler calls. - Found: in the client suite, four NetherNet tests fail together with
connecterror:10(#2983), and the replay test can stop on a held RakNet client timer when files run one after another (#2984). Both happen on program headd2d36c1c2ftoo, and each file passes alone. - Checks at D1's head, on program head
d2d36c1c2f:pnpm run build,pnpm run typecheck, the Prismarine suite withJSP_CONTENT_VANILLA=1(432 files, 4349 tests), the protocol suite (395 files), the parity suite (53 files), client e2espawn,chat,movement,duplicate-loginandsubchunk-request-delivery,pnpm run lint:customandpnpm run lintpass.SessionReplay.test.tspassed with no baseline touched, in two parallel client runs and three times alone. The client suite's other failures are #2983 and #2984.
D2: the remaining handlers on the context
D2 moves the other 68 handlers and workstation.ts off the Server, in seven packages that run side by side, each in its own worktree, and one serial close. A package owns the files in its row and the tests beside them, and nothing else. The lists match the blocks of noServerAccess.test.ts; a package deletes its block as it converts.
How a handler converts:
- It implements
ConnectionLaneHandler,ReadLaneHandlerorInputLaneHandler, declarestakes = 'context', destructures its context, and logs throughlog. - An input-lane handler's
handlereturnsundefinedand awaits nothing. A send goes tostep.output. Work that still waits for something a later slice removes goes tostep.defer, with a comment naming that slice: a world change or the credits (9), the respawn search (9), a command or a chat line (9, unless the command is synchronous by then), or a disk write that the ADR moves off the tick. A block read uses a loaded-chunk accessor and rejects an unloaded chunk, as ADR 0014 says, and does not defer. - A helper the handler passes the
Serverto takes the service it reads instead, such ascontentUse(content, contentWorld, player)forcontentUse(server, player). - Each package traces every one of its handlers' lanes through what the handler calls, which closes the last item of "Checks to run" for its files, and moves a handler whose trace shows another lane.
| Package | Files under network/handler/ | What is not mechanical | Gate beyond the unit tests |
|---|---|---|---|
| D2a connection lane | the 31 connection-lane session handlers in its block, from ActorPickRequestHandler to UpdateClientOptionsHandler | Nothing. SetDifficultyHandler reads config.getDifficulty | Lane.test.ts; client e2e spawn |
| D2b read lane | AnvilDamage, BookEdit, BossEvent, LabTable, LecternUpdate, PlayerToggleCrafterSlotRequest, StructureBlockUpdate, StructureTemplateDataRequest, and BlockTarget.ts | The six that are write requests the server drops stay in the read lane until their write exists (B1). The parity check that rejecting an interaction with an unloaded chunk matches Java ("Checks to run"), as a test under a declared family, since BlockTarget is where the rejection lives | The new parity test; client e2e block-placement |
| D2c relays and session inputs | ActorEvent, ContainerClose, Disconnect, Emote, Interact, LevelSoundEvent, PlayerSkin, RequestAbility, ResourcePackChunkRequest, ServerboundLoadingScreen, SetPlayerGameType, ShowCredits | The relays move to step.output. ShowCredits and ServerboundLoadingScreen complete a held world change, which they defer. Disconnect closes the connection, which the dispatcher already holds as an input | Client e2e movement-fanout, dimensions, ender-dragon, title; parity protocol.dimension-change |
| D2d movement, actions and inventory | InventoryTransaction, ItemStackRequest, MovePlayer, PlayerAction, PlayerAuthInput, RequestChunkRadius, Respawn, and workstation.ts, tradeScreen.ts, eyeOfEnderEcho.ts | One package because PlayerAuthInputHandler runs the other three's code and applyClientMovement. PlayerAction's block read is the IN_TICK_CHUNK_LOADS entry for slice 6, which goes with it. The respawn search is deferred. workstation.ts stops calling getServer() | The replay suite with unchanged digests; client e2e movement, jump, teleport, mining-drops, block-placement, eating, villager-trade, recipe-book, death-respawn |
| D2e commands, chat and permissions | CommandRequest, RequestPermissions, SetDefaultGameType, SettingsCommand, Text | ADR 0014: an operator list change updates memory synchronously and persists off the tick, which needs a synchronous PermissionManager member. The default game mode's config write the same. Commands and chat are deferred | Client e2e chat; the commands package's suite |
| D2f the join | Login, ClientToServerHandshake, RequestNetworkSettings, ResourcePackClientResponse, SetLocalPlayerAsInitialized, ConnectionPacketHandler.ts | The three handlers before a session exist take a ClientConnection, so the package designs their context in a file of its own. B1's note moves building the player and reading the spawn in front of the join, whose result enters the world as an input. ConnectionPacketHandler.ts goes | The replay suite; client e2e spawn, rejoin, duplicate-login, dimensions |
| D2g the client | packages/client: ClientPacketRegistry.ts, ClientSession.ts, Client.ts, DimensionChange.ts, Credits.ts, login/LoginSequence.ts and their tests | The client registers PacketHandlers by packet name and phase over its decoded payloads with a client context, in place of each responder's on('packet') listener and isPacket chain. The client has no world tick, so its handlers stay asynchronous | The client suite and SessionReplay.test.ts with unchanged digests |
| D2 close, after the others | PacketHandler.ts, SessionHandler.ts, network/handlerContexts.ts, NetworkDispatcher.ts, PacketHandlerRegistry.ts, noServerAccess.test.ts | Delete the shape that takes the server, the takes field and takesContext, and the dispatcher's and appendHandler's second path. The allowlist is empty, and the test asserts that nothing reaches the server. Count the step.defer calls through reportProgress, as the work slice 9 has left | Everything in the gate for each slice; the full client e2e suite |
Rules that keep the packages apart:
- Only D2e edits
HandlerContext.ts, for the members it owns (permissions,defaults,commands,chat). Another package that needs a service changed waits for the close. - Only D2f edits
NetworkDispatcher.tsand theKNOWN_WORLD_ACCESSlist inLane.test.ts. No package editshandlerContexts.tsortesting/handlerContext.test.helper.ts; a package that needs another helper adds a file of its own undertesting/. - Narrow a helper's parameter freely. Narrow a return type or an exported member only when every reader is in the package.
- Each package adds its own Changeset. A handler's
handlesignature is public through Prismarine's"./*"export, so a package that converts one adds aminorone; D2g's changes the client's public API, which isminortoo.
D2a: the connection lane
- The 31 connection-lane session handlers in D2a's block, from
ActorPickRequestHandlertoUpdateClientOptionsHandler, take aSessionContext, and the block is gone fromnoServerAccess.test.ts. 40 of the 80 modules undernetwork/handler/reach no server. - Each handler's lane was traced through what it calls, and none moves. 25 log the packet and drop it, reading only the session's
identityorruntimeId;PacketViolationWarningHandleralso names the rejected packet fromlatestCodec, which is fixed at startup.ClientCameraAimAssistHandler,SetPlayerInventoryOptionsHandlerandTickingAreasLoadStatusHandlerdo nothing.ClientCacheStatusHandlerandClientCacheBlobStatusHandlercallsetClientCacheEnabledandhandleClientCacheBlobStatus, which touch only the session's blob cache and send on its own connection.SetDifficultyHandlerreadsconfig.getDifficultyand answers on its own connection. - No
step.defer, since a connection-lane handler has no input step. No service was missing fromHandlerContext.ts, and the package edited no shared file. - For the close:
Lane.test.tsstill lets a connection-lane handler callserver.getLoggerandserver.getConfig(SERVER_MEMBERS) and import../../Server(CONNECTION_IMPORTS). No session handler in the connection lane uses either now, but the join's handlers andResourcePackChunkRequestHandlerstill take the server. Both allowances go with the shape that takes the server. - Found:
Server.test.ts' "refreshes the process title every five seconds" waits on the real clock and failed once in the full Prismarine run on a loaded host, then passed alone. It is taggedperformance, which CI leaves out, so no issue was opened. - Checks at D2a's head, on program head
e671fe72cb:pnpm run typecheck,pnpm run lint:custom,pnpm run lint, the protocol suite (395 files),Lane.test.ts,SessionReplay.test.tswith no baseline touched, and client e2espawnpass. The Prismarine suite withJSP_CONTENT_VANILLA=1passed 432 of 433 files (4376 tests), the one failure being theperformancetest above. In thespawnrun the server loggedClientCacheStatusHandlerandClientCacheBlobStatusHandlerhandling the join's packets through the context.
D2b: the read lane
- Converted, all eight in the read lane:
AnvilDamage,BookEdit,BossEvent,LabTable,LecternUpdate,PlayerToggleCrafterSlotRequest,StructureBlockUpdateandStructureTemplateDataRequest. Each implementsReadLaneHandler, declarestakes = 'context'and destructures{ session, log }. D2b's block ofnoServerAccess.test.tsis gone. No handler is in the input lane, so there is nostep.defer. - The lane trace agrees with the declarations. Six read the sender's view position and one loaded chunk through
blockTargetRejectionand log;BookEditreads the sender's inventory and logs;BossEventreplays a bar to the sender alone throughBossBar.answerQuery. None writes and none answers anyone but its sender. The six write requests the server drops (AnvilDamage,BookEdit,LabTable,LecternUpdate,PlayerToggleCrafterSlotRequest,StructureBlockUpdate) move to the input lane when their writes exist. BlockTarget.tsdid not change. ItsBlockTargetSenderwas alreadyPick<PlayerSession, 'getViewPosition' | 'getWorld'>, and it reaches no server.sessionSeeingBlocksinnetwork/handler/blockTargetSession.test.helper.tsnow also returns aSessionContextover its session, whose log fillslogged, and takes player members, so theBookEdittest uses it too. The sharedtesting/helpers were not edited.- The "Checks to run" item on rejecting an interaction with an unloaded chunk is confirmed by the new parity family
gameplay.block-targets.packages/parity/src/javaClassFile.tsparses a class file from the pinned Java jar into each method's calls in bytecode order. Java 26.3'sServerGamePacketListenerImpl.handlePickItemFromBlockchecks reach (isWithinBlockInteractionRange), thenServerLevel.isLoaded, then reads the block, and returns at the first check that fails;updateSignTextcheckshasChunkAtbeforegetBlockEntity.blockTargetRejectionmakes the same checks in the same order. Against a realWorldwith one chunk in memory, the six handlers drop a position in reach in an unloaded chunk before reading its block, and the provider is asked for no chunk. The reach bound itself differs and is not compared: Java measures from the eyes to the block's box with the interaction range plus one, and the server from the view position to the block's corner with the creative reach plus one.handleUseItemOnchecks reach andmayInteractbut not whether the chunk is loaded, so it is not part of the row. - Left for the D2 close: nothing. These handlers need no
HandlerContextmember, and the close can tick the "Checks to run" item. - Checks on program head
0b2c42f684:pnpm run typecheck,pnpm run lint:customandpnpm run lint, the parity suite (54 files, 312 tests),SessionReplay.test.tsand client e2eblock-placementpass. The Prismarine suite withJSP_CONTENT_VANILLA=1passes except files that fail differently on each full run under load (Server.test.ts,live.test.ts,blockStateAllocation.test.ts); each passes when run alone. No replay digest changed.
D2c: relays and session inputs
- Converted, with the lane each trace showed:
ActorEvent,EmoteandPlayerSkin(input, relays throughstep.output);ContainerClose,Interact,RequestAbility,SetPlayerGameType,ShowCredits,ServerboundLoadingScreenandResourcePackChunkRequest(input);Disconnect(connection, moved from input). - Lane traces. Each input handler writes or shows something: the skin and the player list entry, the inventory, the crafting grid and dropped stacks, the window table, the flight state, the game mode, a world change, or a teardown.
ResourcePackChunkRequeststays an input because its kick goes throughClientConnection.disconnect, which takes the player off directly and not through the held removal.Disconnectonly logs and closes the transport, and the dispatcher holds the removal that the closed transport causes as an input of its own, behind the client's earlier inputs. That makes it a connection-lane handler, and it closes theDisconnecthalf of the last item under "Checks to run". The goodbye no longer waits for an input step before the transport closes. step.defercalls, four in all:SetPlayerGameType:PlayerSession.setGamemode, which awaits the send that stops block cracking before it writes the game mode. Slice 7.ShowCredits:completeCredits, a world change. Slice 9.ServerboundLoadingScreen:completeDimensionChange, which waits for the arrival's spawn and may start a queued world change. Slice 9.ResourcePackChunkRequest: the kick, whose teardown saves the player record and announces the departure in chat. Slice 9.
- Four writes stay in the handler's unit only because the method that makes them writes before its first await:
PlayerSession.setFlying(RequestAbility),openMainInventory's window table entry (Interact),releaseArrivalChunks' released flag (ServerboundLoadingScreen) andWorld.dropContents(ContainerClose). The handler tests stub these methods, so nothing fails if one of them gains an await before its write; the write would then leave the unit. Slice 7, which takes sends out of these methods, removes the risk. ContainerClosenow empties the grid and drops the overflow before its reply settles rather than after. The writes are the same unit and the sends go out in the same order.- Left for the close, then done in D2e:
LevelSoundEventHandlerstayed on the shape that takes the server. It relays the client's own packet throughNetworkWorldReplicator.sendAroundfor the sender's world, andHandlerServiceshas no replicator. It needs a member such asreplicators: Pick<WorldReplicators, 'for'>, or one narrowed tosendAround. Rebuilding the audience in the handler would copygetViewers, andgetProximityBroadcastis not inHandlerConfigeither. - Edited outside the handler files: one assertion in
Lane.test.ts' "flags the world access it guards against", which readSetPlayerGameType'sserver.getPermissionManagercall and now reads itssession.sendGamemodecall. No converted handler exercises the test'sServerbranch any more, so the close can delete that branch with the shape that takes the server.PlayerSessionDimensionChange.test.tsgained areportScreenhelper for its three loading screen calls; D2d edits thePlayerAuthInputHandlercalls in the same file. - Checks at this package's head, on program head
e671fe72cb:pnpm run typecheck,pnpm run lint:customandpnpm run lint; the Prismarine suite withJSP_CONTENT_VANILLA=1(433 files, 4378 tests), the protocol suite (395 files),SessionReplay.test.tswith no baseline touched, parityprotocol.dimension-change, and client e2emovement-fanout,dimensions,ender-dragonandtitlepass.
D2d: movement, actions and inventory
- Converted, all in the input lane, where each trace kept them:
InventoryTransaction,ItemStackRequest,MovePlayer,PlayerAction,PlayerAuthInput,RequestChunkRadiusandRespawn. Each writes the player, its inventory, its window, the world or the session's view distance. The D2d block ofnoServerAccess.test.tsis gone.workstation.tstakes the enchantment registry and the world's loaded blocks in place ofgetServer().tradeScreen.tsandeyeOfEnderEcho.tsreached no server already;InventoryTransactionhands the echostep.tick. PlayerAuthInputapplies a movement, flag actions, block actions, an item use and a stack request from one packet, each through the handler that owns it. Once one part defers, a part applied at once after it would take effect first, soorderedParts(network/handler/inputParts.ts) defers every later part whole behind it and runs the work a deferred part defers inside it. A block placed in the same input as a break still goes down after the break.PlayerAction's block read, the slice 6 entry inIN_TICK_CHUNK_LOADS, is gone. A block action, and a break transaction with client-timed breaking, is dropped at verbose level when its chunk is not loaded, and the crack timing reads the block throughWorld.loadedBlockAt. That is a behavior change: such a packet used to load or generate the chunk.step.defercalls, nine in all:PlayerActionStartDestroyBlockandContinueDestroyBlockwith server-timed breaking:World.startBlockCracking, which reads the block throughgetBlockState. Slice 6.- The same actions with client-timed breaking:
World.hitBlockand the crack event. Slice 6. PlayerActionCrackBlock:World.hitBlock. Slice 6.PlayerActionPredictDestroyBlock:World.breakBlock, which writes throughsetBlockByName. Slice 9.PlayerActionCreativeDestroyBlock:World.breakBlock. Slice 9.PlayerActionRespawn:PlayerSession.respawn, which asks the respawn search. Slice 9.InventoryTransactionDESTROYwith client-timed breaking:World.breakBlock. Slice 9.InventoryTransactionmelee:meleeAttack, which awaits its sounds before it says whether the blow landed, and the hunger depends on that. Slice 7.Respawnfor a dead player: the respawn search and the change of world. Slice 9.
- Writes that happen after a wait, in an output, so outside the handler's unit:
refreshEnchantOptionssets the window'sbookshelfPowerafter it counts the shelves, andPlayerSession.setViewDistancestarts the chunk streaming after its first send. Nothing reads either before the next input. Several outputs depend on a write that comes before the method's first await, as D2c noted for its four:setPlayerPosition,setSprinting,setFlying,dropFromPlayerandloose. Slice 7 removes the risk. ItemStackRequestdrops its overflow stacks before the response is sent rather than after. The stacks are the same unit's writes, and nothing the response or the trade offers read changes with them.- Left for the close: the workstation input slots (
OpenBlockContainer.inputs, written byItemStackRequestHandler) are still not logged. Logging them as aninventory-slotcontainer of the player needs a container name ininventory/slotLog.ts, a restore in the player systems'restoreSlot, and the setter onPlayerSession's open window, all outside this package's files. - Edited outside the handler files:
world/inTickChunkLoads.ts(the entry above),contentPayloads.ts(joinContentPayloadstakes the three log levels it writes at), theidleMovementbenchmark, and the callers inentity/data/live.test.ts,inventory/blockContainer.test.tsandPlayerSessionDimensionChange.test.ts. The tests that build aServerdouble reach the handlers throughtesting/serverInputContext.test.helper.ts, a new helper that reads each part from the double. - Checks at this package's head, on program head
0b2c42f684:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass.SessionReplay.test.tspasses with no baseline touched. Client e2emovement,jump,teleport,mining-drops,block-placement,eating,villager-trade,recipe-bookanddeath-respawnpass. The Prismarine suite withJSP_CONTENT_VANILLA=1passed 431 of 434 files (4380 tests) at load average 33 to 51. Its failures were the twoperformancetests inServer.test.tsand timeouts invanillaEntityRuntime.test.ts,live.test.tsandServer.test.ts, and each of those files passes when run alone. The replay test timed out once while the suite ran beside it, and passed when run alone.
D2e: commands, chat and permissions
- Converted, all input lane:
CommandRequest,SettingsCommandandText(a command or a chat line),RequestPermissions(an operator list change),SetDefaultGameTypeandLevelSoundEvent. Each writes or shows something to other players, so none moves lane.LevelSoundEventis D2c's leftover: it relays throughWorldReplicators.for(world).sendAround, which is synchronous, so it sends nothing throughstep.output. HandlerServicesgainedpermissions(isOp,setOpNow,can),defaults(setGamemode),commands(dispatchCommand),chat(send) andreplicators(for).handlerContexts.tswires each as a getter or a bound method.- Memory first, disk after:
PermissionManager.setOpNowrunsPlayerToggleOperatorEventand changes the operator list before it returns, then queues theops.jsonwrite behind the writes queued earlier.Config.setGamemode(gamemode, true)changes the value at once and queues the file write. A failed write is logged, and the change in memory stays.PermissionManager.setOpkeeps its contract on the same pieces, and now logs a failed write. step.defercalls, four in all:CommandRequest:dispatchCommandand theCommandOutputPacketreply. A command can do anything. Slice 9, unless the command is synchronous by then.SettingsCommand:dispatchCommand. Slice 9, the same.Text:ChatManager.send, which runs the plugins' chat listeners. Slice 9.SetDefaultGameType:PlayerSession.setGamemodefor every session, which awaits its sends and writes players in other worlds, then the default's resend. Slice 9, where a change to another world travels as a message.
- Fixed:
PermissionManager.setOpsent the target's command list before it changed the operator list, so a player made an operator got the commands of a non-operator and one who lost the status kept the operator commands until something resent the list. It sends after the change now.PermissionManager.test.tsrecordsisOpat eachsendAvailableCommandscall. - Left for the D2 close:
HandlerContext.tsandhandlerContexts.tsnow carry every member the converted handlers use. ThenoServerAccess.test.tsblocks of D2c and D2e are gone.
D2f: the join
- Converted, with the lane each trace showed:
RequestNetworkSettings,LoginandClientToServerHandshake(connection, unchanged), andResourcePackClientResponseandSetLocalPlayerAsInitialized(input, unchanged). The D2f block ofnoServerAccess.test.tsis gone, andConnectionPacketHandler.tswith it. - The three handlers before a session implement
ConnectionHandlerand take aConnectionContext, both innetwork/handler/ConnectionContext.tswithtakesConnection. The context holds the connection, a logger, the four settings they read (ConnectionConfig) andlogin: the sessions, the ban list andplayers, aJoinPreparation.network/connectionContexts.tsbuilds it from the server, outsidenetwork/handler/, and the dispatcher hands it to a handler whose phases all precede the session. - B1's note:
JoinPreparation.preparereads the default world's spawn, builds the store player standing there, attaches its session and loads its record, in the connection layer in front of the join, as ADR 0014 places login I/O. Its result is a session whose player is in no world. That player enters its world as an input, whenSetLocalPlayerAsInitializedadds it. - Lane traces.
RequestNetworkSettingssets the codec and the compression and answers on its own connection.Loginverifies the token, reserves and claims the account, kicks a duplicate and prepares the player;ClientToServerHandshakeresumes that admission. The login reaches a world twice, throughlogin.players.prepareand through the kick of a replaced session, so both stay inKNOWN_WORLD_ACCESS; neither runs inside a tick.ResourcePackClientResponseopens the play phase and raisesplayerSpawn, andSetLocalPlayerAsInitializedadds the player to its world, so both stay inputs. step.defercalls, four in all:ResourcePackClientResponse,Refused: the kick, which saves the player's record and announces the departure in chat. Slices 8 and 9.ResourcePackClientResponse,SendPacks: the same kick. Slices 8 and 9.ResourcePackClientResponse,Completed: the join burst, which waits for the join world's spawn and ends with the join line in chat. Slice 9.SetLocalPlayerAsInitialized:World.addPlayerandcompleteSpawnas one piece, so a failure incompleteSpawnstill undoes the entry. A review later found that the undo left the session in the world's player map and the store player attached to the removed entity;World.addPlayernow lets go of both throughEntityStore.undoWith.World.addPlayerwrites before it returns and is asynchronous only in its signature;completeSpawnwaits on sends before it writes the game mode. Slice 7.
createStartGameDatatakes the three settings and the tick it reads in place of the server, andjoinContentPayloadstakes only the logger levels it writes at.- Edited outside the handler files:
NetworkDispatcher.ts, which builds the connection context;PacketHandlerRegistry.ts'RegisteredHandlerunion;Lane.test.ts, which reads aConnectionContext'sconnectionas aClientConnectionand keeps the twoKNOWN_WORLD_ACCESSentries with new reasons; the inbound latency benchmark's handler stub; and the parity familyprotocol.negotiation, which handsRequestNetworkSettingsHandlerthe context. - For the close: the
connectionContexts.tsgetters read the server's logger, config, session manager and ban manager when a handler uses them, ashandlerContexts.tsdoes.KNOWN_WORLD_ACCESSkeepsLoginHandlerandClientToServerHandshakeHandleruntil the duplicate kick is a message to the replaced session's world.World.addPlayeris asynchronous only in its signature; making it synchronous letsSetLocalPlayerAsInitializedadd the player in the handler and defer onlycompleteSpawn. - Checks at this package's head, on program head
0b2c42f684:pnpm run typecheck,pnpm run lint:customandpnpm run lint;SessionReplay.test.tswith no baseline touched; parityprotocol.negotiation; and client e2espawn,rejoin,duplicate-loginanddimensionspass. The Prismarine suite withJSP_CONTENT_VANILLA=1passed 430 of 434 files (4375 tests) at load average 50 to 68, the four failures all timeouts. Alone with a longer timeout,vanillaEntityRuntime.test.ts,bedrockSections.test.tsandregistryBundle.test.tspass, andServer.test.tsfails only theperformancetest D2a's notes name.
D2g: the client
- The client's shape is
packages/client/src/net/ClientHandler.ts.ClientPacketHandler<T>isPacketHandler<T, ClientContext, ClientPhase>, with no lane, since the client has no world tick.ClientPhaseis'login' | 'play'.ClientContexthas thesession, picked tosend,sendImmediate,enableCompressionandenableEncryption, and alogwith the logger's levels.PacketHandler.tsneeded no change. ClientPacketRegistry.registerHandler(PacketClass, handler)holds a handler under the class's name and returns a function that removes it. The handler runs for a packet of that name only when that class decoded it, so its payload type comes from the class: aninstanceofcheck on the packet narrows the payload, with no cast and no reliance on an id table. ADecodedPacketnow carries thepacketthat decoded it.dispatchruns the handlers that accept the phase in registration order, over a copy of the list asEventEmitterdoes, and one that throws does not stop the rest.ClientSessionholds the phase, starting inlogin, andregisterHandlerdelegates to the registry. It dispatches each packet after itspacketevent and beforepacket:<id>, counts what a handler returns inisIdleandsettleduntil it settles, and logs a failed handler as a warning that names the packet.- Converted, each in its own commit:
CreditshandlesShowCreditsPacket.DimensionChangehandlesChangeDimensionPacketandLevelChunkPacket.LoginSequencehandlesNetworkSettingsPacket,ServerToClientHandshakePacket,PlayStatusPacket,StartGamePacket,DisconnectPacket,ResourcePacksInfoPacketandResourcePackStackPacket, registered whenrunstarts and removed when the login settles. A step that throws still fails the login. After it sendsSetLocalPlayerAsInitializedit moves the session toplay. - Phases: the login's steps accept
loginonly.CreditsandDimensionChangeaccept both, because they listened to every packet from the momentClientwas built, and a change can arrive while the spawn answer is still being sent, before the session reachesplay. - What changed in behavior: the listeners tracked a promise for every packet the session read, and now only a packet with a handler starts work. The
LevelChunkhandler returns nothing when no change is pending, so a chunk leaves nothing to wait on. The order of sends is unchanged, andSessionReplay.test.tspasses with no baseline touched. - The public API change, in the
minorChangesetclient-packet-handlers.md: the new registration, phase and context,DecodedPacket.packet, and constructors without a logger (new LoginSequence(session, auth, options),new DimensionChange(session, runtimeEntityId),new Credits(session, runtimeEntityId)). Emittingpacketon a session by hand no longer drives the responders. - For the D2 close:
isPacketandClientboundPacketDatastay. They narrow on the id alone, and the readers left watch thepacketevent:cli/commands.ts, which printsText, andTeleportChunks.test.ts, both outside this package's row, and the real-server trip inDimensionChange.test.ts, which reads the whole sequence back in arrival order. Once those register handlers or narrow withdecoded.packet instanceof, both can go. - Found: the published declarations used relative imports without an extension, so a consumer that resolves with
NodeNextgot no members from any package's root (#2985). It predated this package, and program head carries the fix. - Checks at D2g's head:
pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The client suite passes, 14 files and 83 tests, its NetherNet tests included on that run (#2983 failed the four of them on the baseline run ate671fe72cb).SessionReplay.test.tspasses in the suite and alone. Client e2espawn,dimensionsandender-dragonpass. The packed client and its workspace dependencies install in a scratch project, where a consumer that registers a handler typechecks with TypeScript 5.9.3 andskipLibCheckoff under bothNodeNextandBundler, and a handler of the wrong payload is rejected. Rerun on program head0b2c42f684: the build, typecheck, both lint gates, the client suite (14 files, 83 tests) and the three e2e scenarios pass.
D2 close
- Deleted:
network/handler/PacketHandler.ts, the shape that took the server,handle(packet, server, session). With it went thetakesfield of every handler and ofConnectionLaneHandler,ReadLaneHandlerandInputLaneHandler,takesContext, and the second path inNetworkDispatcherand inappendHandler. The dispatcher hands a session's handler toHandlerContexts.runand nothing else. No plugin or other caller registered a handler in the old shape:git grepfoundappendHandlerandregisterHandleronly inPacketHandlerRegistryand its test. PacketHandlerRegistry.appendHandlertakes and returnsSessionHandlers. The merged handler runs in the first handler's phases and the stricter of the two lanes. An input-lane merge returns nothing to wait for, so a part that returns a promise has it handed to the step's outputs. A merge of connection or read parts is awaited part by part. It no longer takes the server, which is how a merged handler used to build its parts' contexts, so a merge now runs in the lane it declares and not as one asynchronous input.noServerAccess.test.tshas no allowlist and asserts that no module undernetwork/handler/importsServeror callsgetServer(), which meets the slice 6 target "NoServerimport in a handler".Lane.test.tslostSERVER_MEMBERS, theServerentry ofCONNECTION_IMPORTSand its parameter kind, and the check for a call on aServer. The other import patterns stay, since they guard a connection-lane handler against the world.KNOWN_WORLD_ACCESSkeepsLoginHandlerandClientToServerHandshakeHandler, which still reach the world as the test shows.- The
NetworkDispatchertests registered doubles in the old shape. Those in the input lane now register a handler for a session in theplayphase, because an input-lane double in a connection phase is a pair that production cannot have, and the harness awaits an input double through the step's outputs so the ordering tests still wait for it. - The
step.defercalls are counted innoServerAccess.test.tsthroughreportProgress: 23 calls in 14 handler modules at this head. That is the work slice 9 has left, and the number is not gated. It is more than D2d's nine because the other packages deferred a world change, a command, a chat line or a disk write too. - The client lost
isPacketandClientboundPacketData. The CLI'sconnectcommand registers aTextPackethandler,TeleportChunks.test.tsregisters three, and the portal trip inDimensionChange.test.tsreads the whole sequence back through a localpayloadOf, which narrowsdecoded.packetwithinstanceofand readsgetPacketData(). - Not done: the workstation input slots (
OpenBlockContainer.inputs) are still not logged. A logged slot needs a place in the snapshot, because "a snapshot plus the log reproduces the replay digest" (slice 7) replays the log onto a snapshot, and the player record keeps no workstation slots: the window is session state, nothing logs that it opened or closed, andinputsis a plain array that 18 lines inItemStackRequestHandler,workstation.ts,tradeScreen.tsandContainerCloseHandlerindex directly. Logging them would take a class likeCraftingInputowned by the store player, a window that borrows it, and a rule for an undo that reaches a window already closed. Either the digest or the snapshot would then carry state they do not carry now, so this is not a change that leaves digests alone. It needs a decision about whether a workstation window is player state, which goes with D4's replica and slice 7's snapshot, and it stays where D3 left it. - Changesets:
minorfor@jsprismarine/prismarine(the old shape was public through its"./*"export) and for@jsprismarine/client(isPacketandClientboundPacketDatawere public through its"./*"export). Both packages are below1.0.0, where a breaking change isminor. - Checks at this head, on program head
204b9acc66:pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The Prismarine, client, parity and protocol handler suites pass (504 files, 4807 tests) apart from two NetherNet tests inLogin.test.ts(#2983), which pass alone (11 tests); one run before it also failed three tests, among themenchantingTable.test.ts's seed test, which passes alone and on program head. Client e2espawn,chat,movement,block-placement,dimensions,villager-tradeanddeath-respawnpass.SessionReplay.test.tspasses with no baseline touched.
D3, the logged block writes, left these for D4 and later packages.
- Every block write in a loaded chunk goes through
World.writeBlockRuntimeId, which now appends ablockfield entry with the runtime id before and after it. The paths that reach it aresetBlockRuntimeId,setBlockByName,setLoadedBlockRuntimeIdandsetLoadedBlockState. Physics, the engine systems throughEngineWorld(fluids, fire, redstone, blasts), the block behaviors throughworldBlockStorage(writes, piston moves and destruction), commands, the dragon fight's features, item interactions and a cooking station's lit swap all use them. No code outside world generation and chunk loading writes a loaded chunk around it. Layer 1 is never written by the world today; the entry kind carries the layer for when it is. - Every block entity write goes through
writeBlockEntity, which handsWorld.blockEntityChangedthe tags before the write, and every removal happens inside a block write. That covers content records (setBlockData), chest and furnace slots throughopenContainerSlots, loot fills, a piston's carried block entity, cooking stations and spawners. A block entity is logged asblock-entityfield entries addressed by its block position (blockentity/blockEntityLog.ts):dataholds every tag but the position and the stacks, withItemskept as an empty list so the tags keep their order, anditems.<slot>holds one stack. A value is the field's tags as network NBT bytes, or null. A block entity appears withdatafirst and leaves withdatalast, and a block write logs the removal of its block entity before the block, so an undo, which runs newest first, always finds what it writes into. AnItemslist whose stacks do not each name a distinct slot stays whole indata. - A workstation that is a block entity (furnace, blast furnace, smoker, chest, barrel, hopper, shulker box) keeps its slots in
Items, so they areitems.<slot>fields. The enchanting table, anvil, stonecutter, smithing table and trade screen keep their input slots on the player's open window (OpenBlockContainer.inputs), because two players at one table each have their own, andItemStackRequestHandlerwrites them. They are player state, not block state, so they are not block entity fields. They are not logged yet. The handler is D1's and D2's, and the natural home is aninventory-slotcontainer of the player, as the cursor and the crafting grid are. - The log stays in
EntityStore, which is per dimension and so per world. The world registers its inverse writer and its block update journal on the store when its first chunk enters, which is before any write into it and before any unit that could make one. A world-owned name for the log is a rename D4 can make if replication wants one; nothing depends on where it lives. - An undo writes the chunk without logging (
Chunk.restoreBlockRuntimeId, which keeps the block entity, andChunk.restoreBlockEntity, which files a block entity back in the place it held, since the chunk serializes them in that order and the digest reads it). It tells light, the portal index and the clients, as any block change does, and runs no block behavior and schedules no neighbor updates. The block update queue is in the replay digest, soBlockUpdateScheduleris aUnitJournal: the store marks it when a unit starts and rolls it back with the unit's span. - Not undone yet: scheduled block and fluid ticks that a write's neighbors queue (
@jsprismarine/engine-systems' tick lists) and the light queue. Both are in the digest, so a unit that throws after a write whose neighbors scheduled a tick, or whose light changed, does not return the digest to where it was. The proxy tests use writes that touch neither. Block updates, world systems, cooking stations, spawners and the dragon fight run outside units, so a throw there keeps its writes, as before; ADR 0015 makes each block update a unit, which is still to do. - Writes between ticks, such as a command from a tick function, are counted as unlogged, as C1c set out. A chunk entering the world is still one chunk entry and carries no block entries.
- Proxy tests:
WorldChangeTick.test.tschecks that N block writes append exactly N entries, that an undone unit of block writes, of block entity changes and a removal, or of a placement leaves the replay digest unchanged, that the engine systems' write and a piston push are logged, and that undoing a second push restores what the first left.inventory/blockContainer.test.tsdrives a real world: a stack moved into a chest is oneitems.<slot>entry, a lit furnace is onedataentry a tick, and a world with an idle chest and furnace appends no block entries over twenty ticks. - Counts:
ChangeLogCostreportsblockEntriesandblockBytes, andSessionReplay.test.tsreports them (non-gating). The scripted replay session places no blocks, so its 19 sampled ticks log 19 entries and 2212 bytes in all and no block entries. A replay scenario that builds and mines would give D4 a block figure. - For D4:
- Replication can send
UpdateBlockfromblockentries andBlockActorDatafromblock-entityentries, foldingdataand theitems.<slot>fields back into one compound withwithBlockEntityField, which is the formBlockActorDatacarries. The log's order already puts a placement's block before its block entity, which the client needs. WorldChangeSink'sblock-setandblock-entity-setstill go out for every write and every undo. D4 deletes them once it reads the log.- A lit furnace appends one
dataentry a tick, and a spawner near a player one each tick its delay counts down. Each carries the block entity's tags without its stacks, before and after. - A change that only reorders an
Itemslist logs nothing, though the chunk's bytes change. Every writer in this server keeps stacks in slot order.
- Replication can send
- Checks at D3's head, on program head
d2d36c1c2f:pnpm run typecheck, the Prismarine suite withJSP_CONTENT_VANILLA=1(431 files, 4362 tests), the block-behaviors (27 files) and engine-systems (8 files) suites,SessionReplay.test.ts, the whole parity suite (53 files, the block and redstone families among them), and client e2eblock-placement,mining-drops,lever-lamp,furnace,lit-furnace,farmland,edge-chunk-block-changeandsub-chunk-block-changespass. No replay digest changed.
D4: replication reads the change log
D4 is the rest of slice 7: replication reads each sealed tick of the change log, keeps a replica, computes each change's audience once and encodes once for each codec and compression group, and the simulation stops sending. It splits into six packages. D4a comes first, because every other package reads the log through what it builds. D4b and D4d then run side by side, D4c and D4e run side by side after them, and D4f closes the slice.
Where things stand at program head 204b9acc66:
- The log is complete for blocks, block entities, entity fields, inventory slots and membership (D3, C1c, C4c). Writes between ticks are counted as unlogged and are not in it.
- The replicator (
network/NetworkWorldReplicator.ts) is aWorldChangeSink. The world pushesblock-setandblock-entity-setthroughworldChangedfor every write and every undo, and the replicator collects them and sends them in the tick's flush phases, which run before the tick's log is sealed. - Entities reach clients through
StoreActors.sync, which compares each actor with what it last sent: health, equipment (one equipment set built per store player a tick), the owner, the metadata inputs, the synced properties and the pose.showPlayerStatethrottles breath withbreathWorthSending. A first sight callsspawnToonce for each viewer, which reads the live entity and encodes for that viewer. - Transient facts (sounds, particles, level events, actor events) are sink methods that send at once. Handlers relay through
step.output, andPlayerSessionsends to its own client and awaits the send in the middle of simulation code. 184 lines ofPlayerSession.tsawait something. broadcastSharedBatchesand the block flush already encode once for each codec and compression group.spawnTo,sendAddPlayerand the per-session sends do not.
The slice 7 items that earlier packages left, and the package that takes each:
| Item | From | Package |
|---|---|---|
UpdateBlock from block entries, BlockActorData from block-entity entries folded with withBlockEntityField, and WorldChangeSink's block-set and block-entity-set deleted | D3's "For D4" 1 and 2 | D4a |
A lit furnace's and a spawner's data entry each tick, and an Items reorder that logs nothing | D3's "For D4" 3 and 4 | D4a (sent as before; the reorder stays unlogged, as D3 found no writer that makes one) |
StoreActors.sync's comparisons and the per-tick equipment comparison | C3, C4g, C4h | D4c, over D4b's replica |
| The breath send, its throttle and the player state metadata | C4d, C4h item 15 | D4c |
The motion send for an impulse occurrence | C3, C4b | D4d |
The status effect clock and the actor record tags (persistentNbt), which the actor keeps outside the store | C3 | D4b gives them logged homes |
Merchant offers, saved and hashed but not logged, because a trade changes the Merchant in place | C2 | D4b logs them, D4e sends them to the trader from the log |
| A stack network id after an undo: the slot has to be sent again | C4c | D4e |
step.defer 1 of D2c: SetPlayerGameType, PlayerSession.setGamemode | D2c | D4e |
step.defer 8 of D2d: InventoryTransaction melee, meleeAttack awaits its sounds | D2d | D4e, after D4d makes the sounds occurrences |
step.defer 4 of D2f: SetLocalPlayerAsInitialized, World.addPlayer and completeSpawn | D2f | D4e |
Writes that stay in a handler's unit only because they come before the method's first await: setFlying, openMainInventory, releaseArrivalChunks, dropContents, setPlayerPosition, setSprinting, dropFromPlayer, loose | D2c, D2d | D4e removes the sends those methods await |
Advancement triggers read from the log keep their timing (gameplay.combat-advancements) | C3, "Checks to run" | D4d |
| "Encode calls for one broadcast occurrence equal the number of codec and compression groups" | Targets | D4d for occurrences, D4c for first sights |
| "Replication visits entities in proportion to log entries, not to the number of entities" | Targets | D4a for blocks and block entities, D4c for entities |
| "A snapshot plus the log reproduces the replay digest" | Targets | D4f |
| Outbound packets count toward the tick that produced them | Slice 7, ADR 0006 | D4f |
| Package | Depends on | Files | What is not mechanical | Gate beyond the unit tests |
|---|---|---|---|---|
| D4a the block replica | D3 | New network/replica/BlockReplica.ts; the block and block entity parts of NetworkWorldReplicator.ts and WorldChangeSink.ts; WorldChange.ts, deleted; in World.ts, update and the block write, undo, resend and chunk paths; EntityStore.ts's handling of writes between ticks; a block-shown occurrence in ChangeEntry.ts | A block entity's compound is folded from its fields, and the fields a tick did not change come from the replica, which takes a chunk's block entities when the chunk enters (the chunk's content arrives as an input under ADR 0012, so this is the input and not a read of the simulation). The seal moves ahead of the flush phases, so the flushes read a sealed tick. Block writes between ticks, such as a command from a tick function, are logged at the start of the next tick, which is when their packets went out before. resendBlock changes no state and becomes an occurrence. An undone unit's writes leave the log, so they send nothing | Proxy tests: reading a tick folds only the block entities the tick's entries name, whatever number the replica holds; a BlockActorData carries the bytes the block entity's own toNBT gives; a unit of block writes that throws sends nothing; a block write between ticks is sent in the next tick's flush. Replica entries and bytes through reportProgress. Replay digests; parity blocks.behavior, integration.block-behaviors; client e2e block-placement, mining-drops, lever-lamp, furnace, lit-furnace, farmland, edge-chunk-block-change, sub-chunk-block-changes |
| D4b the entity replica | D4a | New network/replica/EntityReplica.ts; the metadata projection helpers moved out of entity/data/storeActors.ts into a module the actor and the replica share; the status effect clock and persistentNbt in storeActors.ts; merchant offers in entity/data/trading.ts | Every field a client is shown needs a logged home before the replica can be built from the log alone: the status effect clock, the actor record tags and the offers are not logged today. The replica is folded from a membership entry's saved form and the entity and slot entries after it, so the projection has to run from plain data. Logging the offers changes no state, so no digest moves | Proxy test: for every entity in the replay scenarios, the replica folded from the log equals the projection the actor makes from live state, tick by tick. Replica entries and bytes through reportProgress. Replay digests; parity entities.addactor, gameplay.brain-effects |
| D4c entity replication | D4b | entity/data/storeActors.ts, entity/data/storePlayers.ts, world/ShownActor.ts, the entity parts of NetworkWorldReplicator.ts and WorldChangeSink.ts (reconcile, entityMoved, flushMovement, entityAdded, entityRemoved, the attribute, metadata, equipment and status effect changes), the tracking phase in World.ts, and PlayerSession.sendAddPlayer | StoreActors.sync and its comparisons go: moves come from position entries, health and metadata from the replica, equipment from inventory-slot entries, a removal from a leave entry. A first sight is answered from the replica and encoded once for each group, so spawnTo stops reading the live entity. Breath goes out from vitals.air entries with the same throttle. The tracking phase moves after the seal. Entity writes between ticks are carried into the next tick as D4a carries block writes | Proxy tests: with N entities and k of them written in a tick, replication visits k; a first sight to V viewers in G groups encodes G times. Replay digests; parity entities.addactor, integration.client-packets; client e2e movement-fanout, movement-rotation, look-direction, gait, summon-entity, attack-entity, damage, item-drop-pickup, natural-spawn-placement, ender-dragon |
| D4d occurrences | D4a | world/changelog/ChangeEntry.ts's occurrence kinds; the transient members of WorldChangeSink.ts and their parts of NetworkWorldReplicator.ts (blockSound, namedSound, namedParticle, worldEffect, actorEffect, actorSound); the methods in World.ts that call them; the relaying handlers (Animate, ActorEvent, Emote, LevelSoundEvent); entity/data/live.ts' impulse send | Each transient fact becomes an occurrence that replication encodes once for each group, after the seal. An occurrence in a unit that throws is removed with it, so a sound for an undone action no longer plays. Advancement triggers that observers read from the log have to fire at the same point as the inline triggers did | Proxy test: one broadcast occurrence to V viewers in G groups encodes G times. Parity gameplay.combat-advancements; client e2e attack-entity, player-knockback, damage, eating, lever-lamp, block-placement |
| D4e the owner's view | D4b, D4d | network/PlayerSession.ts; the three handlers with slice 7 step.defer calls (SetPlayerGameType, InventoryTransaction, SetLocalPlayerAsInitialized); the inventory and trade screen sends | The simulation stops awaiting sends to the player's own client. Slots reach their owner from inventory-slot entries, and a slot whose write was undone is sent again, because the stack's network id changed. The trader is sent offers from the log. The three deferred pieces run in the handler | The step.defer count lowered by three, and the awaits reachable from World.update through reportProgress. Replay digests; client e2e spawn, rejoin, death-respawn, villager-trade, recipe-book, eating, attack-entity, teleport |
| D4f the close | D4c, D4e | WorldChangeSink.ts reduced to the sealed tick and what is left; the replay recorder; a snapshot test beside SessionReplay.test.ts; the docs the slice invalidates | A packet replication sends counts toward the sealed tick it read, not the tick the transport framed it in (ADR 0006). A snapshot plus the log reproduces the replay digest only if everything the digest hashes is logged or held in the snapshot: D3 found that scheduled block and fluid ticks, the light queue and the block update queue are hashed and not logged, so this package logs them or puts them in the snapshot and says which | Everything in the gate for each slice, the full client e2e suite, and the three targets gating |
Rules that keep the packages apart:
- D4a lands first. The others build on its sealed-tick reader, its
network/replica/module and its carrying of writes between ticks into the next tick. NetworkWorldReplicator.ts,WorldChangeSink.tsandWorld.tsare shared by section: D4c owns the entity members and the tracking phase, D4d the transient members and the methods that call them. Neither moves the other's members.- D4c owns
storeActors.tsandstorePlayers.ts; D4e ownsPlayerSession.ts.sendAddPlayeris D4c's, and D4e does not edit it. - No D4 package edits the D2 close's files (
PacketHandler.ts,SessionHandler.ts,handlerContexts.ts,NetworkDispatcher.ts,PacketHandlerRegistry.ts,noServerAccess.test.ts,Lane.test.ts) until the close has landed. - Each package adds its own Changeset. The sink is public through Prismarine's
"./*"export, so a package that changes its members adds aminorone.
D4a: the block replica
- Blocks reach clients only from the log.
WorldChangeSink.worldChangedandworld/WorldChange.tsare gone. A world hands its sink each sealed tick throughtickSealed, fromEntityStore.onSealed, whichWorld.blockLoginstalls, andWorld.updatecalls it so the sink reads every tick. The sink also hearschunkEntered(x, z, blockEntities)andchunkLeft(x, z)fromenterChunkand the unload. network/replica/BlockReplica.tsholds the logged fields of every block entity in the loaded chunks, as network NBT bytes by field.chunkEnteredreplaces a chunk's record with the fields of the block entities it arrived with.read(sealed)notes each block's last runtime id (layer 0 only, the one the world writes) and writes eachblock-entityfield into the replica, passing over a field in a chunk it does not hold.compound(at)foldsdataand theitems.<slot>fields withwithBlockEntityFieldand passes the result throughBlockEntityRegistry.fromNBT(...).toNBT(), so the tags come out in the order the block entity's owntoNBTwrites them, which is the order clients were sent before.NetworkWorldReplicatorkeeps one replica per world and collects the read into the same pending maps the flushes used, so the flushes are unchanged.- The sink is not handed the chunks loaded before it was attached. The level attaches it straight after it builds the world (
Level.ts,onWorldBuilt), before any chunk loads. - The seal moved.
World.updateseals after the tracking phase and then runsreplicateTick: the block changes, the block entities, the movement, the time, the chunk unloads and the outbound flush, in the order they ran before. A tick that throws is sealed and sends nothing, and the replicator keeps what it read for the next flush, as its pending maps did before. EntityStoreholdsblockandblock-entityentries andblock-shownoccurrences made between ticks and appends them when the next tick opens, ahead of that tick's own entries (isBlockEntryinChangeEntry.tsnames them). They are no longer counted as unlogged. Other writes between ticks still are, which D4c changes for entities.World.resendBlocklogs ablock-shownoccurrence carrying the runtime id it shows, since the replica keeps no blocks.- What changed on the wire: a unit that throws and is undone no longer sends the blocks it restored, because its entries leave the log. The writes it undid were never sent, since the flush runs after the tick. Nothing else changed: the scripted replay's digests are unchanged and the eight block scenarios pass.
- Proxy tests.
network/replica/BlockReplica.test.ts: holding 1 or 1000 block entities, a tick of three entries (one slot and two blocks) reads 3 entries and folds 1 compound, so the work follows the log and not the replica's size.world/WorldChangeSink.test.ts, over a real world and store: a block write reaches the sink in the tick that made it, a unit of two block writes that throws hands the sink nothing, a write between ticks arrives with the next tick, a resend is ablock-shownoccurrence, a chunk's block entities are handed over as it enters, and a chest folded from its fields gives the bytes of the chunk's owntoNBT.WorldTickPhases.test.tschecks that the seal comes afterreconcileand beforeflushBlockChanges.EntityStore.test.tschecks the carrying and that an entity write between ticks is still counted as unlogged. testing/sealedBlocks.test.helper.tsbuilds block entries, sealed ticks of them, a chest's tags, a sink that reads into a replica (replicaSink) andsealBlockKindsfor a store-backed world double. The replicator tests, the cooking, spawner, break, interaction and block entity tests read the log through them. The world doubles built on the prototype now setchanges, which the constructor would have.- Counts:
NetworkWorldReplicator.blockReplicaCounts()reports the block entities held, their bytes, the entries read and the compounds folded, andSessionReplay.test.tsreports them (non-gating). The scripted replay holds 0 block entities, reads 1 block entry and folds none. The entry is the water block its script places from the server'stickevent, between ticks, which D3 counted as unlogged and the next tick now logs. - For the later packages: the transient sink members (
blockSound,namedSound,namedParticle,worldEffect,actorEffect,actorSound) and the entity members are unchanged.replicateTickis where D4c moves the tracking phase. The block entity flush still sends oneBlockActorDataper position throughsendAround; D4d's encode-once count can take it with the occurrences. - Checks at D4a's head, on program head
204b9acc66:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The Prismarine, block-behaviors, engine-systems and parity suites withJSP_CONTENT_VANILLA=1pass together, 525 files and 5272 tests, at load average 9 to 13.SessionReplay.test.tspasses with no baseline touched. Client e2eblock-placement,mining-drops,lever-lamp,furnace,lit-furnace,farmland,edge-chunk-block-changeandsub-chunk-block-changespass.
D4b: the entity replica
network/replica/EntityReplica.tsholds every entity of a world as plain data folded from the log. A membershipenterentry starts an entity from the saved form it carries (entity/data/savedForm.tsreads the form as logged fields, named the way field entries address them), eachentityandinventory-slotentry after it writes one field or slot over that form, and aleaveentry drops it. An entry for an entity the replica does not hold is passed over, which is what happens to an entity that entered between ticks, because its entering entry is not logged.NetworkWorldReplicator.tickSealedreads each sealed tick into one replica per world, after the blocks.EntityReplica.viewanswers anEntityView(entity/data/actorView.ts): the pose, velocity,on_ground, the metadata projection, health with its maximum, the synced properties, the status effects and the equipment.entityViewOfbuilds the same view from a live entity. The metadata projection is split inactorMetadata.tsinto what a component set decides (componentMetadataFacts, over aComponentView) and what the entity's state decides (ActorMetadataState, plain values), joined byprojectActorMetadata. The replica resolves a component set from the entity's type and its loggedgroups, base components first and then each group in order, asEntityInstancedoes, and caches one view per type and group list.@jsprismarine/entity-goals'navigationProfiletakes anyNavigationSourcefor this.healthAttributesOfmoved out ofstoreActors.tsintoactorView.ts.- Logged homes. The status effect clock is the body field
statusEffectClock, stored as its reading at one update ({ age, tick }) and written at an entity's first update and after a gap in its updates, so a steady entity logs nothing for it; a new entity starts the clock again, as a new store actor did. The actor record tags are the body fieldactorTags, network NBT bytes (nbtBytes.ts, shared with the block entity log), andStoreActor.persistentNbtis an accessor over them that answers a new compound on each read. The online owner is the body fieldowner. The two conditions a client is shown,in_waterandon_ground, are logged ascondition:<name>as they change (WatchedSet). A trader's offers are the fieldmerchant, written after a draw, a trade and a restock in the form a save writes. A trade screen trades throughEntityRuntime.tradeSource, which reads the merchant on each call, so an undone trade, which builds the merchant again from the logged offers, is what the screen shows next. The saved form also carries the owner, the shown conditions, the record tags and each attribute with its bounds, which the record does not hold. - The vanilla proxy test found one more gap: a body field and
samePlaincompared numbers with===, so a pitch written from0to-0was not logged while clients were sent-0. Both compare withObject.isnow. - Nothing changed on the wire, and
StoreActors.syncstill sends. A unit that throws now also undoes the offers, conditions and owner it changed. The world and outbound digest series of the scripted replay are byte for byte those of program head52f4016c9bover its 19 sampled ticks, checked by writing both series out from the two checkouts, so no baseline moved. - Proxy tests.
network/replica/EntityReplica.test.ts: a fixture entity through thirteen shown writes (name, color, fire, water, owner, leash, a synced property, a group change, health, a status effect, a beam, a fuse and the holder's removal) and three wanderers, 30 ticks, with the folded view equal to the live one after every tick; holding 3 or 300 entities, a tick that names two of them reads 2 entries and reports 2 changed entities; an entity whose entering entry was never read has no view. WithJSP_CONTENT_VANILLA=1, 125 entities of the 131 vanilla types (the rest refuse a bare spawn) over 20 ticks match after every tick, and the replica reports 125 entities in 150771 bytes, 2657 entries read and 2618 views built.SessionReplay.test.tsholds every entity's view against the replica as each sampled tick seals: 38 views over 19 ticks, no divergence, and a replica of 2 entities in 2512 bytes with 17 entity entries read, throughreportProgress.PlayerChangeLog.test.ts' warm-up tick now includes the player's first update, which writes the clock once. - For D4c:
EntityReplica.readreturnsEntityTickChanges: the entities that entered and left, and the fields and slots each held entity had written.tickSealeddiscards it today; D4c keeps it for its sends.entityView(id)andentityReplicaCounts()are the replicator's public members.- An entity that enters between ticks never reaches the replica. Carrying entity entries into the next tick, as D4a carries block entries, fixes it.
EntityViewleaves out what a player's own state decides: breath (vitals.air,vitals.breathing), sprinting, flight from the game mode, the head yaw, and the session's skin and name. A player's own fields are logged byPlayerSystemsbut are not in the saved form, so the replica holds them only once written; D4c has to seed them or put them in the saved form. A player's equipment comes from its inventory, which the replica holds asinventory-slotentries (EntityReplica.slot).- The teleport bit of a move (
MobBody.teleportPending) is not logged. - A store actor sends the maximum health it was built with, while the view carries the logged maximum (#2995). Sending health from
attribute:healthentries fixes it. - A leash held by a player resolves through the world's sessions (
playerIdOf), asEntityRuntime.leashHolderIdOfdoes through its host.
- Checks at D4b's head, on program head
52f4016c9b:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The Prismarine and parity suites withJSP_CONTENT_VANILLA=1pass together, 491 files and 4744 tests, at load average 12 to 27, includingentities.addactorandgameplay.brain-effects; a first run at load average 15 to 27 failed three enchanting table tests that pass alone.SessionReplay.test.tspasses. Client e2espawn,villager-tradeandeating(a golden apple's effects) pass.
D4c: entity replication
- Entities reach clients only from the log.
StoreActors.sync, its per-tick comparisons and the per-tick equipment comparison are gone, withWorld.flushEntityAttributesand the sink'sentityAttributesChanged,entityMetadataChanged,entityEquipmentChanged,entityAddedandentityRemoved.network/replica/EntityReplication.tsreads each sealed tick into the replica fromtickSealedand sends it from the sink's newflushEntities, whichWorld.replicateTickruns first after the seal. It shows the entities that entered in the order of the entries: an arriving player is shown the world, then each arrival is offered to everyone else. Then it visits the entities the tick wrote, in the order the replica took them in, which is the store's, and sends each one's equipment and metadata, then their health, and queues their moves forflushMovement. A leaving entity waits for the sink'sflushDepartures, which drops it from every client, unless it enters again later in the same flush, when it leaves in order. - Where each kind comes from: a move from
position,yaw,pitchandteleportsentries; metadata from the replica's projection, with a store player's sprint, flight and breath; synced properties and health (with its maximum) from the replica; a mob's equipment from itsequipmentfield, and a player's from theinventory-slotentries of its hotbar, armor and off hand and itsinventory.handSlotfield. After the seal,World.replicateTickrunsflushEntities, D4d'sflushOccurrences,flushDepartures, D4e'sflushOwnerViews, the tracking pass, then the block changes, the block entities and the movement. An occurrence about an entity that entered reaches a client after its AddActor, and one about an entity that left, such as a killing blow's hurt flash (D4d's "For D4c" 1), before its RemoveActor. The tracking pass hides whatever the replica no longer holds, so it follows the departures. - Each send still goes out only when it differs from what the entity's viewers were last sent, over the visited entities alone. That keeps the packets the old comparison sent: the 1e-4 block move threshold, the breath's
breathWorthSendingthrottle, a metadata object written over as the actor's was (so a property that leaves the projection stays as clients hold it), and equipment compared as encoded bytes. - A first sight is built from the replica (
network/replica/firstSight.ts), sospawnToreads no live entity. Tracking readsShownEntityvalues fromWorldChangeSink.shownEntities, and each one'sspawnToencodes its packets once for each entity, codec and compression group in a flush. An entity that entered is shown as it entered, before the entries after its entry, so the AddActor and the updates that follow in that tick are the ones a store actor sent.PlayerSession'ssendAddActor,sendAddItemActor,sendAddPlayer,sendEquipmentForandsendHeldItemForare gone, and its armor, status effect and ability sends use the same builders. - D4b's five items:
EntityReplica.readreports the memberships in entry order, each entering entity as it entered, and the fields and slots an entity that entered wrote after its entry.EntityStorecarries every state entry made between ticks into the next tick, andEntityRuntime.removenow records a leaving entry between ticks too. The client e2esummon-entityfound that gap: a console/killruns between ticks, and the pig stayed on screen.- A store player's head yaw is the logged field
look.headYaw, and its entity enters the log with a shown form beside its saved form (entity/playerShown.ts): head yaw, hand slot, sprinting, game mode, flying, air, breathing, and the hotbar, armor and off-hand slots. Identity (UUID, name, device, platform chat id) comes from the store player and the operator flag from the permission service, as before. - A teleport writes the logged body field
teleports, a count; a tick that writes it sends the move with the teleport bit. - Leashes resolve players through the world's sessions, and every leashed entity is projected again in a flush that had a membership.
- Two more logged homes: an inert actor's saved identifier is the saved form's
shownAs(EntityRuntimeOptions.shownAs), and an inert TNT's fuse is its body'sfuseTicksin place of a hand-set metadata value. - A store actor's
metadataandattributesare read from the store when asked; what remains reads them: a player's own client, saves and the replay digest. The live metadata leaves out the leash holder, which only other clients are shown. - What changed on the wire: health carries the maximum the store holds (#2995). AddActor, RemoveActor and the first sights of a spawn leave after the seal instead of mid-tick, in the same tick. A player crossing a chunk mid-tick is shown entities as the last sealed tick left them. Metadata is sent for any entity a tick wrote, where the old pass projected only entities in simulated chunks. A game mode change and a revival send the player's metadata from the log instead of from
PlayerSession. The scripted replay's world and outbound digest series are byte for byte those of program head70d6c63830, so no baseline moved. - Proxy tests in
network/NetworkWorldReplicatorEntities.test.ts, over a real store's sealed ticks: holding 3 or 300 entities, a tick that writes 2 of them visits 2; anactor-eventand a removal in one tick reach a tracker as the event's frame and then the drop, and the other way round when the departures run first; a first sight to 5 viewers in 3 codec and compression groups encodes 3 times, and the 3 viewers of one group get the same bytes; a raised maximum health goes out as the new maximum (#2995); an entity removed between ticks is dropped with the next tick.entity/data/storePlayers.test.tsholds a player's AddPlayer, abilities, armor, moves and breath throttle to the old packet sequence (breath writes of 249, 245, not breathing, sprinting and survival send 1, 0, 1, 1 and 1 SetActorData), and the eye of ender test still sends 2 moves in 3 ticks.EntityReplicationCounts(visited,firstSightEncodes,firstSights) is the replicator'sentityReplicationCounts. - For D4e: status effects still go out when an actor changes them (
StoreActor.announceStatusEffect,statusEffectChanged), not from thestatusEffectsentries, because the entries carry the whole list each tick and no change kind.entityMovedstays for the movesPlayerSessionandMovePlayerHandlermake, and a player's own client still reads the liveStoreActor.metadata. - For D4f: the tracking phase still plans over every entity the replica holds, on its interval. The first-sight cache lives for one flush and is cleared by each read.
SessionReplay.test.tsdoes not reportentityReplicationCountsyet. PlayerSessionOwnSends.test.ts(D4e) no longer finds aSetActorDataPacketamong the packetssetGamemodequeues: the player's metadata goes to it and its viewers from the logged game mode after the seal.testing/changeTicks.test.helper.ts'replicateEntitiesruns the four post-seal phases in the world's order for tests that drive a replicator.- Checks at D4c's head, rebased onto program head
25cdcbb46d:pnpm run typecheck(49 tasks),pnpm run lint:customandpnpm run lintpass; lint's one warning is the existing unusedTaginpackages/leveldb/src/db/VersionEdit.ts. The Prismarine and parity suites withJSP_CONTENT_VANILLA=1pass together, 495 files and 4793 tests, at load average 11 to 15.SessionReplay.test.tspasses, and its world and outbound digest series are byte for byte those of25cdcbb46d. Client e2espawn,movement-fanout,summon-entity,attack-entity,player-knockback,damage,item-drop-pickup,death-respawn,villager-trade,eatingandrejoinpass together, anddimensionspasses alone; it fails when it runs soon afterdamagein a selection, because it asserts full health on arrival (#2997). Before the rebase, on70d6c63830,movement-rotation,look-direction,gait,natural-spawn-placement,natural-spawn-under-canopy,ender-dragon,teleportandfall-damagepassed as well.
D4d: occurrences
- Every transient fact is an occurrence in the change log, and replication sends it after the seal.
ChangeEntry.tshasblock-sound,named-sound,named-particle,world-effect,actor-sound,relayed-sound, and the existingactor-event,animate,emoteandimpulsekinds, extended with what replication needs.soundwas declared and never written, and is nowblock-sound. WorldChangeSinklostblockSound,namedSound,namedParticle,worldEffect,actorEffectandactorSound, and gained the tick phaseflushOccurrences.tickSealedkeeps the sealed tick's occurrences in log order, andWorld.replicateTickrunsflushOccurrencesfirst, ahead of the block changes, so a break's particles still reach a client ahead of its block change, as when they were sent the moment they happened. The world methods keep their names and signatures (sendWorldEvent,sendActorEvent,sendActorSound,hitBlock,playNamedSound*,spawnNamedParticle*) and log through a privateWorld.occurred. A position of another world is refused there with the verbose linegetViewersgave, since the log has no world on a position.- The four relaying handlers log in their own unit and no longer pass a send to
step.output:World.relayAnimation,World.relayEmoteandWorld.relaySoundare new, andActorEventgoes throughsendActorEvent. The emote's debug line no longer counts its audience, which the handler no longer knows. The client sound relay rebuilds its packet from the fields the client sent, which are the bytes the relay always carried, since the send path encodes from fields. - The impulse:
host.playerImpulseand itslive.tssend are gone, and replication sendsSetActorMotionto the owner from theimpulseoccurrence throughPlayerSession.sendMotion. EntityStoreholds an occurrence made between ticks for the next tick, as it holds block entries (isCarriedEntryinChangeEntry.ts). Anaction-rejectedis a record and not a send, so it is still counted as unlogged.- What changed on the wire. An occurrence logged in a unit that throws leaves with the unit and is not sent. An occurrence made between ticks, such as a sound from a tick function, goes out with the next tick's flush. A world with no entity runtime has no log and sends none of them, as D4a already had it for blocks. Inside a tick, the occurrences now follow the packets the simulation still sends inline (attributes, metadata, the spawns and removals, the owner's own state) rather than sitting between them. One consequence is visible in a capture: an
ActorEventfor an entity removed later in the same tick, such as the hurt flash of a killing blow, finds no trackers at the flush, because the removal went out inline and the trackers forgot the entity. The client removes the entity in that tick either way, so nothing on screen changes.animateandemotereach their audience through one shared batch for each codec and compression group instead of onesendfor each viewer. - Advancement timing: the advancement triggers and the plugin
entityDamageandentityDeathevents still fire inline fromEntityHostFacts.damageCommitted. No observer reads them from the log, so their timing did not move, andgameplay.combat-advancementspasses unchanged. - Proxy tests.
network/NetworkWorldReplicatorOccurrences.test.ts: one occurrence of each of the nine broadcast kinds, sent to six viewers in three codec and compression groups, is encoded 3 times, 2 of them for the first protocol version, and every viewer of a group gets the same bytes (the slice 7 target for occurrences). The same file checks each kind's audience and packet, that nothing is sent before the flush and each occurrence once, and, through the realLevelSoundEventHandler, that a relayed client sound goes out with the bytes the client sent.world/WorldChangeSink.test.ts, over a real world and store: an occurrence in a unit that throws is not in the sealed tick, one between ticks arrives with the next tick, and one at a position of another world is refused.EntityStore.test.tschecks the carrying, andWorldTickPhases.test.tsthat the flush runs after the seal and ahead of the block changes.PlayerChangeLog.test.tschecks that a shove sends no motion until replication reads the sealed tick. testing/occurrences.test.helper.tsbuilds a prototype world double over a real store that reads back the occurrences of each tick (occurrenceWorld), andsealOccurrenceshands a sink a sealed tick of occurrences. The world doubles inlive.test.ts,serverSource.test.tsandinteraction.test.tsread sounds and actor events from the sealed tick.- The scripted replay logs 20 entries and 2350 bytes over its 19 sampled ticks (
SessionReplay.test.ts, non-gating). It holds no occurrence: its session makes no sound the server logs. - For the later packages:
- D4c: the actor event flush reads
session.tracksat the flush, after an inline removal. Reading a leave entry in log order, after the occurrences before it, sends a killing blow'sActorEventto the trackers again.sendToTrackedEntitynow takes aPick<ShownActor, 'getRuntimeId'>. - D4e:
meleeAttack's sounds and particles (Combat.ts) are logged synchronously now.sendActorSound,sendWorldEventandsendActorEventare stillasyncand resolve without waiting on anything, sostep.defer8 can drop the awaits and run in the handler.hitBlockstill awaitsgetBlockStatebefore it logs. - The block entity flush still sends one
BlockActorDataper position throughsendAround, which already encodes once for each group; it has no proxy test of its own.
- D4c: the actor event flush reads
- Checks at D4d's head, on program head
52f4016c9b:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The Prismarine and parity suites withJSP_CONTENT_VANILLA=1pass together, 491 files and 4752 tests, at load average 20 to 33.gameplay.combat-advancementspasses.SessionReplay.test.tspasses with no baseline touched. Client e2eattack-entity,player-knockback,damage,eating,lever-lamp,block-placement,movement-fanoutandmining-dropspass. The NetherNet loopback tests inLogin.test.ts,ticker.test.ts's timeout test and threeenchantingTable.test.tsseed tests each failed in one full run under load and passed alone and in later full runs.
D4e: the owner's view
- The simulation no longer awaits a send to a player's own client in the three slice 7 deferred pieces and the methods they reach. A send queues its packet before its first await (
MinecraftSession.sendDataPacket), so a method that calls its sends in turn and hands their promises toallSent(PlayerSession.ts) has written everything and queued every packet when it returns. Awaiting each send in turn queued the next packet, and made the writes after it, only once the transport framed the last. Converted:setGamemode,setFlying,stopMining,announceSettings,sendGameTypeChange,sendAbilityChange,completeSpawn,sendInventory,sendArmorFor,sendAttributes(which now clears the dirty attributes as it queues them),sendStatusEffects,setPlayerPosition,openMainInventoryandreleaseArrivalChunks. They keep theirPromise<void>return, which now only says when the transport framed the packets. - The three
step.defercalls run in their handlers.SetPlayerGameTypecallssetGamemodein its unit and hands the promise tostep.output.SetLocalPlayerAsInitializedcallsWorld.addPlayer, now synchronous, andcompleteSpawnin its unit.InventoryTransaction's melee callsmeleeAttack, which returns itsbooleandirectly: the blow goes through the store's damage pipeline, and its sounds and particles are occurrences. A target that has left its world's store takes nothing, which is what the pipeline answered for it before.looseis synchronous the same way, so a bow release puts the arrow in the world in its unit. Thestep.defercount is 20 calls in 12 handler modules, down from 23 in 14 at program headf8593f7826. - D2c's and D2d's writes that stayed in a unit only because they came before the first await:
setFlying,openMainInventory,releaseArrivalChunksandsetPlayerPositionhave no await now.setSprintinghad none.World.dropContents,dropFromBlockanddropFromPlayerdrop through a synchronousspill; they make no sends.looseis above. - Where each owner send comes from now:
- The game mode change, the join's spawn state, a move, the inventory window and a released arrival: the input's handler, queued in its unit, as before. The packets and their order are the same;
PlayerSessionOwnSends.test.tsasserts the order, and the same assertions pass against the program head'sPlayerSession.tswith the calls awaited. - A trader's offers: replication.
tickSealedcollects the traders whosemerchantfield the sealed ticks wrote, and the new sink phaseflushOwnerViews, whichWorld.replicateTickruns afterflushOccurrences, sendssendTradeOffersto every session with a trade screen open on one.ItemStackRequestno longer sends them after a trade. The prices still come from the screen'sTradeSourceat the flush, since the per-player context (cures, the hero effect) is not in the log. - A slot whose write was undone: replication.
EntityStorelogs aslot-shownoccurrence for each slot an undone span wrote, once and in the order the span first named it, in the span around the undone one, so undoing that span too names the slot again (ChangeLog.revertSpanreturns the entries it removed).flushOwnerViewssends each to the player that carries it throughPlayerSession.sendOwnSlot, which assigns the restored stack its new network id and sendsInventorySlotfor the inventory, the armor, the off hand and the cursor. The ender chest and the crafting grid have no window the client always holds, so nothing is sent for them.
- The game mode change, the join's spawn state, a move, the inventory window and a released arrival: the input's handler, queued in its unit, as before. The packets and their order are the same;
- What changed on the wire. A trade's
UpdateTradeleaves after the seal in the same tick, not straight after theItemStackResponse. A level up or a restock while the screen is open now reaches it. A slot written back by an undo is sent to its owner. A failure insetGamemode,completeSpawn, a melee hit or a bow release now undoes the whole input, where it used to undo only the deferred piece. The scripted replay's digests did not move and no baseline was touched; it logs 21 entries and 2496 bytes, as at the program head. - Proxy tests.
network/PlayerSessionOwnSends.test.ts, over a real session:setGamemodehas written the mode and queued its 8 packets before it returns, and awaiting adds none;completeSpawnqueues 18 packets in the old order and has cleared the dirty attributes; an undone slot leaves oneslot-shownentry and is sent under a network id other than the one it had.entity/data/EntityStore.test.ts: a unit that wrote one slot twice and another once leaves 2 occurrences, a nested undo inside an undone unit leaves 2, and a unit that finishes leaves none.network/NetworkWorldReplicatorOwnerViews.test.ts: 3merchantentries over two ticks send the offers once to each of the 2 players trading with that trader and 0 times to the others, and slots go to their owner alone in log order.WorldTickPhases.test.tsplacesflushOwnerViewsafterflushOccurrences.network/PlayerSessionAwaits.test.tswalks calls onthisfromWorld.updateand fromPlayerSession.updatePlayerand reports the awaits it reaches throughreportProgress: 27 in 50Worldmethods, unchanged, and 53 in 48PlayerSessionmethods, down from 64;PlayerSession.tsholds 145 awaits, down from 184. It fails if a method the input handlers run in their unit gains an await. - For D4f:
inventory-slotentries reach the owner only for an undo. The client learns the result of its own stack requests from theItemStackResponse, so sending a slot for every entry would send each request's slots twice. Sending the rest from the log needs replication to know which slots a response answered. Until then the whole-inventory resends (the commands incommand/host/, a pickup inlive.ts, the item interactions initem/interaction.ts) still callsendInventoryfrom simulation code, without awaiting it.World.sendActorSound,sendWorldEventandsendActorEventare stillasyncand resolve without waiting, someleeAttackandloosehand their promises a.catch. Making them synchronous removes that.PlayerSession.tsstill holds 145 awaits; most are chunk streaming, the dimension change and the respawn, which are slices 8 and 9.
- Checks at D4e's head, on program head
f8593f7826:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTagtype inpackages/leveldb/src/db/VersionEdit.ts, which this package does not touch. The Prismarine and parity suites withJSP_CONTENT_VANILLA=1pass together, 495 files and 4775 tests, at load average 36 to 63.SessionReplay.test.tspasses with no baseline touched. Client e2espawn,rejoin,death-respawn,villager-trade,recipe-book,eating,attack-entity,teleportandmining-dropspass.
D4f: the close
- The target "a snapshot plus the log reproduces the replay digest" gates. A third run of the scripted session in
SessionReplay.test.tsstops at the tick before the sample, which is the snapshot, and runs no tick after it. It writes each sampled tick of the first replay's log onto that world throughEntityStore.replay, and after each one the digest, taken with the sealed tick's header, equals the one the replay had as that tick sealed: 20 ticks, the snapshot's own included. Entity ids are mapped from the replay's players to the snapshot's, since runtime ids are process-local. A chunk entry takes the input the replay took: the snapshot asks for the chunk and admits it when the entry comes, and a chunk that left is taken out by the newWorld.replayChunkLeave, which needs only the position. The sample enters 6 chunks and unloads at least one. Leaving out the entity entries makes 17 of the ticks diverge, so the comparison bites. - What the digest hashes and the log did not hold, and where each went:
- The block update queue: logged. Each queue, move to an earlier tick and removal is a
block-updatefield entry holding the due tick (BlockUpdateScheduler.observer). Applying the entries in order keeps the queue's processing order, which the digest hashes, and the log now undoes a unit's updates, soBlockUpdateScheduleris no longer aUnitJournal;UnitJournalandEntityStore.addUnitJournalare gone. - The scheduled block and fluid ticks: logged. Each tick queued and taken off is a
scheduled-tickfield entry holding its trigger tick, priority and order (TickScheduler.observe,TickList.restorein@jsprismarine/engine-systems). This also closes D3's "Not undone yet" for scheduled ticks: an undone unit takes back the ticks its writes queued.WorldChangeTick.test.tscovers both queues, since the scripted session's water reacts through block updates that drain in the tick that queued them and queues no scheduled tick. - The level clock, the weather, the game rules, the random tick generator, the depth of the light queue and the chunk loads in flight: in the sealed tick, as its
header(world/changelog/TickHeader.ts,World.tickHeader). Each moves every tick whether or not anything was written, or belongs to the level that every world shares, or is work derived from block writes, or is an input in flight, so an entry for each would break "an idle world appends no entries".World.replayStateDigest(header)hashes a given header in place of the world's own, with the same bytes as before. The light queue is still not undone with a unit; its depth is in the header, so a unit that throws after a write that queued light leaves the depth as the write left it, as D3 found.
- The block update queue: logged. Each queue, move to an earlier tick and removal is a
- Outbound packets count toward the tick that queued them (ADR 0006).
MinecraftSessionreads the tick when a send queues a packet, not when the transport frames it, so a packet replication sends counts toward the sealed tick it read. The scripted replay frames every such packet inside its tick, so its outbound digests did not move;MinecraftSessionOutboundCodec.test.tschecks a packet framed after its tick ended. WorldChangeSinkcarries the sealed tick and its flush phases, and loststatusEffectChanged: a status effect change is astatus-effectoccurrence, sent after the seal to the trackers with one encode for each group and to the entity's own client. What it still hears outside the sealed ticks, and why, is in its comment: a chunk entering and leaving with its block entities (the content arrived as an input),entityMovedfor a player's own moves (a head turn alone, the pose resent at a spawn and a respawn's teleport bit, which the log's pose comparison does not show), and the time and a game rule (the level's, which no world's log holds).- Counts through
reportProgressfromSessionReplay.test.ts, not gated: replication read 18 entries (1 block, 17 entity), visited 6 entities and encoded 2 first sights for 2 first sights. The change log holds 35 entries and 4260 bytes over the 19 sampled ticks, up from 21 entries at program head847fe2cf32: the block updates the water queues are entries now. The world and outbound digest series are byte for byte those of847fe2cf32, checked by writing both series out from the two source trees, so no baseline moved. - Leftovers done: D4e's 2,
World.sendActorSound,sendWorldEventandsendActorEventreturn nothing, andWorld.sendStatusEffecttoo, so their callers hand no promise a.catch; the actor event relay logs in its handler's unit with no output. D4c's status effects go out from the log, as above.PlayerSession.tsholds 132 awaits, down from 133;Worldkeeps 28 reachable fromupdate. - Not done, with the reason and what each blocks:
- Every owner slot from
inventory-slotentries (D4e's 1). Replication would have to know which slots anItemStackResponsealready answered, or each request's slots go out twice. The response is built in the handler from the request, so this needs the response to be an occurrence that names its slots. Until then the whole-inventory resends incommand/host/,live.tsanditem/interaction.tscallsendInventoryfrom simulation code. It blocks moving replication off the simulation's thread. - Trade prices at the flush (D4e). The price depends on the trading player's cures and hero effect, which are not in the log. A
merchantentry says the offers changed, and the screen'sTradeSourceprices them when they go out. It blocks a replica that answers a trade screen alone. - Tracking over every entity the replica holds (D4c). A tracking pass decides what each viewer sees from where the viewers stand, which moves without an entry for the entities; it runs on its interval over the replica, not the simulation. Making it follow the log needs a way to find the entities near a viewer that moved without walking all of them. It blocks nothing in slice 7, since the pass reads only the replica.
- The workstation input slots (
OpenBlockContainer.inputs, the D2 close). The digest and the save hold no workstation slots, so the snapshot target does not need them, and logging them would put a window that is session state into the log. It needs the decision the D2 close named: whether an open workstation is player state, with a home on the store player. It blocks undoing a stack request that moved items into a workstation and then threw. entityMovedandtimeChanged,gameRuleChanged, as above.
- Every owner slot from
- Changeset
change-log-snapshot.md:minorfor@jsprismarine/prismarine(public members removed or changed:WorldChangeSink.statusEffectChanged,UnitJournal, the async return of fourWorldmethods) andpatchfor@jsprismarine/engine-systems(newobserveandrestore). Both are below1.0.0. - Checks at D4f's head, on program head
847fe2cf32:pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTaginpackages/leveldb/src/db/VersionEdit.ts. The Prismarine, parity, client, block-behaviors and engine-systems suites withJSP_CONTENT_VANILLA=1passed 543 of 545 files (5421 tests) at load average 13 to 30; the two failures were port binds under load,Cli.test.ts(UDP port 19280 in use) and a timeout inmodules/network.test.ts, and both files pass alone. Client e2espawn,rejoin,movement-fanout,block-placement,mining-drops,lever-lamp,lit-furnace,attack-entity,item-drop-pickup,villager-trade,recipe-book,eatinganddeath-respawnpass together, anddimensionspasses alone.
Phase E: the synchronous tick
| Package | Slice | Depends on | Work |
|---|---|---|---|
| E1 | 8 | D3, B4 | Saves at a tick boundary, under ADR 0018 |
| E2 | 9 | D4 | Dimension travel as messages, and worlds built outside ticks |
| E3 | 9 | D4 | Chunk tickets and waiting states for the dragon fight |
| E4 | 9 | E2, E3 | Tick functions as inputs, and the test that a tick completes without yielding |
E1, E2 and E3 run side by side.
E1 split
E1 runs as five parts. E1a goes first, and E1b needs it. E1c and E1e can run side by side once E1b has landed. E1d edits the provider's chunk write, as E1c does, so it follows E1c. Each part leaves the suite green, and the replay digests do not move.
| Part | Work | Main files | Not mechanical | Gate |
|---|---|---|---|---|
| E1a the commit path | Database.write takes a per-call sync. Database.commit applies a batch to the memtable at once and appends and syncs its log record on libuv's thread pool, framed as fragment headers over slices of the encoded batch, so the framing copies nothing. Commits append in sequence order, and write, flush and close wait for or refuse an append in flight | packages/leveldb: Database.ts, format/LogFormat.ts, db/Storage.ts | What a read sees while a commit is in flight, and what a failed append leaves: the memtable holds a batch the log does not, so the database refuses every later write | A torn commit applies none of its keys after a reopen; one commit appends one record; the bytes a commit copies are its encoded batch and nothing else |
| E1b the level's pass | One pass for a level and every dimension it holds. The cut runs in Level.update after the worlds, on the autosave tick: it builds every player record, persists the data entities, takes each chunk's entity membership and actor records, and plans each changed chunk's sub chunks. A sub chunk is encoded over the following ticks, a bounded count each tick, or first when a write is about to reach it, with the chunk's Data3D. The pass commits one batch through E1a outside the tick, marks each chunk saved as of the cut, and rewrites level.dat once, by atomic replace. A player record or chunk written outside the pass waits for the pending commit | world/LevelSave.ts (new), Level.ts, World.ts, chunk/Chunk.ts, providers/leveldb/LevelDB.ts, providers/leveldb/LevelDat.ts, PlayerPersistence.ts | Marking a chunk saved against the cut's revisions rather than its current ones; a write that bypasses Chunk (getSubChunk(...).setBlock) is caught by a revision check at encode time until E1e closes it | One batch per level per pass; a write after the cut is not in that commit and forces at most one sub chunk encode; a crash between cut and commit leaves the previous save; an idle pass writes nothing; the saved state equals the cut tick's |
| E1c writes outside a pass join the next commit (landed) | A player's record on disconnect and on a dimension change, and a chunk written on unload or after loading dropped a stackless item, are held in the level's next batch instead of written. A read of a held key answers from the batch. An unloading chunk leaves memory once its bytes are held. The level record is written by a pass that cuts only the level's state, which carries the held writes | world/LevelSave.ts, Level.ts, PlayerPersistence.ts, World.ts (unloadChunk), providers/leveldb/LevelDB.ts, providers/leveldb/OverlayBatch.ts (new), chunk/Chunk.ts | Reads that must see held writes: readChunk, readPlayerData, the digest and actor reads of a later chunk write; and when a held batch commits if no autosave comes, at shutdown | A disconnect after the cut lands in the commit after it; a crash before it loses the record and the world state together; a reload of an unloaded chunk reads its held bytes |
| E1d fresh chunks outside the cut | A chunk generated since the last pass and untouched by gameplay is written on its own, as terrain and its generation spawns as pending entity records, with no actor records or digest. One that gameplay touches before it is written moves into the cut whole | providers/leveldb/LevelDB.ts, chunk/Chunk.ts, World.ts (createGeneratedEntities) | The generation spawns become live mobs when the chunk enters the world, so the fresh write needs a copy taken before that, and the next cut must delete the pending record when it writes the mobs. A pending record carries type and position only; a spawn's beam target, bottom plate and invulnerability need a format decision from the owner before this starts | The first pass after a start region holds what gameplay changed, not the 441 fresh chunks; a crash after a fresh write and before the cut reloads the generated mobs once |
| E1e read-only chunk getters | getSubChunk, getOrCreateSubChunk and getBiomes stop handing out mutable state to code that runs after generation and loading. Generators and the provider get their own entry points | chunk/Chunk.ts, chunk/SubChunk.ts, chunk/ChunkBiomes.ts, the generators under generators/ | Which callers are generation and which are runtime; the benchmarks and tests that write through getSubChunk | E1b's revision check has no reachable failure, and a type test shows a runtime caller cannot write through the getters |
E1
E1a, E1b, E1c and E1e landed, each in small commits. E1d did not start, and slice 8 is not whole until it lands or the ADR is amended to leave it out. It follows the merge of the program branch as #3060, which states the format decision it waits for: the pending entity record is this project's own key, so the game does not read it, and growing it needs a reader for the old shape.
E1a.
Database.writetakes a per-callsync.Database.commitapplies a batch to the memtable at once, so a read sees it, and appends its record withfs.writevand syncs it withfs.fsyncon libuv's thread pool. A record is framed as 7-byte fragment headers and block padding over views of the encoded batch (frameLogRecord), so the values are copied once, into the encoding;LogWriterframes through the same function and its output is byte for byte what it was. Commits append in the order they were made (AppendFile.appendLater),writerefuses while one is appending (Database.appendingsays so), and the memtable flush andclosewait for it. A failed append leaves the database refusing every later write, since its memtable holds a batch its log may not.replaceFileAtomicallyis exported forlevel.dat.E1b.
LevelSaveowns a level's writes to its save. On the autosave tick, at the end ofLevel.update, its cut callsWorld.cutSavefor every ready world: each viewer's record,dataEntities().persist(), the entity membership and actor records of every chunk that changed or holds entities, the block entities, and the deletions. It holds back each chunk's changed sub chunks andData3D(LevelDBChunkCut), and plans the level record andlevel.datfromLevel.levelDataNow. Each later tick encodesSAVE_ENCODE_BUDGETsub chunks. A write that is about to reach a held sub chunk callsChunkSaveCutHook.beforeSubChunkWrite, which encodesData3Dand that one sub chunk first; a biome replacement encodesData3D. After the last encode the pass commits one batch throughDatabase.commitwithsync, outside the tick, marks each chunk saved as the cut saw it (Chunk.markSavedAs), and then writeslevel.datonce, by atomic replace. A save that is asked for (Level.save,World.save, a shutdown, a replay checkpoint) cuts between ticks when its turn in the queue comes, and finishes an autosave still encoding first, because a shutdown stops the ticks.At E1b's head, writes outside a pass waited rather than joined, until E1c: a chunk the unload pass writes, the stackless-item rewrite and
saveLevelDataqueue behind the pass inLevelSave.enqueue, andPlayerPersistence.savewaits for a commit that was pending when it was called. A record built before a cut lands before that cut's commit; one built after lands after it.A batch that is not a pass's and is not held (since E1c, a provider used on its own and the legacy migration) is appended before
writereturns when no commit is appending (LevelDB.writeSmall). On the thread pool it lands a turn of the event loop later, and the replay harness's settle check, which cannot see thread-pool I/O, let the next tick run first:SessionReplay.test.tssaw two chunk leaves a tick late.Proxy tests, counts and bytes only.
LevelSave.test.ts: a pass over four chunks in each of two dimensions commits one batch holding both dimensions' keys, and nothing reaches the database during the ticks that encode it; a write after the cut forces one sub chunk encode, one in total, and the commit holds the block as it was at the cut; chunks written on every tick after the cut are saved as they were at the cut; a cow removed after the cut is in that commit and gone from the next; a copy of the folder taken between cut and commit reopens to the previous pass; a second pass with nothing changed writes no batch, and one after 20 idle ticks writes onlyjsprismarine:world_data.LevelDBSavePass.test.tscovers the provider alone, a commit cut in half included.Database.test.tsandLogFormat.test.tscover a torn commit, the order of commits, and framing that copies no payload byte. Bytes per pass go throughreportProgressfromLevelSave.test.ts: 153 keys and 89,402 bytes for eight fresh chunks of solid stone.No saved format changed.
PlayerPersistenceFormat.test.ts,DiskFormat.test.ts,VanillaWorld.test.tsand the provider tests pass unchanged; the chunk keys and values a pass writes are thosewriteChunkwrote, andlevel.datholds the same bytes, now written through a rename.What runs at another point than before:
- The autosave's cut is at the end of the level's tick, after every world. It used to start at the beginning of each world's tick and run across later ticks' awaits.
- A pass spans as many ticks as its sub chunks need at
SAVE_ENCODE_BUDGETa tick: 56 for 441 fresh Overworld chunks. level.datis written once per pass, where it was written once per dimension.- A shutdown writes every world of the level in one pass.
World.disablesaves through the level,World.stopstops without saving, andWorld.saveForReplayis gone.
Left for later, and what each blocks:
- E1d. Fresh chunks are still part of the cut, so the first autosave after a start region holds about 10 MB until its commit, the bound ADR 0018 accepts. Writing them outside the cut needs the generation spawns kept as pending entity records, and today's record holds a type and a position, not a spawn's beam target, bottom plate or invulnerability. Extending it is a format change, so it waits for the owner's decision.
E1b listed E1c and E1e here, and an autosave skipped while any other write to the save was queued. E1c and E1e close all three; see below.
Checks at E1b's head:
pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTaginpackages/leveldb/src/db/VersionEdit.ts.JSP_CONTENT_VANILLA=1 pnpm exec vitest run --maxWorkers=4 packages/leveldb packages/prismarine packages/client packages/paritypassed 539 files (5,259 tests) at load average 16 to 17. Client e2espawn,rejoin,block-placement,death-respawnanditem-drop-pickuppass together, anddimensionspasses alone.E1c.
LevelSaveturns onLevelDB.holdOutsideWrites. From then onwriteChunk,writePlayerDataandwriteWorldDataput their keys into anOverlayBatch, which keeps one write per key, the latest, in the order the keys were last written.beginSavePasstakes the held writes as the pass's preamble and commits them ahead of the pass's own keys in oneDatabase.commit; a write held after that call waits for the pass after it, and an abandoned pass hands its preamble back. The unload pass and the stackless-item rewrite write their chunk at once rather than queueing,PlayerPersistenceno longer waits for a pending commit when the provider stores the record (the fallback JSON still does), andLevelSave.enqueueandLevel.enqueueSaveare gone. WhenheldWriteBytespassesHELD_WRITE_LIMITon a tick with no pass running, the tick commits the held writes in a pass that cuts nothing.Level.saveLevelDatabecame a pass that cuts only the level's state, so it carries the held writes and writeslevel.datafter its commit.LevelDB.disablecommits whatever is still held, and logs a commit that fails before it closes.How a held write is read: every provider read goes through
LevelDB.read, which looks at the held writes, then at each pass's batch the database has not applied yet, newest first, then at the database. An overlay in the provider, not the memtable. The memtable flushes to a table on its own schedule, when it fills or at close, which would make a held write durable before the pass that should carry it, and a held write in the memtable would also reach every other reader of the database. The pass's own batch is in the overlay because a write after a cut decides what to delete from what is stored, and the pass lands first.entriesNowgiveslistChunksandcaptureReplaySnapshotthe same view, byte for byte the database's when nothing is pending.The limit, 4 MiB, is the database's default write buffer: about 190 changed chunks at the measured 22,327-byte mean, or nine chunk borders a player crosses at the default view distance. An early commit then fills at most one memtable, and with the 10 MB ADR 0018 lets a pass hold, a pass cut just before the limit holds about 14 MB. The tick checks once, so the held bytes can pass the limit by one tick's writes, at most
CHUNK_UNLOAD_BATCH_SIZEchunks.The autosave skip: the autosave is still skipped while another pass runs, for the reason
LevelSave.tickgives, an ever-growing backlog of passes over the same chunks. Writes outside a pass no longer take the queue, so a stream of unloads cannot delay it.What was not mechanical:
- A chunk written outside a pass while an earlier pass that cut it has not committed.
Chunk.getPersistenceChangescompares with the last save, not with that pass, so a section the pass puts and gameplay empties afterward is on neither list. The cut first has the earlier cut encode what it still holds (Chunk.finishSaveCut,ChunkSaveCutHook.finish), since it takes the chunk's hook, and then deletes each section the earlier cut put that the chunk no longer holds (LevelDB.unsettled).Chunk.markSavedAsignores a state older than the one it holds, so the earlier pass's commit does not mark dirty a chunk the held write saved, which would have kept it from leaving. - The legacy migration writes at once even while writes are held, because the level deletes
level.jsononce it returns. - Sixteen test files whose doubles of
BaseProvidergo throughfakeLevelbuild them withstubOf(BaseProvider, ...), sinceLevelSavenow calls the provider when it is built and on each tick.
- A chunk written outside a pass while an earlier pass that cut it has not committed.
Proxy tests, counts and bytes only.
LevelSave.test.ts: a disconnect after the cut is not in that commit, which is one batch, and is in the next, whoseheldWritesis 1; a disconnect and rejoin with no pass between reads the record back and writes no batch; a chunk unloaded and loaded again with no pass between has its gold block and writes no batch; three unloads and two disconnects between passes land in one batch holding the three chunks and both records,heldWrites8 (a section andData3Dper chunk, a record per player); eight records under the limit commit nothing on a tick, and records past it commit one batch of their own on the next tick, which the following pass does not repeat; a copy of the folder taken after an unload and a disconnect reopens to the previous pass, without the record.reportProgressgives 361 records of about 11.6 KB, 4,200,220 bytes, for the early commit.LevelDBSavePass.test.ts: a held write of a chunk a pass still holds finishes that cut (two forced encodes) and deletes the section the pass put; an abandoned pass hands its held write to the next; closing commits what is held as one batch.OverlayBatch.test.tsandChunkSaveCut.test.tscover the batch and the twoChunkchanges. The E1b figure, 153 keys and 89,402 bytes, did not move.No saved format changed.
PlayerPersistenceFormat.test.ts,DiskFormat.test.tsand the replay digests inSessionReplay.test.tspass unchanged.VanillaWorld.test.tsis unchanged and skips without its fixture, which is not on this machine.What E1c leaves:
- The fallback JSON records of a provider without player storage are not part of the commit: they are written after it, as before.
level.datis still written after the commit, not in it, as ADR 0018 says.Database.commitrefuses a write once when a background flush failed, and then accepts writes again. A pass refused that way hands its held writes back, but its own chunks rely on staying dirty, as in E1b.
Checks at E1c's head:
pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTaginpackages/leveldb/src/db/VersionEdit.ts.JSP_CONTENT_VANILLA=1 pnpm exec vitest run --maxWorkers=4 packages/leveldb packages/prismarine packages/client packages/paritypassed 540 files (5,276 tests) at load average 20 to 26. Client e2erejoin,spawn,death-respawnanddimensionspass, one at a time.Found on the way: the LevelDB level record drops running raids (#3042).
E1e.
getSubChunk,getOrCreateSubChunk,getSubChunks,getSubChunkEntries,getPersistenceChangesandnetworkSerializeBlobshand outReadonlySubChunk, whosegetStoragesreturnsReadonlyBlockStorage, andgetBiomesreturnsReadonlyChunkBiomes. Each is aPickof its class's readers, so the compiler refuses a write through one, and nothing is copied on read. The writable paths left are the chunk's own:setBlockRuntimeId,setBlock,restoreBlockRuntimeIdandfillColumnfor gameplay and the flat and debug generators; the newsetBiomeandfillBiomeColumnfor a biome write after generation, which only tests and plugins make today;setBiomes, which the generators and the provider call to hand over a column they built; and the constructor, to which the provider,networkDeserializeand the data-driven generator hand the sub chunks they decoded or built, before the chunk is in a world.setBiomesnow marks the chunk changed, andgetOrCreateSubChunkreturns the sub chunk already at its index, where it used to replace it with an empty one.The revision check and
bypassedWritesstay. A plain JavaScript plugin can still reach a sub chunk's ownsetBlock, and a builder could keep a reference to what it handed over, so the check is kept as one comparison per encode.LevelDBSavePass.test.tsdrives each writable path after a cut: a block write forces one sub chunk encode and the commit holds the block as it was at the cut, a biome write forces none and the commit holds the biome as it was, and a write through the view, typed away with@ts-expect-error, counts one bypass and logs it.LevelSave.test.tswrites throughWorld.setBlockRuntimeIdon every tick of a pass and counts no bypass.ChunkReadonlyViews.test.tschecks at compile time that no getter's result has a mutator.E1e changed no saved format and no replay digest:
DiskFormat.test.ts,VanillaWorld.test.ts,PlayerPersistenceFormat.test.tsandSessionReplay.test.tspass unchanged. A Changeset (read-only-chunk-getters) records the narrowed types as a breaking change to@jsprismarine/prismarineand names the methods to call instead.Checks at E1e's head:
pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass, with the sameTagwarning.JSP_CONTENT_VANILLA=1 pnpm exec vitest run --maxWorkers=4 packages/prismarine packages/chunkgen packages/worldgen packages/lighting packages/client packages/paritypassed 564 files (5,718 tests). Client e2espawn,block-placementandrejoinpass, one at a time.
E2
E2 is too large for one pass, so it splits in two. E2a goes first.
| Part | Work | Main files |
|---|---|---|
| E2a portal travel as messages | A trip through a portal leaves its world's tick as a message. The destination world is built and the arrival chunks are loaded outside every tick, and the destination admits the arrival in its input step once they are loaded. The source side leaves its world in the same input step, so a trip is one leave entry in the source world and one enter entry in the destination. Removes the portal travel entry of IN_TICK_CHUNK_LOADS | world/portal/DimensionTravel.ts, entity/data/storeActors.ts, world/Level.ts, world/TickContext.ts, Server.ts |
| E2b the other world changes | /tp and changeWorld into another dimension, the queued change that completeDimensionChange starts, the credits, and SetDefaultGameType as messages, which removes their step.defer calls | command/host/entities.ts, command/jsprismarineCommands.ts, network/PlayerSession.ts, the handlers that defer a world change |
E2a, and the credits from E2b, left these notes.
- How a trip runs. In a source world's tick,
StoreActor.updatefinds the contact ready and callsDimensionTravel.depart, which plans the trip from the source world alone (the route, the scaled target, the traveler's place in the portal, its size and facing), files it and returns. It starts the destination's preparation throughoutsideTick(world/TickContext.ts):Level.prepareWorldbuilds the world if nobody has entered it, then the chunks the arrival reads are loaded, and those chunks enter at an input step like any other. Nothing in the destination changes in that tick, and the traveler stays in its world.Server's input step runsDimensionTravel.admitafter the chunk completions and the respawn answers. A trip lands once its world is ready (Level.readyWorld) and either a portal stands within the search radius in loaded chunks or every chunk of the creation area is loaded. The arrival is then resolved from loaded chunks only, a portal or the End platform is written, andtransfertakes the traveler out of the source store and into the destination's. Both writes land in the tick the input step opened, one membershipleavein the source world and oneenterin the destination. A trip that is not ready stays filed, and a trip whose traveler left its world first is dropped. - Readiness is decided in the input step from loaded chunks, so a replay that admits the same chunk completions at the same ticks lands each trip at the same tick. Preparation runs while ticks run and is never awaited by one.
- Timing: a trip lands at the input step of a later tick, not inside the update that started it. The traveler spends the rest of that tick in the source world, and the data-driven half of its tick runs there once more. No replay digest moved, because
SessionReplay.test.tshas no portal trip, so nothing was re-baselined. - While a trip is filed,
DimensionTravel.touchskips the traveler, so standing in the portal starts no second trip. - The credits.
PlayerSession.completeCreditsreturns abooleanand files the change withDimensionTravel.move, which the same input step share carries out before the trips:changeWorld, then the respawn answer.ShowCreditsHandlerno longer defers. The change runs one input step later than before; the client sees the same packets in the same order (client e2eender-dragon). - Removed:
DimensionTravel.resolve, and theIN_TICK_CHUNK_LOADSentry "portal travel loading the arrival chunks". Three entries are left: the respawn search, the dragon fight (E3) andWorld.setBlockRuntimeId. step.defercalls, asnoServerAccess.test.tsreports them: 20 in 12 handler modules before, 19 in 11 after.- Proxy tests, in
DimensionTravel.test.tsunder "DimensionTravel as messages": a mob's trip appends no membership entry in its source tick and no entry at all in the destination, then exactly oneleavein the source and oneenterin the destination at the input step; the same for a creative player driven throughWorld.updatewith the completion queue active, counted over every input step until it lands; and a trip into an End nobody entered asks for the build exactly once, from outside the tick (insideTick()is false at the call), and the traveler is still in its world when the tick ends. - Left for E2b:
/tpandchangeWorldinto another dimension, which awaitworldForandchangeWorldinside a command; the queued changecompleteDimensionChangestarts, and itsstep.defer, which needs the advancement trigger kept after the sends;releaseChangeHeldOverDeath;SetDefaultGameType. - Left for E4:
transferstill awaits inside the input step: theplayerChangeDimensionevent,startCredits' send andchangeWorld, which saves the player record and awaits its sends. A trip out of the End awaits the respawn search for a player andgetSpawnPositionfor anything else, also in the input step.touchstill awaitsentityPortalEnter.outsideTickis anAsyncLocalStorage.exit; once the tick is synchronous and the store is a flag, it is a call that clears the flag. - Risk for chunk tickets (E3): the chunks a trip loads hold no ticket. The unload pass can take them from a destination that has players before the trip lands, and the trip then loads them again.
- Checks at this head, on program head
9c09572f42:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The Prismarine suite withJSP_CONTENT_VANILLA=1passes, 442 files and 4493 tests, with 2 files and 10 tests skipped; the run before the last fix failed onlyblockContainer.test.ts, whose travel double had noisDeparting. The parity and client suites passed 68 of 69 files:SessionReplay.test.tsfailed under the suite at load average 20 to 25 and passes alone with no baseline touched. Client e2edimensionsalone, thenspawn,rejoin,death-respawn,spawnpoint-respawn,ender-dragon,item-drop-pickupandteleportpass. NetherNet tests neededpnpm --filter @jsprismarine/native-network run build:nativein this worktree first.
E2b left these notes.
- A move.
DimensionTravel.moveTo(session, target, then)files a player's change of world, andmoveis the same for a world already built. A target (MoveTarget) answers the arrival once it can land without waiting and prepares what it waits for outside every tick:stemTargetbuilds a stem's world throughLevel.prepareWorld,spawnTargetsearches a world's spawn when it has none, andarrivalTargetwaits for nothing. The input step'sadmitcarries out each ready move throughchangeWorld, then itsthen, before the portal trips. A move that is not ready stays filed, and every later move of the same player waits behind it, so a player's moves land in the order they were filed. A player who disconnected is not moved. - A cross-dimension
/tpresolves the stem withWorldManager.stemForand files a move to it, so the command returns at once and nothing waits on the destination. A dimension the level has no stem for is refused at once with "This level has no ... dimension".worldForincommand/host/support.tsis gone.WorldManager.startWorld, which a command that writes into an unbuilt dimension calls, builds it outside every tick throughprepareWorld. PlayerSession.setWorld, which thechangeWorldcommand calls, files a move to the world's spawn and returns nothing; the command now answers "Moving ... to ...".completeDimensionChangekeeps its order: the teleport, the attributes, the inventory,SetSpawnPositionand then the changed dimension advancement hook, exactly as before, so no packet moves. Only its end changed: the change held behind the pending one is filed as a move rather than awaited. The change stops being pending before the method's first wait, soServerboundLoadingScreenHandlercalls it in the handler and hands the rest tostep.output. The hook now runs in that output, outside the handler's unit, as D2c's and D2d's notes describe for other writes after a wait. The check isprotocol.dimension-changeand client e2edimensions, which pass.releaseChangeHeldOverDeathfiles the held change as a move and returns nothing;respawncalls it without waiting.WorldMessages(world/WorldMessages.ts,Server.getWorldMessages,HandlerServices.messages) is how an input in one world changes another. A message names its world and is applied in the input step, after the moves and trips, as a unit of that world's change log.SetDefaultGameTypewrites the game mode of every player in the sender's world in its handler, with the sends instep.output, and sends each player in another world a message that sets the mode and then tells that client the default. Each client still hears its new mode and then the default; across clients the order is now per client rather than every mode first.- Timing: each of these changes lands at an input step after the one that asked for it, or later in the same step when the step's travel share has not run yet, as for a command a player types. A console command runs between ticks and lands at the next step. No replay digest moved, and nothing was re-baselined.
step.defercalls: 19 in 11 handler modules before E2b, 17 in 9 after (ServerboundLoadingScreenandSetDefaultGameTypeno longer defer). Left:PlayerAction(6) andInventoryTransaction(1), which break and hit blocks throughWorld.breakBlockandhitBlockand ask for a respawn;Respawn(1), the respawn search, which is E3's;ResourcePackClientResponse(3) andResourcePackChunkRequest(1), the kick and the join burst;CommandRequest,SettingsCommandandText(1 each), a command and a chat line; andinputParts(2), which only runs what the parts it holds defer.- Proxy tests, in
DimensionTravel.test.tsunder "DimensionTravel as messages": a move into a Nether nobody entered, filed inside a tick, asks for the build once from outside the tick, appends no membership entry in the tick that filed it, and lands as oneleavein the Overworld and oneenterin the Nether; a player's second move waits behind a first that is not ready, and both land in order; a message for the Nether appends nothing in either world until the input step, and then entries in the Nether only. The unit tests for/tp,setWorld, the held changes, the loading screen andSetDefaultGameTypeassert that nothing changes before the step admits. - Left for E4, updated:
admitstill awaitschangeWorldfor each move and trip, which saves the player record and awaits its sends, andtransferawaits theplayerChangeDimensionevent andstartCredits' send. A trip out of the End awaits the respawn search for a player andgetSpawnPositionfor anything else.touchawaitsentityPortalEnter.WorldMessages.admitawaits each message's sends, andcompleteDimensionChange's output awaitsoverworldSpawnOf, which reads the level's spawn and builds the Overworld only before it has one.spawnTargetsearches a spawn outside the tick, and that search writes the world's spawn between ticks.outsideTickbecomes a call that clears the tick flag once the tick is synchronous.WorldManager.worldForstill awaits a build and has no production caller left. - Checks at this head, on
1559b5ec5f:pnpm run typecheck,pnpm run lint:customandpnpm run lintpass. The Prismarine suite withJSP_CONTENT_VANILLA=1passes, 442 files and 4496 tests, with 2 files and 10 tests skipped. The parity and client suites pass, 69 files and 399 tests,SessionReplay.test.tsandprotocol.dimension-changeincluded. Client e2edimensionsalone, thenspawn,rejoin,death-respawn,spawnpoint-respawn,ender-dragon,teleportandchatpass.
E3
E3 is too large for one pass, so it runs as three parts, in this order. Each part removes its own IN_TICK_CHUNK_LOADS entry.
| Part | Work | Removes |
|---|---|---|
| E3a | Chunk tickets on World: a ticket starts the loads it lacks outside the tick, so each chunk enters at an input step, and keeps its chunks from unloading until released. The dragon fight waits for its arena, for the chunks its death consequences write and for each pillar it resets | The dragon fight entry |
| E3b | The respawn search reads loaded chunks only. An attempt that lacks a chunk takes a ticket and waits, and RespawnRequests tries it again at each input step. The death, the Respawn handler and the respawn action file requests | The respawn search entry, and the two respawn step.defer calls |
| E3c | World.setBlockRuntimeId, setBlockByName and breakBlock write into loaded chunks only, and breakBlock writes before it first waits | The World.setBlockRuntimeId entry, and the three block action step.defer calls |
All three parts landed in one pass.
world/ChunkTickets.tsholds a world's tickets, andWorld.chunkTickets()returns them.request(owner, chunks)starts a load for each missing chunk and returns whether all of them are loaded. A second request from the same owner replaces its ticket. The load runs underoutsideTick(world/TickContext.ts), so its chunk waits for an input step even while the tick that asked is open. A ticketed chunk does not unload:World.chunkRetainedasks the player areas and the tickets.- The dragon fight takes three tickets.
dragon-fight:arenacovers the four chunks around (0, 0) before the scan or a saved respawn and is released once they are in.dragon-fight:deathcovers those four and the next gateway's chunks before a dead dragon's consequences, and the fight does nothing else while it waits.dragon-fight:pillaris taken when a pillar's beam starts. The reset at the pillar's fortieth tick waits for it, and the respawn clock waits too. - The respawn search runs one synchronous body over loaded chunks.
attempttakes a ticket in the player's name for each chunk it lacks, and keeps every chunk an earlier attempt asked for in that ticket until the search answers orreleasedrops it, so a chunk the search already read cannot unload between attempts. It starts a missing world or world spawn outside the tick, and says it is waiting. It reads the world spawn only once the player's own point is settled.RespawnRequests.admittries each request at every input step, after the chunk admissions. A waiting request holds back the later requests of the same player and no one else's.findloads whatattemptlacks and waits, forDimensionTravel.leaveEndonly, andPlayerSession.getRespawnPointkeeps it for that caller. World.breakBlock,setBlockRuntimeIdandsetBlockByNameare synchronous and loaded-only.World.loadedBlockreads aBlockfrom a loaded chunk.IN_TICK_CHUNK_LOADSwent from four entries to two, and E2 owns both: portal travel's arrival chunks, and the respawn search portal travel out of the End awaits. Thestep.defercount went from 20 calls in 12 handler modules to 15 in 10. The respawn action, theRespawnhandler and the three breaks no longer defer.- What a client sees changes only where a chunk was not loaded. The dragon's death consequences, a pillar reset, and a death screen or respawn whose chunks were missing now arrive at the first tick after the chunk does, where they used to arrive in the tick that loaded it. A break or a
/setblockin a chunk that is not loaded now does nothing. Replay digests did not change. - Proxy tests, which assert counts and order.
ChunkTickets.test.tschecks one load per missing chunk however often it is asked, and a hold until the last release.WorldPlayerChunkCounts.test.tschecks that a ticketed chunk survives an unload pass.DragonFight.test.tshas four. Three waiting ticks start one load, and the death runs in the tick whose input step admitted the chunk, with no admission in the middle of a tick. The four chunks of gateway 0 give the same per-tick fight state in either arrival order. A pillar's clock holds at 39 until its chunks arrive.RespawnSearch.test.tsandRespawnRequests.test.tscheck one load per missing chunk, one build per missing world, and the per-player order. - Left for E4: the stall guard and
TickStalledErrorin the ticker, andTickContext'sAsyncLocalStorage.RespawnRequests.admit,DragonFight.tickand the explosions it awaits are still asynchronous, and so is the change of world inside a respawn's answer, which E2's travel as messages removes.BlockPhysicsstill awaits the now-synchronoussetBlockByName.
E4 split
E4 runs as six parts, in this order. Each removes the awaits listed for it and leaves the suite green, and only the last one makes the tick synchronous. The worklist is every await reachable from the ticker's simulation phases. At the start, PlayerSessionAwaits.test.ts reports 28 awaits in 51 World methods reachable from update and 35 in 40 PlayerSession methods reachable from updatePlayer; it follows calls on this only, so the parts below also list the awaits it does not see. step.defer is called 12 times in 7 handler modules.
| Part | Work | Awaits it removes |
|---|---|---|
| E4a travel and messages | The input step's travel and message shares stop waiting. changeWorld, startCredits and the detour queue their packets and hand the record save to work outside the tick. A trip out of the End asks the respawn search with attempt and waits at the input step, and a trip's arrival chunks hold a chunk ticket until it lands | DimensionTravel.admit, admitMoves, transfer, touch (entityPortalEnter), leaveEnd; changeWorld's save and sends; playerChangeDimension; the credits send; WorldMessages.admit; RespawnRequests.admit; completeDimensionChange's overworldSpawnOf. Removes RespawnSearch.find and the last IN_TICK_CHUNK_LOADS entry |
| E4b the world's phases | Block physics, the dragon fight and its explosions, the portal touches, the entity updates and the data-driven entities run without waiting | World.tickWorld and what it reaches: runBlockUpdates, touchPortals, DragonFight.tick, StoreActor.update, LiveEntities.tick, BlockPhysics |
| E4c replication | The flush phases queue their packets and do not wait for the transport, and so does each player's part of the tick | World.replicateTick and the NetworkWorldReplicator flushes, PlayerSession.updatePlayer and what it reaches, flushOutboundPackets |
| E4d the input step | The input queue applies each input in one call. A handler's outputs are handed off, and each step.defer whose reason is gone runs in the handler | InputQueue.admit, HandlerContexts.run, the chunk admissions, the player removal |
| E4e the tick event and functions | The tick event calls its listeners without waiting. Tick functions run as an input of the world they run in, and a command in one that acts in another world is a message to that world | emitTick, CommandManager.tickFunctions |
| E4f the synchronous tick | World.update and the ticker's simulation phases are synchronous. TickContext keeps a flag the ticker sets, outsideTick clears it, and the stall guard goes. A gating test runs a tick with the wall clock stubbed to throw and checks that it finishes in one call with nothing pending | The awaits in ServerTicker.runTick |
E4
E4 landed in the six parts of its split, each its own commit or two, with one change to the split: PlayerSession.updatePlayer and what it reaches went with E4b, because the player's actor runs it inside the entity updates, and E4c kept the replication phases.
- E4a.
DimensionTravel.admit,admitMoves,transferandtouch,RespawnRequests.admitandWorldMessages.admitrun in one call.changeWorld,startCredits, the detour and the respawn answers queue their packets and return a promise that only says when the transport framed them, and the record's save is handed off.entityPortalEnterandplayerChangeDimensionare posted and read as guards (ADR 0013), so a listener cancels before it returns. A trip out of the End asks the respawn search withattemptthroughPlayerSession.attemptRespawnPointand stays filed until the search has its chunks; anything else waits for the Overworld spawn, searched outside the tick.RespawnSearch.findand the lastIN_TICK_CHUNK_LOADSentry are gone. A trip's arrival chunks hold the chunk tickettravel:<n>from its preparation until it lands or is dropped.completeDimensionChangereads the Overworld spawn the level knows (knownOverworldSpawnOf), and searches outside the tick only for a level that has none. - E4b. Block physics, the dragon fight and its explosions (
explodeis synchronous), the boss bar, the portal touches,StoreActor.update, the status effects, the data-driven entities and the player's own part of the tick run to their end.World.dropFromBlockwrites and returns. A first sight'sspawnTobuilds its batch before it returns, so the tracker still marks an entity only once its spawn is queued. - E4c. The replicator's flushes,
EntityReplication.flushandflushDepartures, the tracking pass, the clock and the game rules returnvoidand queue their packets;WorldChangeSink's tick phases arevoid.allSentmoved tonetwork/allSent.ts.sendContainerDataqueues every changed counter at once: it awaited each in turn, so its later packets left after the tick and would have counted toward the next one. - E4d.
InputQueue.admit, the dispatcher's input handlers (HandlerContexts.runInput), the chunk admissions and the player removal run in one call, andInputStep.outputis let go instead of waited for.InputStep.deferand the input parts that ordered deferred work are gone, as areEntityStore.unitAsync,World.runInputandapplyInput. A block hit and the crack animation read loaded chunks. Commands run in the handler throughCommandManager.runCommand, the chat line calls its listeners in the handler, a kick queues the goodbye and takes the player off as it is called, and the player's leave queues its sends and hands off its save.PlayerPersistenceorders the writes of one key and makes a read wait for the pending write, so a player who leaves and joins again reads the record they left with. The join burst runs in its handler once the world's spawn is known, and otherwise is a message to that world after a search outside the tick. - E4e. Tick functions run at the end of the input step, before the worlds update, as one unit of the default world (
World.runInputNow). Java'stickChildrenruns its functions before its levels; the ticker's comment that said "after the worlds, as Java runs it" was wrong. The tick event is posted, andEventEmitter.postreports an asynchronous listener's rejection to a handler. - E4f.
World.update,Level.update,ServerTicker.advanceand the loop's tick are synchronous.TickContextis a plain flag the ticker sets inrunInTickandoutsideTickclears; the wait trackers, which follow read-lane handlers and chunk deliveries across their awaits between ticks, moved toworld/TickWaits.tsand keep theirAsyncLocalStorage. Nothing a tick runs waits for a chunk, so the stall guard,TickStalledError,admitNow,admitParked,IN_TICK_CHUNK_LOADSandadmittedInTickare gone. The drain of chunk preparations, which reads a wall-clock budget, moved after the simulation phases.Servertakes atickClockfor the loop's schedule and timings. - The slice 9 target gates in
packages/client/src/SynchronousTick.test.ts: a real server and a loopback client, ticking disabled, 20 ticks with a chat input, falling sand and flowing water,performance.nowandDate.nowthrowing when read inside a tick. Each tick returnsundefinedfromServer.advanceTickwith the change log sealed for that tick, and the world's digest under that tick's header is the same after the event loop drained. Anawaitat the top ofWorld.updatefails it on the sealed tick, and aDate.now()inWorld.tickWorldfails it on the reads. - Counts.
PlayerSessionAwaits.test.tsreported 28 awaits in 51Worldmethods reachable fromupdateand 35 in 40PlayerSessionmethods reachable fromupdatePlayerat the start, and 6 in 51 and 17 in 41 at the end;PlayerSession.tsholds 66, down from 128. It follows every call onthis, awaited or not, so what it still counts is work the tick starts and does not wait for: the unload pass and its saves (unloadChunks,unloadChunk,enqueueSave) inWorld, and chunk delivery (flush,flushQueuedChunks,announceSpawnIfReady,answerRespawnIfReady) inPlayerSession. The gating test is what shows the tick awaits none of it.step.defer: 12 calls in 7 handler modules at the start, 0 at the end. - What arrives later or in another place than before:
- A trip out of the End whose respawn chunks are not loaded lands at the input step after they are; the step used to wait for them inline.
- A join into a world that has not found its spawn sends its burst at the input step after the search, and a dimension change on a level with no spawn sends its
SetSpawnPositionafter the search. Neither happens on a level a player has joined. - Tick functions run before the worlds update in the same tick, where they ran after the seal. Their writes are in that tick's change log and go out with its replication.
- A plugin's asynchronous
ticklistener, and the part of a chat listener after its first wait, run after the tick. A cancel ofentityPortalEnterorplayerChangeDimensionafter an await is too late. - A command's
CommandOutputis queued once the command's synchronous part has run, so feedback a command sends after a wait, such as/opor/ban, now follows it. - A trip's arrival chunks stay loaded until it lands, which closes E2's risk note.
- Replay digests did not move, and nothing was re-baselined:
SessionReplay.test.tspasses its three tests with the baselines as they were. - Left for later:
- A command in a tick function that acts in another world writes it directly, in the same input step, when no world is in the middle of its update, but as no unit of that world's change log and not through
WorldMessages. A host verb answers synchronously (setblockreports whether the block changed), and Brigadier's dispatch cannot wait for a message's answer. Making it a message needs a verb that can file its write and answer from the target world's plain data. - The wait trackers still use
AsyncLocalStorage, because the work they follow awaits between ticks; nothing inside the tick reads them. - Chunk preparation and the chunk scheduler's slice still read the wall clock, outside the simulation phases; ADR 0009 allows the clock to decide only when a chunk is ready.
- A command in a tick function that acts in another world writes it directly, in the same input step, when no world is in the middle of its update, but as no unit of that world's change log and not through
- Checks at E4's head, on program head
b18cb2a1cb:pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTaginpackages/leveldb/src/db/VersionEdit.ts. The Prismarine, parity, client, commands, block-behaviors and engine-systems suites withJSP_CONTENT_VANILLA=1passed 556 of 558 files at load average 14 to 15;SessionReplay.test.tsandHttpSignaling.loopback.test.tsfailed under the suite, and both pass alone after the tick clock fix. Client e2edimensionsalone, thenspawn,rejoin,chat,movement,block-placement,mining-drops,lever-lamp,attack-entity,death-respawn,spawnpoint-respawn,ender-dragon,teleportandvillager-tradepass. The NetherNet tests needed the native library, copied from a worktree with the same native sources because the shared CMake cache belonged to another checkout.
Phase F: cleanup
| Package | Slice | Depends on | Work |
|---|---|---|---|
| F1 | 10 | E4 | One lifecycle event bus |
| F2 | 11 | F1 | Explicit exports for Prismarine, split by directory |
F1 split
Written on 2026-11-12 at program head 8de27db6bc.
The server has two emitters today. No world, session or player has an emitter of its own.
- Prismarine's
EventEmitterinevents/EventEmitter.ts.Serverextends it, soserver.on,once,removeListener,removeAllListeners,hasListeners,emitandpostare its methods. It also holdsevtThirdParty, a second untyped bus for events plugins define, which nothing raises or reads. Itsemitcalls every listener at once and waits for them withPromise.all. Itspostcalls them and returns. A listener that throws stops the listeners after it and throws into the code that raised the event. - The kernel's
KernelEventBusinpackages/kernel/src/events.ts, which each module reaches through its context as a bus scoped to the module. It is untyped in Prismarine, which builds a plainKernel, and nothing in the server subscribes to it or raises an event on it.
The kernel bus survives, typed by a ServerEvents map that lists the 18 events EventTypes lists now, under the same names and with the same event classes. The kernel bus gains what the server's events need: post, which calls each listener in subscription order and returns, once, off and hasListeners. A listener that throws or rejects is reported to the kernel's logger, and the listeners after it still run, on post and on emit.
Each event keeps its emit site and its timing. A before-hook is posted inside its unit and its cancel is read when post returns: chat, entityDamage, entityPortalEnter, playerChangeDimension, playerMove, playerSetGamemode, playerToggleFlight and playerToggleSprint. The after-events are posted: tick, entityDeath, playerSpawn and playerToggleOperator. The three RakNet events stay awaited with emit, because they run outside the tick and raknetConnect reads a cancel made after a wait. playerConnect, playerDisconnect and playerDespawn keep their names in the map, though nothing raises them (#2972).
The order of the work:
- The kernel bus gains
post,once,off,hasListenersand the reported failures, with the proxy tests. Serverbuilds aKernel<ServerEvents>and stops extendingEventEmitter.on,once,removeListener,removeAllListenersandhasListenersstay onServerand subscribe on the kernel bus, andpostandemitforward to it until the emit sites move.chat:ChatManagerposts on its module's bus and then delivers to its recipients, one pass, instead of one listener per recipient. The join message goes throughChatManager.send.tick:emitTickleavesTickerServer, and the ticker posts on the bus it is given.entityDamageandentityDeath, then the player events, then the portal events, then the RakNet events, a group per commit, each raised on the bus.Server.post,Server.emit,EventEmitter.tsandevtThirdPartyare deleted.
What a plugin's listener sees differently, and what a plugin author changes:
- A listener that cancels
chatstops every player, including those who joined before the listener subscribed. Before, each player was its own listener, and a cancel stopped only the players who joined after the canceling listener. A cancel made after a wait is still too late. - Every listener of an event that the server waits for with
emit(raknetConnect,raknetDisconnect,raknetEncapsulatedPacket) waits for the listener before it. Before, they all started at once. - A listener that throws or rejects no longer stops the listeners after it or the code that raised the event. The server logs the error. A before-hook that throws neither cancels nor changes the action.
- Subscribing the same function twice to one event still calls it once.
removeAllListeners('chat')no longer takes chat away from the players, because they are no longer listeners.server.emit,server.postandserver.evtThirdPartyare gone, andServeris no longer anEventEmitter. A plugin that raised a server event, or usedevtThirdParty, has to stop.server.on,once,removeListener,removeAllListenersandhasListenerstake the same arguments as before.
F1
F1 landed in the order of its split, with one change: the RakNet events moved in the commit that put Server on the kernel bus, because ServerNetworking hands the transports and the dispatcher the bus itself and nothing else raised them.
- The kernel bus.
postcalls each listener in subscription order and returns how many it called;emitwaits for each one before it calls the next. Both report a listener that throws or rejects toonListenerError, which aKernelsends to its logger, and go on to the next listener.once,off,removeAllandhasListenersare new, a handle subscribes the same function to an event once, and a listener may return any value. - The emitters. Before: Prismarine's
EventEmitter, whichServerextended, withevtThirdPartybeside it, and the kernel's bus, which nothing used. After: the kernel's bus, typed byServerEventsinevents/ServerEvents.ts.Serverbuilds aKernel<ServerEvents>first in its constructor, and itson,once,removeListener,removeAllListenersandhasListenerssubscribe on it.EventEmitter.ts,PluginEventEmitter,EventTypes,Server.postandServer.emitare gone. The docs facts read the events table fromServerEvents, and it came out unchanged. - Each event and where it is raised now.
chat:ChatManager.sendposts it on the chat module's bus and then delivers to the joined players in one pass, so a player is no longer a listener; the join message goes throughChatManager.sendinstead of anemitof its own.tick: the ticker posts it onTickerServer.events, which replacedemitTick, at the pointemitTickwas called.entityDamage(a before-hook inside the blow) andentityDeath(after the commit, inside the blow, before the advancement triggers):live.ts.playerSetGamemode,playerToggleFlight,playerToggleSprint:PlayerSession.playerToggleOperator:PermissionManager.playerMove,playerSpawn: the handler services'events, which is the bus.entityPortalEnter,playerChangeDimension:DimensionTravel.raknetConnect,raknetEncapsulatedPacket:RakNetTransport, andraknetDisconnect:NetworkDispatcher, all three awaited withemit. None of them moved relative to the simulation: the posted events are posted where they were posted, and a before-hook's cancel is read whenpostreturns, as before. - When a listener runs, compared with before: a chat player is reached after every
chatlistener, where before a player who joined before a listener was reached before it; the RakNet listeners run one after another; and a listener after one that throws now runs. The split lists what a plugin's listener sees differently and what a plugin author changes. - Proxy tests, all by order and count.
events.test.tsin the kernel:postcalls 2 listeners in subscription order and returns before the asynchronous one's wait; a throwing and a rejecting listener are reported, 2 reports, and the third listener still runs, onpostand onemit; a cancel is seen by the listener after the canceler;oncecalls its listener for 1 event of 2; the same function subscribed twice is called once andpostreturns 1.kernel.test.ts: a failing listener writes 1 line to the kernel's logger and the next listener runs.ServerEvents.test.tson a realServer:server.onand the kernel bus are one list, 3 listeners called in subscription order by the chat module's post, and after a throwing listenerpoststill calls 3 and then, with theoncegone, 2.ChatManager.test.ts: 2 listeners then 2 players in one pass; a cancel stops both players whenever they joined, and the listener after it sees the cancel; a cancel after a wait delivers 1 line; a throwing listener gives 1 report and 1 line.ticker.test.ts: in one tick, a throwing and a rejectingticklistener give 2 reports, the third listener runs, and the network poll and the tick count still advance. - Replay digests did not move:
SessionReplay.test.tspasses with the baselines as they were. Client e2edimensionsalone, thenspawn,chat,rejoin,attack-entity,death-respawn,block-placementandender-dragon, pass. - Left for later:
- Chat is posted and then delivered, which is the order of a guard and a commit, but it is not a pipeline of ADR 0013: no guard list, no replaceable message, and no rejection record. The script host's chat before-event will need one.
- The console and the remote console still show chat as
chatlisteners, so a plugin that subscribed after them and cancels does not hide a line from the console. - The three RakNet events are still awaited one listener at a time. They run outside the tick, and
raknetConnectreads a cancel made after a wait, so they stay as they are until the transport has a guard of its own.
- Checks at F1's head, on program head
8de27db6bc:pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTaginpackages/leveldb/src/db/VersionEdit.ts. The Prismarine, kernel, parity, client, commands and server suites withJSP_CONTENT_VANILLA=1pass 541 files at load average 24 to 30.commandStartup.test.tsfailed twice while another checkout's tests held its UDP port (#3000) and passed in the next full run. The built@jsprismarine/prismarineand@jsprismarine/kernel, loaded under thejsprismarine:distcondition, giveServerits subscribe methods and nopostoremit, and the bus reports a throwing listener and calls the next one.
F2 split
Written on 2026-11-12 at program head 9ecbe88282.
Prismarine exports "." and "./*", so every file under src/ is importable, and the barrel baseline holds 20 of its files, with 20 noBarrelFile and 77 noReExportAll findings. The root index.ts re-exports 13 directory barrels and Managers.ts, and events/Events.ts, network/index.ts and blockentity/index.ts gather symbols by importing and exporting them again, which Biome does not see. Outside the package, the server package imports 6 modules, the client's tests 10 and the parity suite about 70, and Prismarine's own benchmarks import it by name to pick up the built files under jsprismarine:dist.
The published package keeps these entry points, each a module that defines what it exports:
| Entry point | Why it is public |
|---|---|
., which becomes Server.ts | The class an embedder builds. Server.ts gains a named Server export beside its default one, so import { Server } from '@jsprismarine/prismarine' still works |
./version | @jsprismarine/server prints the version with serverVersion |
./config/Config | An embedder builds a Config, as the extending guide shows |
./console/RemoteConsole, ./console/RemoteLogTransport | @jsprismarine/server attaches its console with them |
./content/settings, ./content/packStack | @jsprismarine/server reads the content settings and resolves the pack stack before it builds a Server |
./events/ServerEvents, ./events/Event and each event class's module under events/ | Listeners receive these classes, and ServerEvents types server.on |
./chat/Chat | ChatEvent.getChat() returns it |
./network/PlayerSession | The player events and the Server getters hand out sessions |
./network/InboundPacketTrace, ./network/OutboundPacketTrace, ./network/ReplayManifest | The replay API the package README documents |
./modules/tokens | The kernel tokens are how a program reaches the server's services, which Server's own comments and the README name |
./world/Level, ./world/World | The README documents Level.captureReplayInitialState and World.replayStateDigest, and events return worlds |
./world/GeneratorFactories | A program registers a generator kind on it |
./world/generators/datadriven/DataDrivenGenerator, ./world/generators/datadriven/SharedTerrainTileStore | The README's terrain cache and shared tile examples import them |
Everything else becomes internal to the published package. The workspace keeps explicit entries, in exports but not in publishConfig.exports, for the internal modules its own consumers import today: the parity suite, the client's tests and Prismarine's benchmarks. Test helpers under testing/ get a source target only, since the build does not emit them. pnpm writes publishConfig.exports over exports when it packs, so none of these reach npm. The two null entries go, because nothing is left for them to block.
The barrels go, and each importer imports from the defining module: the root index.ts, Managers.ts, the directory index.ts files under block, blockentity, command, command/function, command/host, config, console, content, entity, inventory, item, network, utils, world, world/generators/datadriven and world/generators/factories, events/Events.ts, and the re-exports in world/Dimension.ts and world/generators/datadriven/javaWorldgenData.ts. world/generators/factories/index.ts keeps builtInGeneratorFactories, which it defines, in a module of its own. network/Handlers.ts stays: it is the handler registry's list, and nothing outside the package reads it once network/index.ts is gone.
The order, so each commit builds:
"./*"is replaced by an explicit entry for every specifier the workspace imports today. The root still points atindex.ts, and no import changes.- One barrel per commit, deepest first: the generator factories, the data-driven generator,
Dimension.tsandjavaWorldgenData.ts, thenworld,entity,block,blockentity,item,inventory,content,config,console,command,network,utils,Managers.tsandevents/Events.ts. Each drops its line from the rootindex.ts, moves its importers to the defining modules and lowers the baseline withnode scripts/check-barrels.mts --update. - The root:
"."points atServer.ts,index.tsis deleted,publishConfig.exportslists the public entries, and the API reference reads Prismarine's entry points frompublishConfig.exports. - A test fails when
scripts/barrel-baseline.jsonhas an entry underpackages/prismarineor when the exports map has a pattern, which makes the slice 11 target gate.
What a consumer changes:
- Import each symbol from the module that defines it, through one of the entry points above.
import { Config, Server } from '@jsprismarine/prismarine'becomesimport { Server } from '@jsprismarine/prismarine'andimport { Config } from '@jsprismarine/prismarine/config/Config'. - The root no longer exports
Events,Handlers,BlockEntities, the managers or any other namespace. Event classes come from their own modules, such as@jsprismarine/prismarine/events/chat/ChatEvent. - A path that is not an entry point above stops resolving from the published package. A program that needs one should open an issue, so the module can be made public on purpose.
F2
F2 landed with the entry points of its split and one change to the order: the root went first, not last. Moving every importer of the root index.ts to the defining modules in one commit left the directory barrels with no importer but the root, so each later commit deleted one barrel and touched only that barrel's own importers. A codemod over the TypeScript syntax tree did the moves: for each named import of a barrel it followed the re-exports to the module that declares the symbol and wrote one import per module. Nothing outside the package changed behavior; only import paths moved.
- The barrels. Before: 20 Prismarine files in
scripts/barrel-baseline.json, with 20noBarrelFileand 77noReExportAllfindings. After: none. The rootindex.ts,Managers.ts, the 16 directoryindex.tsfiles andevents/Events.tsare deleted,world/Dimension.tsandjavaWorldgenData.tsno longer re-export, andbuiltInGeneratorFactoriesmoved toworld/generators/factories/builtIn.ts. Most directory barrels had no importer except the root;utils,command/function,networkand the world barrels had one to three each. - The root.
"."isServer.ts, which gained a namedServerexport, soimport { Server } from '@jsprismarine/prismarine'is unchanged and./Serveris no longer an entry.packages/serverloadsConfig,RemoteConsoleandRemoteLogTransportfrom their modules beside the root, and its declaration build maps the root toServer.d.ts. The handler check innoServerAccess.test.tscounts an import of the package root as an import ofServer. - The exports maps. The published map has the 39 entry points of the split. The workspace map has those and 77 internal modules that the parity suite, the client's tests and the benchmarks import, 4 of them test helpers with a source target only. pnpm writes the published map over the workspace one when it packs.
network/indexbecamenetwork/contentPayloads, the module the parity suite actually reads. - The API reference.
packages/prismarine/typedoc.config.mjsreads the entry points frompublishConfig.exports, so Typedoc documents the published modules and no others. The extending guide lists them through theprismarine.entryPoints.listdocs fact, read from the same map. - The gate.
noBarrel.test.tsfails when the baseline has a Prismarine entry, when either exports map has a pattern, when a workspace entry names a missing source module, or when a published entry is not a built workspace entry. Withpnpm run lint:barrels, which fails on any barrel finding the baseline does not allow, it keeps slice 11's target. - The consumer check. The built
@jsprismarine/prismarineand the 43 workspace packages it depends on, packed withpnpm packand installed with npm in an empty ESM project, typecheck a file that imports all 39 entry points and a file that embeds the server as the extending guide does, under TypeScript 6 with--moduleResolution NodeNextand withBundler,skipLibCheckoff. An import ofworld/chunk/Chunkfails with TS2307 under both. At run timeworld/chunk/Chunk,world/positionKeys,indexandServerthrowERR_PACKAGE_PATH_NOT_EXPORTED, and the root,config/Configandversionload. - What a consumer changes is in the split, and the Changeset
prismarine-explicit-exports.md(minorfor Prismarine,patchfor the server) lists where each former root export lives. - Left for later:
- Re-exports Biome does not count. Five type-only re-exports (
BanManager.ts,World.ts,WorldManager.tsandDragonFight.tsfrom the config types andWorldPersistence.ts) and nine modules that import a symbol and export it again, such asinventory/WindowIds.ts,entity/Difficulty.tsandcontent/world/adapter.ts. None is a barrel, and each is one or a few symbols. network/Handlers.tsimports every handler and exports them again. It is the handler registry's list, nothing outside the package reads it, and replacing it with a list module of its own is a change to the registry.- The 77 internal workspace entries. A parity test that reaches an internal module keeps an entry for it; moving those tests onto public entry points, or making a module public on purpose, would shrink the list.
- Re-exports Biome does not count. Five type-only re-exports (
- Checks at F2's head:
pnpm run build,pnpm run typecheck,pnpm run lint:customandpnpm run lintpass; lint's one warning is the unusedTaginpackages/leveldb/src/db/VersionEdit.ts. WithJSP_CONTENT_VANILLA=1, Prismarine's suite passes 446 files with 2 skipped, and the client, server, parity and commands suites pass 94 files, at load average 16 to 17.SessionReplay.test.tspasses with its baselines as they were, so the replay digests did not move.pnpm exec vitest run scripts toolspasses 14 files.node scripts/check-workspace-conditions.mjsresolves every workspace export from source and fromdist. Client e2edimensionsalone, thenspawn,chat,block-placementanddeath-respawn, pass, andspawnpasses against the built packages withpnpm run e2e:client:dist.pnpm --filter @jsprismarine/docs run docs:stageruns clean, andtypedoc --emit nonereports 0 errors.
The gate for each slice
- Typecheck, lint and tests pass.
- The parity families the slice touches pass.
- Replay digests are unchanged, or are re-baselined in a separate commit that states the intended change. Until slice 9 this compares one replay with another under the guarantees listed for the slices already landed.
- A client e2e scenario covers any client-visible change.
- The proxy tests that gate at this slice pass.
- A Changeset for each affected package follows the versioning policy.
- The docs the slice invalidates are updated, such as entities, engine systems and the status in the server re-architecture plan.
Performance targets
Each target is a count or a structure asserted in a test. None is a duration.
| Target | Gates from slice |
|---|---|
| Every handler declares a lane, checked against the handler registry | 1 |
No wall-clock read inside the world systems phase, with performance.now stubbed to throw | 1 |
| A replay applies each recorded input at the tick the capture stamped | 1 |
| An index query examines at most the candidates in the cells it touches, on a fixed scenario | 2 |
| Applying a span and then its inverse leaves the replay digest unchanged, for each field and membership entry kind; undoing a span that holds a chunk entry is refused | 2 |
| An idle world appends no entries, and N walking mobs append at most N position entries in a tick | 2 |
| No mirror class and no health comparison | 4 |
No Entity, GenericEntity or Human class, and no array copy through all() or getEntities() | 5 |
No Server import in a handler | 6 |
| Encode calls for one broadcast occurrence equal the number of codec and compression groups | 7 |
| Replication visits entities in proportion to log entries, not to the number of entities | 7 |
| A snapshot plus the log reproduces the replay digest | 7 |
| A tick makes no wall-clock read and completes without yielding | 9 |
| No barrel in Prismarine | 11 |
Three counts are reported and do not gate: awaits reachable from World.update, change log entries and bytes per tick, and replica entries and bytes. They go through reportProgress.
The slice 7 targets gate from D4f. Encode calls for one broadcast occurrence: NetworkWorldReplicatorOccurrences.test.ts, for every broadcast kind, and NetworkWorldReplicatorEntities.test.ts for a first sight. Visits in proportion to log entries: BlockReplica.test.ts for blocks and block entities and NetworkWorldReplicatorEntities.test.ts for entities. A snapshot plus the log: SessionReplay.test.ts over the scripted session, and WorldChangeTick.test.ts for the scheduled ticks that session does not queue.
Scope notes
Handlers take a narrow context and not Server. Other files that take Server stay out of the program, unless a codemod or delegated agents can convert them mechanically with no real risk. Then they go in.
Deferred
- The Script API host. Its before-hooks are guards under ADR 0013. A wall-clock watchdog for long scripts would conflict with ADR 0009, and the host design has to answer that and pick the point in a tick where
system.runcallbacks run. - The low-level plugin form ("mods"). Keep the store's plain-data edge and the explicit exports in mind, and build nothing for it now.
- Moving replication, a world or pathfinding to another thread. ADR 0011 lists the candidates. A world on its own thread also moves the read lane onto the replica.
- Retention of the change log, crash recovery from it, and rollback tooling.
Checks to run, not assumed
- Advancement triggers fire inline from the legacy damage and death methods. Reading them from the log changes when they fire, so slices 4 and 7 need a parity check on advancement timing.
- Rejecting an interaction with an unloaded chunk should match Java. The parity suite has to confirm it in slice 6. Confirmed by D2b's parity family
gameplay.block-targets. - Counts in place of the 20 ms and 25 ms budgets mean an overloaded host ticks slower and never simulates differently. Slice 1 needs a scenario that shows the tick counter lagging with identical digests.
SubChunkRequestcan load an evicted chunk that was already announced (PlayerSession.ts:578). The read lane allows the wait, and slice 1 has to confirm the load reaches the world as an input.World.replayStateDigestcallsdataEntities().persist()before it hashes, so taking a digest has a side effect. Slice 2 has to remove it or show it changes nothing.- The replay test asserts only that one tick has a digest. Slice 1 should make it compare two runs tick by tick before anything depends on it.
- How the path finder treats a read from an unloaded chunk was not traced.
- Whether a saved player's inventory record is a deep copy. Confirmed by B4 and again by C4e, which also made the record copy the spawn point; see the C4e notes.
- Tick functions run from the ticker after the worlds, and a function can act in another dimension. Slice 9 treats a function as an input to the world it runs in and a cross-world command as a message. Confirm against Bedrock when a function runs relative to the tick.
- The handler lanes were assigned from each handler's direct calls and not from a full trace. Slice 1 reviews the 15 readers and the
Disconnecthandler one by one. Done by D2a to D2g, which each traced their handlers through what the handlers call;Disconnectmoved to the connection lane in D2c.