Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

classwhat it decidesthread
DebugSubscriptionwhat one kind of debug value is: a stream codec, and an expiryboth sides
DebugSubscriptionsthe sixteen kindsboth sides
DebugValueSourcewhich objects can answer which subscriptionsServer thread
ServerDebugSubscriberswho is subscribed, rebuilt every tick, and the permission ruleServer thread
LevelDebugSynchronizersper 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 flagServer thread
TrackingDebugSynchronizerthe engine: registration, the diff, and the tracking filterServer thread
ClientDebugSubscriberwhat to ask for, and the maps holding what came backRender thread
DebugRendererthe renderer list, rebuilt when the enabled entries changeRender 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.

collectorinstalled bywhat it collects
per tickMinecraft.collectPerTickGizmosanything the client tick draws, cleared each tick
per frame, during extractionLevelExtractor.collectPerFrameMainThreadGizmosthe debug renderers of this page, inside the extract pass
per frame, during renderingLevelRenderer.collectPerFrameRenderThreadGizmoswhat the world renderer itself wants outlined
the integrated serverIntegratedServer, around its whole packet-and-tick stepserver 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.

subscriptioncarriesfed by
DebugSubscriptions.BRAINSDebugBrainDumpMob.registerDebugValues
DebugSubscriptions.GOAL_SELECTORSDebugGoalInfoMob.registerDebugValues
DebugSubscriptions.ENTITY_PATHSDebugPathInfoMob.registerDebugValues, from the navigator’s current Path
DebugSubscriptions.BEES / DebugSubscriptions.BEE_HIVESDebugBeeInfo / DebugHiveInfoBee and BeehiveBlockEntity
DebugSubscriptions.BREEZESDebugBreezeInfoBreeze
DebugSubscriptions.POISDebugPoiInfoTrackingDebugSynchronizer.PoiSynchronizer, event-driven
DebugSubscriptions.VILLAGE_SECTIONSnothing but presenceTrackingDebugSynchronizer.VillageSectionSynchronizer
DebugSubscriptions.RAIDS / DebugSubscriptions.STRUCTURESpositions / DebugStructureInfoLevelChunk.registerDebugValues
DebugSubscriptions.GAME_EVENT_LISTENERSDebugGameEventListenerInfothe listener registry
DebugSubscriptions.GAME_EVENTSDebugGameEventInfothe dispatcher — an event, expiring after 60 ticks
DebugSubscriptions.NEIGHBOR_UPDATESa positiona listener installed on the neighbour updater — an event, 200 ticks
DebugSubscriptions.ENTITY_BLOCK_INTERSECTIONSDebugEntityBlockIntersectionEntity, pushed directly, 100 ticks
DebugSubscriptions.REDSTONE_WIRE_ORIENTATIONSan Orientationthe experimental wire evaluator, 200 ticks
DebugSubscriptions.DEDICATED_SERVER_TICK_TIMEno value at allsee 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 DebugSubscription registry 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.