Tags
Verified against Minecraft 26.3 · Part II · A parrot flies down through the canopy looking for a log to perch on, a moment after a data pack put a new block in the logs tag.
A parrot is looking for somewhere to sit. Parrot.ParrotWanderGoal scans
the blocks around it and asks the block state beneath each one whether it is
leaves or in BlockTags.LOGS — a leaves block it recognises by class, a log
only by tag. Nothing in that goal names oak, or spruce, or the
block a data pack added an hour ago; the tutorial toast that tells a new
player to punch a tree asks the same question. A tag is a named set of
registry entries, defined in data-pack JSON and tested against by key, and
it is how data reaches into hard-coded behaviour without the behaviour
naming any specific block. The registries page
ended on a promise: a frozen registry’s contents never change. Its tags
do. Type /reload with a data pack that adds a block to logs and the
parrot perches on it a moment later — with BuiltInRegistries.BLOCK frozen
since before the title screen. Nothing is unfrozen to do it, and no lock is
taken; the swap is safe for a reason that has nothing to do with either.
The cast
| class | what it decides | thread |
|---|---|---|
TagKey | the name: a registry key and an Identifier, interned so equal keys are one object | any |
TagFile · TagEntry | the on-disk shape: a list of entries, each an element or a reference to another tag, and the replace flag | — |
TagLoader | reads every pack’s copy of every file and resolves them in dependency order; what an id resolves to is its TagLoader.ElementLookup | Worker at world load, Server on /reload |
MappedRegistry | the tag half: MappedRegistry.frozenTags, one HolderSet.Named per freeze-time key, and MappedRegistry.allTags, the bound MappedRegistry.TagSet | read from any; written before the freeze by the launching thread, which binds a static registry’s tags to empty, and by a data-pack registry’s load tasks, and after it by whichever thread is applying |
Registry.PendingTags | a loaded table not yet installed, with a Registry.PendingTags.lookup that hands out the new table’s tags, bound only when it is applied | built on a worker at world load, on the server thread for /reload, on the Render thread from the packet; applied on the owning thread |
HolderSet.Named | one tag’s contents, bound at each reload that keeps the tag | — |
Holder.Reference | one element’s own Set of TagKeys — the thing a membership test reads | — |
TagNetworkSerialization | the wire form: tag id to a list of registry ints | Server encodes, client decodes |
A tag is a key and a file
In code a tag is a TagKey: a record of the registry’s ResourceKey and
an Identifier, interned through a weak interner so that TagKey.create
always returns the canonical instance and a membership test is a
set lookup on a canonical key. The keys are declared once, in catalogues that
hold no contents — BlockTags, ItemTags, EntityTypeTags, BiomeTags,
FluidTags and their siblings, twenty-one files in net/minecraft/tags
including DamageTypeTags, EnchantmentTags, StructureTags,
PoiTypeTags, FeatureTags and TimelineTags.
A key that a block and its item share is declared once as a
BlockItemTagId in BlockItemTags and projected into the catalogues that use it:
BlockTags.LOGS is the block half of BlockItemTags.LOGS.
On disk a tag is a TagFile: a list of TagEntry and a replace flag.
Each entry is an element id or a #-prefixed reference to another tag,
with a required flag that defaults to true. The file lives at
data/<namespace>/tags/<registry path>/<name>.json —
Registries.tagsDirPath builds that string in exactly one place, and the
registry path is singular: tags/block, tags/item, tags/entity_type,
tags/worldgen/biome. Vanilla’s own files are written
by the data generator (TagsProvider, TagBuilder), which the running game
never calls, and players reach tags through the #tag syntax of
ResourceOrTagArgument and ResourceOrTagKeyArgument (Part XIII).
ResourceSelectorArgument, beside them, is a glob over ids and takes no
tag at all.
Between the two sits TagLoader, generic over what an id resolves to: a
Holder for a registry, a CommandFunction for function tags. It has two
instance steps. TagLoader.load reads every pack’s copy of every file into
lists of TagLoader.EntryWithSource; TagLoader.build resolves them in
dependency order, TagLoader.tryBuildTag doing one tag at a time through
the TagLoader.ElementLookup the loader was built with. Its output, a map
from each tag to its holders, is resolved but bound to nothing. Nothing owns the
loader: it is called as static functions from WorldLoader,
MinecraftServer.reloadResources, ReloadableServerRegistries, the
registry load tasks and the client’s RegistryDataCollector, with no manager
between them. Function tags are the exception — ServerFunctionLibrary is a real
reload listener and runs its own TagLoader inside it. Everything in
net/minecraft/tags ships in both jars.
Inside a MappedRegistry a tag is two things. MappedRegistry.frozenTags
holds one canonical HolderSet.Named per key for the tags that existed
when the registry froze, and MappedRegistry.allTags is a
MappedRegistry.TagSet, the bound view, which starts out as
MappedRegistry.TagSet.unbound, where every read throws. Beside the
registry, each Holder.Reference carries its own Set of TagKeys, bound
by Holder.Reference.bindTags; that set, not the registry, is what
Holder.Reference.is reads.
The four moments tags are loaded
World load comes first, on the worker pool
(anatomy).
TagLoader.loadTagsForExistingRegistries runs before the reload
listeners, over the RegistryLayer.STATIC layer, and produces a
Registry.PendingTags for every static registry that has at least one tag
that builds. The pending tables are made visible to worldgen and
reloadable-registry loading
through TagLoader.buildUpdatedLookups, and applied, one registry at a
time, in ReloadableServerResources.updateComponentsAndStaticRegistryTags
once every listener has finished. For the whole of a load the old tags are
what Registry.getTags answers and the new table’s names are what the
loading codecs see.
/reload is the same call on the server thread, handed the composite
access of MinecraftServer.registries — so it re-reads and re-applies tags
for the dynamic worldgen registries too, not only the static ones. Of
the loading paths only the reloadable registries re-run — loot tables,
recipes, advancements and the rest — and a worldgen registry keeps its
elements and gets new tags.
The third is a data-pack registry, which loads its tags inside its own load
task rather than through the mechanism above: ResourceManagerRegistryLoadTask
reads them after its elements, with TagLoader.ElementLookup.fromGetters,
and RegistryLoadTask.registerTags binds them under the registry’s write
lock — before the freeze, so there is a lock here and no swap. (The
reloadable registries take this path too — they load through the same load
tasks — so a data pack’s loot-table tags bind like any other’s, and vanilla
ships none.)
The fourth moment is the client’s. One ClientboundUpdateTagsPacket
covering every static and every synced registry is sent by SynchronizeRegistriesTask after
the registry data. In configuration the client merely buffers it —
ClientConfigurationPacketListenerImpl.handleUpdateTags hands it to
RegistryDataCollector.appendTags, and nothing resolves until
configuration finishes. After a server /reload, PlayerList.reloadResources
broadcasts the same packet into the play phase, and
ClientPacketListener.handleUpdateTags handles that one at once.
From JSON to a parrot’s decision
sequenceDiagram
participant WL as WorldLoader
participant TL as TagLoader
participant MR as MappedRegistry
participant RSR as Reloadable<br/>ServerResources
participant Parrot as Parrot
Note over WL,MR: world load, on the worker pool
WL->>TL: loadTagsForExistingRegistries over the STATIC layer
TL->>TL: load: every pack's tags/block/logs.json, lowest first
TL->>TL: build: DependencySorter orders the nested tags, then tryBuildTag
TL->>MR: prepareTagReload: a Registry.PendingTags, which Registry.getTags cannot see yet
Note over WL,RSR: worldgen and reloadable-registry codecs see the new table through Registry.PendingTags.lookup
Note over MR,RSR: the thread driving the load, after the last reload listener has applied
RSR->>MR: Registry.PendingTags.apply: bind, swap, rebind every holder
Note over Parrot: a later server tick, the parrot's wander goal
Parrot->>Parrot: state.is(BlockTags.LOGS) — a set lookup on the holder's own tags
Note over MR,Parrot: no arrow to the registry: the check never reaches it
One tag, from the pack files to the check that uses it. The gap between
MappedRegistry.prepareTagReload and Registry.PendingTags.apply is where
the opening’s riddle lives, a table built that Registry.getTags cannot yet
see, and the last note marks an absence: the parrot’s check touches no
registry at all.
Every pack’s file, lowest first
TagLoader.load asks
the resource system for resource stacks — a
FileToIdConverter.json over the tag directory, listed with
FileToIdConverter.listMatchingResourceStacks — so every copy of
tags/block/logs.json across the enabled packs is visited in priority
order and merged, and a replace in a higher pack discards what the lower
packs contributed to that id. A pack whose file fails to parse is
logged and skipped, never fatal.
Tags of tags resolve in order, and a tag with a hole is dropped whole
The vanilla logs file names no block at all: it is three
tag references, logs_that_burn, crimson_stems and warped_stems, and
logs_that_burn is in turn ten references, oak_logs among them, before
oak_logs finally lists four blocks. TagLoader.build feeds every tag
reference into a DependencySorter and resolves the leaves first. A tag
with any failing entry — a missing required element as much as a
missing required tag reference — is dropped whole, not loaded minus the
entry, and is then absent from Registry.getTags, so neither the network
payload nor a lookup will find it. An optional entry (required: false)
resolves to nothing and the tag still builds: TagEntry.build answers not
required on a miss, whichever lookup it was handed. What the two lookups
differ on is where a required id is looked up, and the difference decides
what a missing one costs. TagLoader.ElementLookup.fromFrozenRegistry, used
for a registry that has already frozen, asks it either way: a name it does
not hold is a miss, and the tag is dropped.
TagLoader.ElementLookup.fromGetters, used by data-pack registries as they
load, sends a required id through the registration lookup, which
manufactures a placeholder Holder.Reference for a name nothing has
registered (MappedRegistry.createRegistrationLookup), and an optional one
through the immutable lookup, which does not. A data-pack registry has
registered all its elements before it reads its tags, so a placeholder there
is never bound: the registry’s freeze fails, with unbound values, and takes
the whole load with it.
Prepared, then applied
This is the swap the opening promised, and it is three steps and no lock.
MappedRegistry.prepareTagReload refuses a registry that is not frozen and
builds the new table, reusing the freeze’s HolderSet.Named objects where it
can; nothing is installed yet, and the Registry.PendingTags.lookup it hands
back answers with the new table’s names, which is what the worldgen and
reloadable-registry codecs are given while they load. Registry.PendingTags.apply is
then three ordered steps: bind each HolderSet.Named, swap the
MappedRegistry.TagSet, then rebind every holder’s tag set in
MappedRegistry.refreshTagsInHolders. Each step replaces an immutable
collection with one reference write, so a reader on another thread —
worldgen on the worker pool during a /reload, the Render thread in
singleplayer — sees a holder’s old tags or its new ones and never a
half-built set; what it can see, for the length of the rebind, is some
holders already new and others still old. Which thread applies depends on the
occasion: at world load the Server thread does not
exist yet, so the apply runs on whichever thread is driving the load — the
launching thread on a dedicated server, through Util.blockUntilDone, and
the Render thread on the client. Only a reload of a running server — /reload, or /datapack enable or /datapack disable — applies on the Server thread.
The client gets integers
TagNetworkSerialization.serializeTagsToNetwork walks
RegistrySynchronization.networkSafeRegistries and writes each tag as a
list of registry ids, dropping any registry whose payload came out empty.
That set is every RegistryLayer.STATIC registry, unconditionally,
concatenated with the synced dynamic ones —
RegistrySynchronization.isNetworkable filters only the second group. Ids
for dynamic registries are meaningful only once both sides have built the
same registry in the same order, which is why SynchronizeRegistriesTask
sends the registry data first. In the configuration phase that ordering is
a send-order constraint rather than a handling one: the client buffers
both packets and resolves everything at the end. The play-phase packet
resolves immediately against the live registry access, and the client then
rebuilds the creative-inventory search tree from the new tags.
Singleplayer skips only what it already has
On the play path
ClientPacketListener.handleUpdateTags always prepares, and skips only the
apply on a memory connection, because the integrated server’s apply
already rebound the registries both halves share. In configuration the
suppression is narrower still: only the non-networkable (static)
registries’ tags are skipped, and the client still binds tags on its own
copies of the remote dynamic registries, copies it then discards for the
server’s own (identifiers and
registries).
The check is a field read
BlockBehaviour.BlockStateBase is a
TypedInstance; TypedInstance.is asks the type holder — for a block,
Block.builtInRegistryHolder, the intrusive holder from
identifiers-and-registries — and
Holder.Reference.is is set-contains on an interned TagKey. No registry
is consulted. Parrot (the perch search), TrunkPlacer (worldgen) and the
client’s PunchTreeTutorialStepInstance all ask this way; FluidState,
Entity and ItemStack go through the same interface.
The other way tags cross the wire
ClientboundUpdateTagsPacket is not the only one. ByteBufCodecs.holderSet
encodes a HolderSet.Named as a marker plus the tag’s Identifier, and
decodes it by looking the tag up in the receiving side’s registry — so any
packet or data component carrying a tag-shaped HolderSet hard-fails on
a client that does not have that tag. That, more than the id numbering,
is why the tags packet must reach the client before play traffic does.
The same idea appears in data: TagKey.hashedCodec, HolderSetCodec and
RegistryCodecs are what turn “#minecraft:logs” in an ordinary JSON field
— a recipe ingredient, a loot condition, a placement predicate — into a
HolderSet without any of those files being tag files.
Questions players ask
What if two tags reference each other? Both are dropped, and both are
logged. DependencySorter.addDependencyIfNotCyclic drops the edge that
would close the cycle, so an order exists and the sort does not hang — but
whichever tag is built first asks for a tag that is not in the new table
yet, gets nothing, and fails as a missing required reference; the second
then asks for the first, which failed, and fails the same way. Two
Couldn’t load tag lines, and neither tag exists.
What happens to a tag a /reload deleted? It keeps its old contents,
and nothing tells you. The tag is absent from the pending map, so apply
neither rebinds nor clears it: anything still holding that HolderSet.Named
will iterate the old list, while HolderSet.Named.contains — which reads
the holder’s refreshed tag set — answers false, and Registry.get for the
key answers empty.
Is picking a random element from a tag deterministic? Yes, per pack
stack. Duplicate entries collapse and file order is preserved —
TagLoader.tryBuildTag collects into an insertion-ordered set — so
iterating a tag, or picking from it with HolderSet.getRandomElement,
gives the same sequence for the same packs.
Which tags does a client never hear about? Those of
RegistryLayer.RELOADABLE-layer registries (loot tables, predicates) and of
non-synced worldgen registries (features, structures) — and on
the receiving side, ids the client’s own registry does not know are dropped
from the payload silently.
For a 1.21-era reader. There is no TagManager and no reload listener for registry tags: loading is static functions on
TagLoader, called fromWorldLoader,MinecraftServer.reloadResources,ReloadableServerRegistries, the registry load tasks and the client’sRegistryDataCollector, and only function tags still go through a real listener. The directory is singular and has no plural fallback — tags/block, not tags/blocks — becauseRegistries.tagsDirPathbuilds that string in one place and accepts nothing else.
Where to look
TagKey · BlockTags · TagFile · TagLoader · Registry.PendingTags ·
MappedRegistry (the tag half) · HolderSet.Named ·
ReloadableServerResources · TagNetworkSerialization ·
Parrot.ParrotWanderGoal
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.