Table of Contents

Namespace GrindFest.Content

Classes

AssetLibrary

The only place in the game that loads content: the registered content directories (ContentDirectoryStore) are the single backend — the Addressables package is gone, so every load, every residency lookup and every release goes through the store.

Responsibilities: address → asset with reference counting, tier policy, async loading with progress, and a deliberately loud synchronous fallback for the transition period (world generation still resolves prefabs synchronously; the goal of P2 is that it never misses, because the pipeline preloaded its set).

Nothing is ever released implicitly: every LoadAsync/GetOrLoad must be paired with a Release, or the tier must be released wholesale (ReleaseTier(AssetTier)). See CONTENT_PLATFORM_PLAN.md layer L3.

AssetLibrary.AssetNotResidentException

Thrown when code requires an asset that nothing acquired. See Get<T>(string).

ContentAssetFile

Shape of a generated assets.json file (replaces whole-catalog prefab scanning).

ContentAssetRecord

One addressable asset, as recorded in the generated assets.json.

ContentCodec

The runtime direction of the value codec (plan §4.2, "one codec, both directions"). The editor direction lives in ContentEncoder (SerializedObject semantics); this side decodes the same encodings with cached reflection, so an apply pass pays one lookup per (type, field) per session.

Encodings handled (mirror of the encoder): primitives JSON number/bool/string, cross-converted (long → float, string → number, …) enums name strings, flags as "A, B", numeric fallback Unity value types Vector2/3/4, Quaternion, Color, Color32, Rect, Vector2Int/3Int from {x,y,…} maps; Bounds from {center,size} arrays / List<T> JSON arrays, element-wise (all elements must decode) [Serializable] obj nested maps, recursed through fields (depth-limited) references {"$in":"comp:X"} / {"$in":"path:Body/Head|comp:X"} resolved on the instance being hydrated; {"$asset":"addr"} resolved when the asset is already resident; {"$guid":…} and non-resident assets report a skip (the archetype's own reference stands)

Everything unsupported is reported as a ContentCodec.SkipReason — never silently dropped.

ContentDB

The runtime content database: every definition, keyed by id, merged from the base game and from mods in a deterministic order.

Deliberately a plain static class (no MonoBehaviour, no scene object): it must be usable from the loading pipeline, from editor tooling, from mods and from tests — including before the game scene exists.

File layout is not part of the contract. Any JSON file under a package's Content/ folder (recursively) is read, and the merge key is always the definition id: • index-shaped — { "format":1, "package":"x", "defs": [ … ] } (what the generator writes) • mirror-shaped — { "kind":"item", "defs": { "<id>" : { "components": { … } } } } (hand-written) • patch-shaped — { "patches": [ { "op": "add|patch|replace|remove", … } ] } — record-level edits of content defined by earlier packages, so a mod tweaks base content without copying it (D13). An unreadable document is reported with the offending file and never ignored silently.

See GrindFest.Workspace/CONTENT_PLATFORM_PLAN.md (layers L1/L2, decisions D5/D13/D19).

ContentDef

One content definition — the data half of a prefab.

id is the stable identity (today: the prefab's Addressables address, which is what saves and networking already store). values carries only what systems filter/sort/decide on; the complete mirror of every component field lives in defs/<kind>.json, loaded on demand.

ContentDirectories

Where the built content directories live, and the one place that registers them.

Layout, one directory per content build, beside the content package it belongs to:

<StreamingAssets>/Content/base/content-directory/          the base game
<persistentDataPath>/Mods/<mod>/Content/content-directory/  one per mod

Why beside the package: the two halves of a scope belong together. The package carries the data (index, assets.json, defs) and the content directory carries the art the keys resolve to, so a scope moves, ships and gets deleted as one thing.

Why not the Addressables layout: a content directory is build output, and build output inside Assets/ is imported as assets. Under StreamingAssets it is copied verbatim into the player, which is what a content build wants, and on Web the same path is reached through the preloaded VFS — hence ExistsAsync(string, CancellationToken), which is the project's abstraction for "a folder on desktop, a URL in a browser".

Registration order is the mod-override mechanism. ContentLoadManager searches registered directories in reverse registration order, so registering the base game first and mods after makes a mod's version of a key win for free — no id remapping, no InternalIdTransformFunc, which is what ModAssetCatalogs needed with Addressables. <xref href="GrindFest.Content.ContentPaths.ListModFolders" data-throw-if-not-resolved="false"></xref> returns mods in dependency order, so a mod that depends on another is registered after it and overrides it, as it should.

ContentDirectoryStore

The content-directories provider: registers built content directories, resolves a key to a Unity.Loading.Loadable<T>, and loads it.

This is the same job the Addressables address table does for AssetLibrary, so it lives behind that same door. It is not a second door: AssetLibrary is still the only place the rest of the game asks for an asset.

Facts this type is built on, all measured in this project (2026-09-19, see GrindFest.Workspace/SYNC_LOAD_ELIMINATION_PLAN.md §2.5):

  • Registration reads the build manifest and the root assets into memory (3.7 ms for a small directory; 19.2 ms in a browser) — it is **not** residency. Assets load on Load() (blocking) or LoadAsync().
  • Search order across directories is **reverse registration**, so a mod registered after the base game wins. That is the mod-over-base override, for free.
  • **A build name can be registered only once per session** — registering a second copy of the same name fails — so unregistering has to precede reloading a mod.
  • Unity.Loading.Loadable<T> holds **one** reference: the first Release unloads for everyone. So a caller must not share one; this type keeps the id and hands out a per-caller Loadable.
  • A content directory is built for the **active build target**, and registration requires **play mode** in the editor ("not supported in edit mode").
ContentIndexFile

Shape of a generated index.json file. Mods ship the same format.

ContentKinds

Well-known definition kinds. Free-form strings are allowed; these are the ones the base game uses.

ContentLoadReport

Result of a content load pass — surfaced in the console, the loading screen and DevTests.

ContentMaterializer

Turns a definition's data into a spawned instance. Two layers:

Hydrate(ContentDef, GameObject) — applies the definition's merged values (patches, replaces, added/removed components) onto a freshly instantiated archetype. Untouched definitions are a no-op: their archetype already carries the final values, and prefab identity stays stable because gameplay derives deterministic values from prefab references. Only overridden content pays the ~2–3 ms apply measured by the phase-0 spike (MaterializerSpikeTests). • Materialize(GameObject, ContentDef, out Report)/Apply(GameObject, Dictionary<string, object>) — the spike-compatible raw surface (clone + apply, or apply onto an instance).

Value decoding lives in ContentCodec (plan §4.2, runtime direction). A baked-template cache (plan L4) is deliberately not part of this build: a cached template is an inactive clone, and instantiating from it would defer Awake for spawns whose consumers expect an active prefab — templates return once consumer activation semantics are audited.

ContentPackage

A content package: the base game (priority 0) or a mod.

ContentPaths

Filesystem layout of the content platform.

Base content : <StreamingAssets>/Content/base/ (shipped, versioned in git) Mod content : <persistentDataPath>/Mods/<Mod>/Content/ (authored, hot-reloadable)

All paths are physical files; nothing here goes through Addressables, because reading definitions must never load art (see GrindFest.Workspace/CONTENT_PLATFORM_PLAN.md).

ContentQueries

Allocation-free indexes over ContentDB — the queries that replace Addressables.LoadAssetsAsync(label) in gameplay code (plan P3).

Why indexes instead of filtering on demand: the callers are hot paths (a loot roll, a spawn table pick) and the project forbids LINQ/allocation churn there. Indexes are rebuilt lazily after each content (re)load, so a mod reload is picked up without restarting.

Nothing here touches an asset: every answer comes from the generated index, which is the whole point (values without prefabs).

ContentRootAsset

Root asset of a content directory build (Unity 6.5+ "content directories", the engine-level replacement for AssetBundles).

Why this type exists: UnityEditor.BuildPipeline.BuildContentDirectory refuses anything that is not a ScriptableObject — "The RootAsset 'X' is not a ScriptableObject. RootAssets must derive from ScriptableObject." — and only the assets reachable from such a root (directly, or through a Unity.Loading.Loadable<T>) end up in the build. So a root asset is where a name becomes a loadable reference, which is the job the Addressables address table does today.

Relationship to the rest of the content platform: this file replaces resolution (key string → asset), not the definition layer. ContentDB/defs/*.json keep answering "what is this item", and a root asset answers "where is its prefab, and is it loadable yet".

One entry type for every kind of content: a Unity.Loading.Loadable<T> built from a Unity.Loading.LoadableObjectId. Verified 2026-09-19 that a GameObject, a ClipTransitionAsset, a DropTable and a BuildPlannerPreset all produce a valid id and construct a Loadable — the id identifies the object, so the type argument does not have to match the asset's concrete type. That matters: 153 of the 3941 content records are not GameObjects (ClipTransitionAsset, TextAsset, Texture2D, FieldSetup, CharacterClass, RoomShape, DropTable, …).

Unity loads every root asset of a registered content directory into memory, so the entries here must stay small: a key and a Unity.Loading.Loadable<T>, never a direct reference to a heavy asset.

ContentTiers

Load tiers decide when an asset is resident. Boot is awaited by the loading screen; UI/Definition stay resident while in use; Runtime is per-use and released.

ContentValues

Derives the queryable scalars of a definition from its encoded component fields.

Rule: if a gameplay system filters, sorts or decides on a value without loading an asset, it belongs in the index; everything else stays in the cold full mirror.

Shared by the generator (base content) and the loader (mod content), so a definition authored by a mod is queryable exactly like a generated one — there is no second implementation to drift.

InkWorldSource

The base game's world: terrain and structures come from Ink sources, and the prefabs they reference live in the curated InkWorldPrefabs Addressables group.

The group is the declaration — that is why the generator records a group per asset in the catalogue: the scope can be answered without parsing Ink, and a prefab added to the group becomes boot content automatically. (Deriving the exact per-area subset from the Ink sources is a later refinement; it needs nothing more than the same list narrowed per Area.)

LoadingPipeline

The boot sequence, as an explicit list of weighted stages.

Why it exists: the current loading screen derives its progress from SceneManager.LoadSceneAsync, which says nothing about content, assets or the party — so it parks at 0.99 while the real work happens (and the player sees a frozen bar). Here, progress is the actual work: content parsing, boot-scope preloads, scene.

The pipeline only drives stages; it does not own the systems. Scene and Party are intentionally left to the caller until GameManager is rewired onto this sequence (plan §8.1).

See CONTENT_PLATFORM_PLAN.md §7.

LoadingStage

One weighted step of the boot sequence.

NullWorldSource

Starts the game with no world content (tests, tools, a future non-world mode).

SyncLoadReporter

Records every synchronous asset load, so the set of assets a preload stage is missing is measured in a normal session and reported on demand by Summary().

Why this exists: the content platform's answer to "what should be resident before this action" is a scope (plan D20, section 7.2), and it deliberately has no hand-written preload list. The intended way to discover a missing scope is to look at what the session fetched synchronously: AssetLibrary.OnSyncLoad names each asset, and during boot that count must be zero. That instrument existed from the start and had no subscriber at all, which is exactly why a blocking load in skills went unnoticed.

What the numbers mean:

  • a handful of distinct addresses, once each, early in a session: a scope that should have declared them;
  • the same address repeatedly: something is re-reading after a release, i.e. a tier/lifetime mistake;
  • nothing at all: the scopes cover the session, which is the target state (P7).

A miss also records who asked for it (LastRequester), because the address list alone cannot separate "every ItemBehaviour in the scene" from "one skill that ran once", and those two need different answers: a tier/scope versus the async path.

Cost when idle: one dictionary insert per miss. There are no misses in a healthy session.

Structs

ContentMaterializer.Report

What an apply pass did — the numbers the spike reports.

ContentRootAsset.Entry

One named asset. The key is the runtime contract; the Loadable is the build-time edge.

WorldScope

"What has to be resident right now" — the contextual half of the preload question (plan §7.2). Tiers answer policy (is it ever released); scopes answer demand (is it needed now).

Interfaces

IWorldSource

A world generator that declares its prefab needs instead of letting the engine guess (decision D16: the world is a package, not a mode switch in the boot path).

Implementations are contributed by content packages; the base game ships the Ink world. Returning an empty list is legitimate (tests, "no world", a world that builds everything procedurally).

The returned addresses are preloaded through AssetLibrary before generation runs, which is what keeps the synchronous FindPrefab path in world generation a cache hit (D17/D18).

Enums

AssetTier

When an asset should be resident. Mirrors the tier strings written into the catalogue (ContentTiers) so data and runtime agree.

ContentCodec.SkipReason
WorldScopeKind