Debugging the running game
Verified against Minecraft 26.3 · Part X · a villager’s brain drawn over its head: one subscription mechanism, sixteen instances, all of them in the jar you downloaded and fifteen of them unreachable without a JVM flag.
Every one of these sixteen debug subscriptions is compiled into the shipped client and the shipped dedicated server. Every subscription is in the registry, every packet is in the protocol, every producer call site is there. Nothing is stripped. The client simply never asks — fifteen of the sixteen are behind a JVM system property read at startup, and the server has to agree besides. The idle cost is small but it is not nothing: the producers check for a subscriber before they work, and the server sweeps every online player’s permissions once a tick whether or not anyone has asked for anything.
That is the pattern the page is about: a registry of subscription kinds, a per-level engine that sleeps until somebody asks, a poll-and-diff sender, and the renderers — thirteen of the twenty-five — that turn the results into floating text and boxes. It is only half a client system — the machinery ships on the dedicated server — but the client is the only thing that ever asks and the only thing that draws, and the trace ends in a renderer.
The cast
| class | what it decides | thread |
|---|---|---|
DebugSubscription | what one kind of debug value is: a stream codec, and an expiry | both sides |
DebugSubscriptions | the sixteen kinds | both sides |
DebugValueSource | which objects can answer which subscriptions | Server thread |
ServerDebugSubscribers | who is subscribed, rebuilt every tick, and the permission rule | Server thread |
LevelDebugSynchronizers | per level, a source synchronizer for each of the fifteen subscriptions with a value codec, the event-driven point-of-interest and village-section synchronizers, and the sleep flag | Server thread |
TrackingDebugSynchronizer | the engine: registration, the diff, and the tracking filter | Server thread |
ClientDebugSubscriber | what to ask for, and the maps holding what came back | Render thread |
DebugRenderer | the renderer list, rebuilt when the enabled entries change | Render thread |
The idea
A DebugSubscription is a registry object — held in
BuiltInRegistries.DEBUG_SUBSCRIPTION under Registries.DEBUG_SUBSCRIPTION
— carrying exactly two things: a nullable stream codec for its value type,
and an expiry in ticks, which is DebugSubscription.DOES_NOT_EXPIRE for
most. Its payload wrappers are the records DebugSubscription.Update — a
subscription plus an optional value, so absence is expressible — and
DebugSubscription.Event, a subscription plus a value. Both are serialised
by dispatching on the registry id onto the subscription’s own codec.
That is the whole abstraction, and it replaces what used to be a fixed packet type per kind of information.
DebugValueSource is the supply side, implemented by Entity, Mob, Bee,
Breeze, BlockEntity, BeehiveBlockEntity and LevelChunk;
DebugValueSource.registerDebugValues hands back one
DebugValueSource.ValueGetter per subscription the object can answer. On the
client, ClientDebugSubscriber keeps what came back, keyed by chunk
position, block position or entity UUID, plus a list of expiring events, and
ClientDebugSubscriber.createDebugValueAccess hands renderers a read-only
DebugValueAccess view. DebugRenderer is a plain list of DebugRenderer.SimpleDebugRenderers,
rebuilt by DebugRenderer.refreshRendererList. Neither the synchronizer that
produced the value nor the renderer that consumes it draws anything: the
renderer appends, which is the next section.
A renderer does not draw a gizmo; it appends one
The renderers at the end of this trace emit through Gizmos, a debug-drawing
API in the game-engine sense: the immediate-mode draw me a box in the world
for one frame facility most engines have and Minecraft did not. Gizmos is a
static façade over a thread-local GizmoCollector, and Gizmos.addGizmo
throws when no collector is installed on the calling thread — so where you
may draw is decided by where you are running, not by what you are allowed to
see. The shapes are small records — CuboidGizmo, LineGizmo, ArrowGizmo,
CircleGizmo, RectGizmo, PointGizmo, TextGizmo — a GizmoStyle is a
stroke and a fill, and the GizmoProperties handle a call returns can pin the
shape on top, keep it for a number of milliseconds, or fade it.
Five places in the game install a collector; four of them keep what they are given, and those four are the table. The fifth is GameTestServer, which installs GizmoCollector.NOOP, so code that draws does not throw in a headless test run. The fourth row is the one worth knowing, though nothing the game ships draws into it.
| collector | installed by | what it collects |
|---|---|---|
| per tick | Minecraft.collectPerTickGizmos | anything the client tick draws, cleared each tick |
| per frame, during extraction | LevelExtractor.collectPerFrameMainThreadGizmos | the debug renderers of this page, inside the extract pass |
| per frame, during rendering | LevelRenderer.collectPerFrameRenderThreadGizmos | what the world renderer itself wants outlined |
| the integrated server | IntegratedServer, around its whole packet-and-tick step | server tick code drawing into a singleplayer world, published for the client to drain |
So server-side game logic can draw, and only in singleplayer: a dedicated server installs no collector at all, so the identical call there would throw. The two renderers below that want server state are singleplayer-only as well: they reach for the singleplayer server directly rather than asking over the wire, and no subscription carries what they draw.
The sixteen instances
Fourteen rows, sixteen subscriptions: two of the rows carry a matched pair.
| subscription | carries | fed by |
|---|---|---|
DebugSubscriptions.BRAINS | DebugBrainDump | Mob.registerDebugValues |
DebugSubscriptions.GOAL_SELECTORS | DebugGoalInfo | Mob.registerDebugValues |
DebugSubscriptions.ENTITY_PATHS | DebugPathInfo | Mob.registerDebugValues, from the navigator’s current Path |
DebugSubscriptions.BEES / DebugSubscriptions.BEE_HIVES | DebugBeeInfo / DebugHiveInfo | Bee and BeehiveBlockEntity |
DebugSubscriptions.BREEZES | DebugBreezeInfo | Breeze |
DebugSubscriptions.POIS | DebugPoiInfo | TrackingDebugSynchronizer.PoiSynchronizer, event-driven |
DebugSubscriptions.VILLAGE_SECTIONS | nothing but presence | TrackingDebugSynchronizer.VillageSectionSynchronizer |
DebugSubscriptions.RAIDS / DebugSubscriptions.STRUCTURES | positions / DebugStructureInfo | LevelChunk.registerDebugValues |
DebugSubscriptions.GAME_EVENT_LISTENERS | DebugGameEventListenerInfo | the listener registry |
DebugSubscriptions.GAME_EVENTS | DebugGameEventInfo | the dispatcher — an event, expiring after 60 ticks |
DebugSubscriptions.NEIGHBOR_UPDATES | a position | a listener installed on the neighbour updater — an event, 200 ticks |
DebugSubscriptions.ENTITY_BLOCK_INTERSECTIONS | DebugEntityBlockIntersection | Entity, pushed directly, 100 ticks |
DebugSubscriptions.REDSTONE_WIRE_ORIENTATIONS | an Orientation | the experimental wire evaluator, 200 ticks |
DebugSubscriptions.DEDICATED_SERVER_TICK_TIME | no value at all | see the sample path below |
Four of the sixteen declare an expiry, and “expires after n ticks” means two
different things among them. DebugSubscriptions.GAME_EVENTS and
DebugSubscriptions.NEIGHBOR_UPDATES are events, and the number is how long
the event stays on screen. DebugSubscriptions.ENTITY_BLOCK_INTERSECTIONS and
DebugSubscriptions.REDSTONE_WIRE_ORIENTATIONS are values pushed directly, and
the number is a time-to-live on a stored value. Only the client purges, and
only for those four.
One instance traced: a villager’s brain
sequenceDiagram
box transparent the client
participant CDS as ClientDebug<br/>Subscriber
participant BDR as BrainDebugRenderer
end
box transparent the server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SDS as ServerDebug<br/>Subscribers
participant LDS as LevelDebug<br/>Synchronizers
participant TDSS as TrackingDebug<br/>Synchronizer.<br/>SourceSynchronizer
end
CDS->>CDS: requestedSubscriptions — the JVM has the brain flag
CDS->>SGPL: ServerboundDebugSubscriptionRequestPacket, BRAINS
SGPL->>SGPL: handleDebug<br/>SubscriptionRequest — stored, unhonoured
Note over SGPL,TDSS: the end of the same server tick
SDS->>SDS: tick — op, or the owner of an IDE world?
Note over SGPL,TDSS: the next server tick
LDS->>LDS: tick — subscribers exist, so wake up
LDS->>TDSS: registerChunk and registerEntity for everything already tracked
TDSS->>TDSS: the Mob offers a DebugValueSource.<br/>ValueGetter
loop each server tick
TDSS->>TDSS: pollAndSendUpdates — takeBrainDump, compare with the last sent
TDSS->>CDS: ClientboundDebugEntityValuePacket — only if it differs
end
CDS->>CDS: updateEntity — stored under the villager's UUID
Note over CDS,BDR: the next client frame
BDR->>BDR: emitGizmos — reads through DebugValueAccess
BDR->>BDR: Gizmos.<br/>billboardTextOverMob — appended now, drawn later
Two sides and, from a sleeping level, one tick of lag before anything is sent: the request is stored at the head of a server tick and the subscriber set read at its end, and the synchronizers wake, and first send, in the next. The packet in the
loop reaches ClientDebugSubscriber through ClientPacketListener like any
other, which is why no listener lane is drawn.
The figure’s own names, in its order:
ClientDebugSubscriber.requestedSubscriptions is what the JVM flag filled;
ServerGamePacketListenerImpl.handleDebugSubscriptionRequest stores the set
on the ServerPlayer; LevelDebugSynchronizers calls TrackingDebugSynchronizer.SourceSynchronizer.registerChunk and TrackingDebugSynchronizer.SourceSynchronizer.registerEntity for everything already tracked; TrackingDebugSynchronizer.SourceSynchronizer.pollAndSendUpdates is the per-tick compare, and for a villager the value it asks for is a brain dump;
ClientDebugSubscriber.updateEntity files the answer under the entity’s
uuid; and BrainDebugRenderer reads it back next frame.
The engine is the synchronizers’ part of that, and it has three properties worth naming. Nothing exists until somebody asks: the level’s synchronizers start asleep, and the first non-empty subscriber set wakes them and retroactively registers every ready chunk and every tracked entity. A polled value is sent again only when it changes: each value source keeps the last value it sent and compares, and a player who starts tracking is sent that stored value once. The point-of-interest synchronizers and the pushed kinds send on each event instead. And nothing reaches a player who cannot see it: sending is filtered by subscription and by whether that player is tracking the chunk or entity. When the last subscriber goes away the value sources are cleared and the level goes back to sleep.
The three cadences: ClientDebugSubscriber.tick runs from
ClientPacketListener.tick, once per client tick, and sends only when the
wanted set differs from the last one sent.
ServerDebugSubscribers.tick runs from MinecraftServer.tickChildren
after the levels have ticked, while each LevelDebugSynchronizers.tick
runs inside its level’s tick — so every level acts on the previous tick’s
subscriber snapshot, a built-in one-tick lag. And DebugRenderer.emitGizmos
runs inside LevelExtractor.extract — the snapshot step of the frame — after entities, block entities, particles, sky and clouds,
fetching one DebugValueAccess for the whole pass.
The exceptions
Every pattern page’s real content.
Two gates, and the second is not a flag. Fifteen of the sixteen kinds are
behind SharedConstants.DEBUG_ENABLED and one of a set of constants beside it —
SharedConstants.DEBUG_BRAIN, SharedConstants.DEBUG_POI,
SharedConstants.DEBUG_BEES and their siblings, thirteen for the fifteen kinds, all read from JVM system properties at startup, so turning one on means launching the game
differently rather than pressing anything. The only subscription an F3 key can
reach is the dedicated server’s tick time, through the FPS charts. And
the server still has to agree: ServerPlayer.debugSubscriptions returns
nothing unless ServerDebugSubscribers.hasRequiredPermissions passes, which
means op on the player list, or the owner of a singleplayer world run from an
IDE. On a normal singleplayer world that means cheats must be on.
Producers check before they work. Path finding only records its open and
closed node sets when somebody wants paths; entities only collect block
intersections when somebody wants them; the neighbour updater’s debug
listener is only installed while someone is subscribed. There are two gates
of that name and the producers do not agree on which to use: path finding and
block intersections ask ServerDebugSubscribers.hasAnySubscriberFor, the live
map, while the neighbour updater asks
LevelDebugSynchronizers.hasAnySubscriberFor, which reads the level’s
snapshot from the previous tick. The change detection, by contrast, is
record equality: a
brain dump is rebuilt every tick per villager and compared with the last one
sent — so the saving is in bandwidth, not in server time.
The other twelve renderers do not use this system at all. The chunk debug
renderer reaches directly into Minecraft.getSingleplayerServer and shows
nothing in multiplayer; the entity hitbox renderer reaches for it too, but only to check the server has the entity, for its flag-gated server hitbox; the ordinary hitbox it draws itself, switched by an F3 entry rather than a flag, as entity rendering says. And a
whole family of them — chunk borders, light, collision boxes, height maps,
the section octree — are purely client-side views that need no server.
One flag combination under-delivers, and says so. The POI renderer’s
ticket-holder rows are behind an explicit SharedConstants.DEBUG_BRAIN test,
so running with the POI flag alone gives POI boxes with their own two labels
and none of the brain ones. The bee flag avoids needing the test at all by
explicitly also requesting goal selectors.
Subscriptions survive a dimension change and a death, but not a
reconnect. A dimension change keeps the same ServerPlayer, so the set is
simply never touched; a respawn builds a new one and ServerPlayer.restoreFrom
copies the requested set across. A fresh login starts empty, and the client
re-sends on its next tick because ClientDebugSubscriber was cleared at
login.
The other query, which is one transaction deep
Not everything the client asks the server for is a subscription. Press the
copy-recreate-command key — Options.keyDebugCopyRecreateCommand, an ordinary
rebindable mapping — while looking at a block or an entity and the client
builds a /setblock or /summon for it and puts it on the clipboard. Whether
the NBT in that command is yours or the server’s is decided by two things.
Holding shift takes the client’s own copy, straight out of the block entity or
the entity in front of you. Not holding it goes to the server, through
DebugQueryHandler: ServerboundBlockEntityTagQueryPacket or
ServerboundEntityTagQueryPacket with a transaction id, a callback parked
against that id, and the command assembled when the reply comes back. The
handler holds one callback and one id, so a second query cancels the first
by overwriting it, and a reply whose id does not match is dropped on the
floor. Including the NBT at all needs the gamemaster permission, and the whole
key is dead while the server has reduced debug info on.
The sample path, which shares only a subscription
The performance charts are a separate and much simpler system, and what they have in common with everything above is the subscriber side: one of the sixteen subscriptions gates the remote half of them, asked for in the same request and granted by the same permission rule. There is no shared engine, no
diff and no tracking filter. A SampleLogger takes a vector of longs; a tick
logs its parts as it goes and a final call flushes the whole vector. There are two implementations
and the difference is the whole story: LocalSampleLogger is the storage
— a SampleStorage ring buffer the charts read directly — while
RemoteSampleLogger stores nothing and broadcasts a
ClientboundDebugSamplePacket if anyone is subscribed.
So a dedicated server measures its tick with a remote logger and sends, while
IntegratedServer.getTickTimeLogger hands back the client’s own local
logger and reports logging as unconditionally enabled — singleplayer TPS
never touches the network. Client-side, DebugScreenOverlay owns four local
loggers with four different feeders: the frame time from the loop, the tick
time from either of the two paths above, the ping from PingDebugMonitor
(which sends its own ping requests, and only while the network charts are
shown), and the bandwidth from a BandwidthDebugMonitor that counts bytes on
the Netty thread and is drained by Connection.tick.
Six packets carry the subscriptions and the samples between them:
ServerboundDebugSubscriptionRequestPacket outbound, and
ClientboundDebugChunkValuePacket, ClientboundDebugBlockValuePacket,
ClientboundDebugEntityValuePacket, ClientboundDebugEventPacket and
ClientboundDebugSamplePacket inbound — for a system that used to have one
packet per subject. The two tag-query packets of the section before are not
among them, and are not part of this machinery at all.
For a 1.21-era reader. The fixed set of debug packets is gone. Instead of one packet type per kind of debug information there is one
DebugSubscriptionregistry and three generic value packets that dispatch on the registry id. The debug screen is a different system again — the F3 entry registry described in the HUD — and it is not a light touch: the F3 entries decide whether eleven of the twenty-five renderers exist at all when the list is rebuilt, and the FPS charts gate the tick-time subscription.
Where to look
DebugSubscriptions for the catalogue and DebugSubscription for how little
a subscription is. TrackingDebugSynchronizer for the engine — the tracking diff and the equality check are in that one class, and the wake-up’s back-fill is LevelDebugSynchronizers’.
LevelDebugSynchronizers.tick for the sleep flag, ClientDebugSubscriber
for both ends of the client’s half, and DebugRenderer.refreshRendererList
for which renderers exist and why.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.