Data-driven registration-phase API (JsonTreeBuilder, index, and content dispatch)¶
This page is a reference (for looking things up). Its subject is the KasugaLibNext data-driven Java API: classes, methods, signatures, call timing, and failure behavior. Applies to: Minecraft 1.21.1 / NeoForge 21.1.203 / Parchment 2024.11.17 / Java 21. Line numbers on this page refer to the current working tree (the uncommitted data-driven reimplementation), baseline commit
ec864a1; signatures evolve with the code, so references are written asclass:lineand can be checked back against the code. Boundary: which key a JSON field is written under, what type it has, and whether it is required all belong toschema.md; the getting-started tutorial belongs tointro.md, and the steps for content authors and extension authors belong toguide-content.md/guide-extension.md. This page mentions field names only at the level of "which method reads it, and what happens when the read fails". Terminology: a domain is one of the two arrays in the index manifest,on_register(registration domain) andon_reload(reload domain); a bucket is a list holding diagnostics, split along two dimensions: the mod dimension, split by mod id (registration-phase errors), and the domain/source-key dimension, split by(Domain, key)(reload-phase errors useRELOAD_DATA[<namespace>]).
1. Module and package structure¶
Code root: modules/data-driven/src/main/java/lib/kasuga/registration/data_driven/.
data_driven/
├── TypeHandler.java # handler interface: top-level/embedded types, phase, identity
├── TypeHandlerRegistry.java # global static handler table (LinkedHashMap, order-preserving)
├── JsonTreeIntegration.java # the wiring point with RegisterContextRegistry
├── builder/
│ └── JsonTreeBuilder.java # registration-phase entry: index parsing, content dispatch, dedup, apply, error bucket
├── context/
│ ├── BuildContext.java # build context base class (modId / rootGroup / meta access)
│ ├── RegBuildContext.java # registration-scenario context (group / reg access)
│ └── JsonRegistryGroup.java # a group created from JSON (a RegistryGroup subclass)
├── handler/
│ ├── MetaTypeHandler.java # marker base class, no extra behavior
│ ├── RegistryGroupHandler.java # registry_groups, PHASE_GROUPS
│ ├── RegTypeHandler.java # abstract base class for registration-type handlers (shared by blocks/items)
│ ├── BlockTypeHandler.java # blocks, PHASE_CONTENT
│ ├── ItemTypeHandler.java # items, PHASE_CONTENT
│ └── BlockEntityTypeHandler.java # block_entities (embedded in blocks), PHASE_EMBEDDED
├── dedup/
│ ├── DuplicateIdResolver.java # pure-function last-wins deduplication
│ └── EffectiveId.java # id normalization
├── reload/ # reload domain: extension points + orchestrator
│ ├── ReloadHandler.java # extension interface: decode/register for one reload type
│ ├── ReloadHandlerRegistry.java # global static handler table (LinkedHashMap, order-preserving)
│ ├── ReloadOrchestrator.java # reload-cycle orchestrator: clear buckets / discover / dedup / register / wrap-up
│ ├── Decoded.java # the result of one decode (entries + errors)
│ └── Reloaded.java # a landed registration entry + the namespace it came from
├── diagnostics/
│ ├── Diagnostics.java # the diagnostic facade (mod dimension + domain/source-key dimension)
│ └── DataDiagnosticsCommands.java # /kasuga_data errors [mod]
└── property/
├── JsonPropertyParser.java # block property parsing
├── JsonItemParser.java # item property parsing
└── compiler/ # custom property matchers
The orchestration and decoding of the reload domain live in two places:
modules/data-driven/src/main/java/lib/kasuga/registration/data_driven/reload/(the orchestratorReloadOrchestrator, the extension pointsReloadHandler/ReloadHandlerRegistry/Decoded/Reloaded)modules/modelling/src/main/java/lib/kasuga/rendering/models/mc/dynamic/fsm/(the consumers: the two handlersFsmReloadHandler/FsmClipsReloadHandler, the registrar beanFsmReloadHandlerRegistrar, and the pure decodersStateMachineDefinitionLoader.javaandAnimationClipLoader.java)modules/render-core/src/main/java/lib/kasuga/rendering/models/uml/dynamic/fsm/FsmAnimationClips.javaandFsmDefinitions.java(bucketed storage)
Dependency direction: modelling → data-driven. data-driven does not depend on modelling.
2. Registration-phase entry point: JsonTreeBuilder¶
builder/JsonTreeBuilder.java is the only entry point of the registration domain. All methods are static, and the class cannot be instantiated (:798).
2.1 buildForMod¶
public static JsonRegistryGroup buildForMod(String modId) // JsonTreeBuilder.java:61
Responsibility: load one mod's on_register content, and build and return its root registry group <modId>:json_root.
Parameter constraint: modId must have a corresponding mod file in FML's ModList (internally via findModFile, :723). When it is not found, null is returned and no error is reported.
Execution order (one call):
ensureHandlersRegistered()(:52), idempotent, registers the four built-in handlers once.clearLoadingErrors(modId)(:63), clears only that mod's bucket.- Locate the index directory
data/<modId>/kasuga_lib/data_driven/(findIndexDir,:776). - Read the index manifests one by one (
listIndexFilesscans the directory,readIndexManifestparses), then aggregate them (resolveIndexManifests). - Parse only the content files
on_registerpoints at; theon_reloadsection is recognized and validated here, but not parsed (:105-110). - Resolve duplicate ids before any apply side effect (
:119). - Apply each handler in ascending
getPhase()order;registry_groupsgets an extra topological sort before apply (:135-146). - Return
rootGroup(:148).
Cases that return null:
| Case | Location | Error recorded? |
|---|---|---|
The index directory does not exist, and the old-layout directory data/<mod>/kasugalib/ does not exist either |
:75-79 |
No, only a DEBUG record |
| The index directory does not exist, but the old-layout directory does | :66-74 |
Yes, WARN + bucket entry, hinting at migration to on_register |
| The index directory exists, but no manifest declares either domain (an empty directory included) | :95-99 |
Yes; the aggregation stage records "declares neither ... in any index file" |
Note: a mod that declares only on_reload does not count as nothingDeclared(), so it returns a non-null empty root group (:100-104 records only INFO), not null.
2.2 buildAll¶
public static Map<String, JsonRegistryGroup> buildAll() // JsonTreeBuilder.java:151
Responsibility: walk every mod file in ModList, call buildForMod for each mod, and collect the non-null results with the mod id as key, preserving LinkedHashMap order.
Parameters: none. Constraint: the same mod file path is processed only once (visitedRoots, :159).
Failure behavior: when a single mod throws, the catch records an ERROR and puts the exception wrapped in an IllegalStateException into that mod's bucket (:168-172), then carries on with the next mod.
Note: there is no call site for buildAll() anywhere in the repository (only its declaration); the actual wiring currently goes through buildForMod. Whether it is a reserved API is to be confirmed.
2.3 When JsonTreeIntegration is wired up¶
JsonTreeIntegration (JsonTreeIntegration.java:13) is a Micronaut @Context bean. @PostConstruct (:21) registers a COMMON-side callback with RegisterContextRegistry; the callback body calls JsonTreeBuilder.buildForMod(modRegistry.getModId()) and, when the result is non-null, attaches it as a child node of modRegistry (:24-33).
The callback fires lazily: it runs only at the first registration-event dispatch for that mod. The guarantee this gives is that every @Context static factory registration precedes JSON parsing, without relying on explicit ordering.
2.4 Phase semantics¶
| Constant | Value | Handler that uses it | Reason |
|---|---|---|---|
TypeHandler.PHASE_GROUPS |
0 | registry_groups |
Groups must exist before content |
TypeHandler.PHASE_CONTENT |
1 | blocks, items |
Regular content |
TypeHandler.PHASE_EMBEDDED |
2 | block_entities |
A BE has to attach to an already existing block Reg |
Within the same phase the order is registration order (TypeHandlerRegistry uses a LinkedHashMap). The application order of registry_groups is also topologically sorted by parent reference via topoSortGroupDefs (:869); when there is a cycle, the Cycle detected among registry groups WARN is logged, the nodes on the cycle apply in their original order, and RegistryGroupHandler.store falls back to the root group for a parent it cannot find. A reference cycle therefore loses only hierarchy, never a group.
3. Index API¶
Index directory: data/<modId>/kasuga_lib/data_driven/. Every .json under it is a manifest, and a manifest declares only content-file paths, never types.
3.1 IndexManifest¶
public record IndexManifest(boolean declaredOnRegister, boolean declaredOnReload,
List<String> onRegisterPaths, List<String> onReloadPaths)
// JsonTreeBuilder.java:344
The parsed result of one manifest, not yet consumed. empty() (:347, package-visible) returns a "nothing declared" instance, used when the manifest file as a whole cannot be read.
| Member | Meaning |
|---|---|
declaredOnRegister |
on_register appears as an array (an empty array counts too, D2) |
declaredOnReload |
on_reload appears as an array |
onRegisterPaths |
The entries of on_register, in file order |
onReloadPaths |
The entries of on_reload, in file order |
3.2 ResolvedIndex¶
public record ResolvedIndex(boolean declaredOnRegister, boolean declaredOnReload,
List<String> onRegisterPaths, List<String> onReloadPaths)
// JsonTreeBuilder.java:362
// public boolean nothingDeclared() // :365
The result of aggregating all of one mod's manifests and removing cross-domain conflicts. nothingDeclared() is the D4 test: when it is true, no manifest ever declared either domain, so the caller must return immediately and register nothing.
3.3 parseIndexManifest¶
public static IndexManifest parseIndexManifest(String modId, String label, JsonObject root)
// JsonTreeBuilder.java:222
Responsibility: parse one in-memory manifest, consuming no file. It has no NeoForge dependency and can be called from pure JVM tests.
Constraints: a null root is treated as an empty file. Only on_register and on_reload are recognized; any other top-level field goes down the unknown-field path and is reported (see 3.6). The return value is never null.
Failure behavior: every violation is recorded as an ERROR and put into the modId bucket, with no exception thrown; within one call, one broken field does not implicate its sibling fields (D3).
3.4 resolveIndexManifests¶
public static ResolvedIndex resolveIndexManifests(String modId, List<IndexManifest> manifests)
// JsonTreeBuilder.java:294
Responsibility: aggregate all of one mod's manifests, run the cross-domain fail-closed check, and return the paths each domain can consume. A pure function, testable.
Constraint: manifests is passed in load order, and the aggregation keeps that order.
Failure behavior:
- The same path appearing in both
on_registerandon_reload(within one manifest or across manifests, both count) is reported as an ERROR and put into the bucket (D6), and is removed from both domains, so neither consumes it. - When no manifest declares either domain, one aggregate WARN is recorded plus a bucket entry (D4), and the returned
nothingDeclared()is true.
3.5 listIndexFiles¶
public static List<Path> listIndexFiles(Path indexDir, String modId) // JsonTreeBuilder.java:762
Responsibility: list the files under the index directory that end in .json, sorted by path string.
Failure behavior: when the directory cannot be listed, an ERROR is recorded and put into the bucket, and an empty list is returned. An empty result is itself a legal result (it means there are no manifests) and is judged separately by buildForMod.
3.6 How D1 to D6 show up at the API layer¶
The rule numbers follow the design document. Each corresponds to one testable behavior:
| Rule | Meaning | Where it lands |
|---|---|---|
| D1 | Both fields are individually optional; when neither appears (an empty file, or a file with unknown fields only) an error is reported and nothing is consumed | parseIndexManifest:240-244 |
| D2 | An empty array is legal and counts as "declared" | parseIndexManifest:237, the resolveIndexManifests:323 branch |
| D3 | An unknown top-level field is reported and processing continues; a field that is not an array is reported and its sibling fields are still processed | parseIndexManifest:226-230, readPathArray:256-277 |
| D4 | The aggregate warning branches on "which field was declared", not on the entries that were parsed out | resolveIndexManifests:323-326 |
| D5 | sources is not an alias; it is reported as an unknown field |
goes through parseIndexManifest:226-230 |
| D6 | A path listed in both arrays fails closed, and neither domain consumes it | resolveIndexManifests:308-316 |
readPathArray (:256) is private: a field that exists but is not an array, or an array element that is not a string, each records one ERROR and a bucket entry, without implicating sibling fields.
3.7 The deprecated field¶
The old index field was named sources. After the hard migration (D5) it is no longer a legal field: writing sources goes down the unknown-field path, reports contains unsupported field 'sources' and puts a bucket entry in, and that manifest declares no domain. The only legal domain fields today are on_register and on_reload.
3.8 Path contract¶
public static String validateSourcePath(String path) // JsonTreeBuilder.java:383
public static String[] indexDirectorySegments(String modId) // JsonTreeBuilder.java:745
validateSourcePath: content-file path validation. Returning null means legal; otherwise it returns a human-readable reason. Rules: non-empty, no leading /, ends in .json, no empty segment, no ./.. segment. A path is resolved relative to data/<mod>/. A pure function with no NeoForge dependency.
indexDirectorySegments: returns the segment array of the index directory relative to the mod file root, fixed at {"data", modId, "kasuga_lib", "data_driven"}. Every call returns a new array, so a caller's modification cannot pollute other callers. ReloadOrchestrator.indexResourcePath drops the first two segments to get the pack-stack path kasuga_lib/data_driven.
4. Content dispatch and apply¶
4.1 parseSource (private)¶
parseSource (:395) reads and parses one content file by path:
- Validate with
validateSourcePath; if illegal, record an ERROR and a bucket entry, then return. - Deduplicate by path string using the
parsedSourcesset; the same content file is parsed only once perbuildForMod, even when several manifests reference it. - Locate the mod file with
findModFile, concatenatedata/<modId>/<sourcePath>, and read it. - A missing file records a
Source file not foundERROR and a bucket entry. - A read/parse exception records an ERROR and a bucket entry (
:431-434). - Hand it to
dispatchContent.
4.2 dispatchContent (private)¶
dispatchContent (:461) dispatches a content file to handlers by top-level field:
- First collect the names of all handlers with
getParentTypeName() == nullintoknownFields, and put every embedded handler intoembeddedByFieldkeyed by typeName. - Every top-level field not in
knownFieldsis reported as an error and put into the bucket. If that field name happens to be an embedded type, the message adds a hint naming which key of which parent entry it should be written into (:475-490). - Each unknown-field message also appends a conditional hint (D9): if these fields belong to reload-domain content (written as a
state_machinesarray, for instance), list the file underon_reloadinstead (:483-490). - For every known top-level handler: the field must be an array and its elements must be objects; otherwise each is reported as an error, put into the bucket, and skipped.
- Embedded types enter only through
extractEmbedded(parentJson)and do not take a top-level dispatch slot (:528-535).
4.3 parseContentBody¶
public static Map<String, List<Object>> parseContentBody(String modId, String contentLabel, JsonObject root)
// JsonTreeBuilder.java:620
Responsibility: the in-memory half of the content pipeline. parseSource handles location and reading, and hands the body to it once read. Returns an immutable map grouped by "handler typeName to definition list", preserving dispatch order.
Constraints and failure behavior: parse and collect only; no registration, no apply. Every malformed input is reported through the bucket rather than thrown; the caller gets back the part that could still be parsed (partial-apply semantics). No NeoForge dependency, so it can be called directly from pure JVM tests.
4.4 applyUnchecked¶
public static void applyUnchecked(TypeHandler handler, Object definition, BuildContext context, String modId)
// JsonTreeBuilder.java:712
Responsibility: run one handler.apply and turn an exception into a bucket diagnostic.
Constraint: buildForMod calls it for every surviving definition. On an exception it records the Failed to apply '<type>' handler for mod '<mod>' ERROR and puts the wrapped IllegalStateException into the bucket (the original exception is kept as the cause). It is public because the contract "a failed apply must leave a trace" could otherwise only be exercised by a full mod load.
5. Conflict resolution¶
Duplicate ids are resolved before any apply side effect, so that MappedRegistry never sees a duplicate key.
5.1 DuplicateIdResolver¶
dedup/DuplicateIdResolver.java, a pure function with no Minecraft types.
public record Candidate(String typeName, String identity, String sourcePath, Object payload) // :39
public record Conflict(Candidate loser, Candidate winner) // :47
public record Result(List<Candidate> winners, List<Conflict> conflicts) // :65
public static Result resolve(List<Candidate> candidates) // :73
public static String describe(Conflict conflict) // :109
public static Throwable toLoadingError(Conflict conflict) // :124
- A
nullCandidate.identityopts out of deduplication: it is always a winner and conflicts with no entry. - The conflict key is
(typeName, identity). DifferenttypeNamevalues never conflict. resolveis last-wins: for the same key it takes the one appearing last in the candidate sequence. The sequence order is the load order, and the method does not sort internally.describe/toLoadingErrorproduce human-readable messages, containing the type field, the identity, and the source file of the loser and of the winner.
5.2 EffectiveId¶
public static String of(String modId, String rawId) // EffectiveId.java:39
Responsibility: normalize a raw id to the ResourceLocation string it finally registers under, for deduplication comparison.
| Raw id | Normalized result |
|---|---|
foo |
<modId>:foo |
minecraft:foo |
<modId>:foo |
othermod:foo |
othermod:foo (verbatim) |
When rawId is null, returns null. It splits namespace and path on the first : only.
5.3 Per-type identity¶
| Type | identity | Source |
|---|---|---|
blocks / items |
the normalized effective id | RegTypeHandler.resolveIdentity (RegTypeHandler.java:58) |
block_entities |
the host block's effective id | BlockEntityTypeHandler.resolveIdentity (:66) |
registry_groups |
the raw id string, not normalized |
RegistryGroupHandler.resolveIdentity (:50) |
| A custom handler | TypeHandler.resolveIdentity returns null by default, i.e. it takes no part in deduplication |
TypeHandler.java:64 |
Why registry_groups is not normalized: groups do not go into an MC registry, and the loader references them by raw string everywhere (a block's registry_group lookup, store's putRegistryGroup), so normalizing only the decision key would produce a mismatch where a duplicate is decided but the reference cannot be found.
5.4 last-wins order¶
resolveDuplicates (:652) builds candidates in the load order of parsed:
- Index files by ascending filename;
- within one manifest, by the writing order of the
on_registerarray; - within one content file, by the writing order of the type-field arrays;
- embedded
block_entitiesfollow the position of the hostblocksentry.
Result: a loser never enters apply at all, and the registry sees the winner exactly once; each conflict records one WARN and goes into that mod's bucket as an IllegalStateException; there is also a summary Resolved N duplicate id(s) WARN. When identity resolution fails it degrades to null and does not escape (identityOf, :691).
Other pages in the data-driven section: Registration-phase API · Extension API · Diagnostics API · Reload API · Section index