Table of Contents

Namespace GrindFest.Content.Editor

Classes

ContentAuthoringSource

The editor's content source: it answers content keys from the project itself, so no content-directory build is needed to press Play.

Why this exists. A content directory is build output, and for a player it is the right answer: one file set, target-specific, shipped. In the editor it is a tax. ContentDeclaration already declares every key from the folder rules and Key is the asset path, yet a prefab added a minute ago stayed invisible until someone ran Generate Content Roots and Build Content Directories — measured 2026-09-20 at 4.3 minutes for the base scope, warm. That is the wait this removes, and it was the reason an author could not iterate on a new variant without a coffee break.

How it answers. Two delegates, both registered into ContentDirectoryStore:

  • Resolve(string) — address to asset in one AssetDatabase lookup. It answers both "do you know this key" and "give me this key" for a single address, and needs no index at all, which is why adding a prefab costs nothing.
  • GrindFest.Content.Editor.ContentAuthoringSource.BuildKeys() — the whole declared set, built **lazily**, because only name→address resolution (`InkWorldGenerator.AddressForName`) and diagnostics actually enumerate.

Keeping up. GrindFest.Content.Editor.ContentAuthoringPostprocessor sees every import, delete and move and updates the set one path at a time through the same rule the build scan uses (WouldDeclare(string, List<string>, out string)), so a new variant prefab is visible to the next lookup — and the two cannot drift, because there is one copy of the rules.

What it is not. It does not touch the build path. Players still read the built directory, and both sources may be registered at once: the built directory wins where it knows a key (it is the authority on what ships) and this fills in what the build does not have yet.

ContentBuildPreprocessor

Regenerates the base content artifacts (StreamingAssets/Content/base) before every player build, so a build can never ship a stale index/mirrors/assets catalogue — the failure mode that otherwise ships silently (stats and loot queries read the artifacts, not the prefabs). The generator is deterministic (~3 s) and writes files only when they change, so a clean working tree stays clean.

If the scan fails, the build fails: the alternative — shipping the previous artifacts — is exactly what this hook exists to prevent. The sampled drift gate (ContentArtifactsTests.MirrorsMatchPrefabs) keeps the working tree honest between builds; this hook closes the build path itself.

Runs early (order -60) but after the VS Code archive download (-100), and before the story deploy preprocessors that consume content — see GrindFest.Workspace/CONTENT_PLATFORM_PLAN.md §7.

ContentDeclaration

What content is, expressed as rules instead of as marks on assets.

This replaces the Addressables groups and labels that used to be the declaration. The reason is not taste: a mark is a step a human forgets, and forgetting was silent — Rock.prefab shipped unreachable for months, and the story's props needed a helper tool (MarkPrefabsAddressable) that existed only because there was nowhere to say this once. A rule cannot be forgotten per asset.

The rule is one sentence: content is everything under a folder named Prefabs or Animations that lives inside a pack we ship, plus a short list of other content folders, plus anything declared explicitly. That is a derivation, not an invention — every one of the 3941 records the marked declaration produced still resolves, and the handful the folders do not reach are named one by one in ExplicitFileName.

Two limits in that sentence are load-bearing, and both were put there after measuring what their absence costs:

  • Inside a pack we ship (GrindFest.Content.Editor.ContentDeclaration.ContentPacks). "Any folder named Prefabs anywhere" was the first version, and it declared 18656 assets — TopDownEngine/ThirdParty/MoreMountains demo content, a Sci-Fi and a Kids art pack nothing in the game references, and every part of PolygonFantasyHeroCharacters. The pack list is the set of packs the shipped catalogue actually used, so it is evidence, not preference.
  • Never under Models. A pack's Models/ folder is source art — 6647 .fbx files in this project — and GetMainAssetTypeAtPath reports an imported model as a GameObject, so a type filter cannot tell it from a prefab. The one model that genuinely shipped (FX_Ring_Mesh.fbx) is declared explicitly.

Two more pieces of information the old declaration carried, and where they come from now:

  • Kind (monster/item/room/…) decides which assets become definitions with encoded components. It comes from a folder table, because that is what the data says: monsters live in Prefabs/Characters, items in Prefabs/Items, rooms in Dungeons/Fields. A folder with no kind still contributes a catalogue entry — being resolvable by name is a different fact from being a definable thing.
  • Tier (when it loads) is inferred from the path and the kind, not authored.

What is deliberately absent: any notion of a bundle, a group, a label set, or a download. That was transport. The declaration says what exists; where it ships is a build decision.

ContentDirectoryBuildProcessor

Puts the built content directories into the player, and makes them readable there.

Two destinations, because the platforms differ in one respect only — whether StreamingAssets is a folder:

  • **Desktop/editor**: StreamingAssets is a real directory, so injecting the built folder is enough. AddAdditionalPathToStreamingAssets(string, string) takes the source *and* the path inside StreamingAssets, so the player lands exactly where BaseDirectory looks.
  • **Web**: there is no file system to read from, and this is the whole reason the project could not ship content to a browser. A file is only synchronously readable if our own build put its bytes into the VFS first, so every file in the directory has to be named in a preload manifest under Library/PlayerDataCache/WebGLPreloadedStreamingAssets — an internal Unity cache, which is why stale manifests have to be deleted rather than trusted.

The active build target is switched before building, because a content directory is built for the active target and a directory built for another one registers and then fails per file in the player ("File's Build target is: 19"). Getting this wrong produces content that loads in the editor and not in the build, which is the most expensive way to find out.

ContentDirectoryBuilder

Builds the content directories — the art half of a scope, next to the content package's data half.

One directory for the base game, one per mod. Not one per root asset:

  • A content build follows every reference from its roots and writes what it reaches, so shared dependencies are *duplicated* across directories. Sixteen scope directories would re-ship the same meshes and textures sixteen times — the measurement is already 341 MB / 3049 files for 100 prefabs, essentially all of it Polygon* packs dragged in as dependencies.
  • Per-mod directories are not an optimisation but the override mechanism: registration order is search order, so a mod registered after the base game wins, and a mod can be unregistered without touching anything else. That only needs a boundary between the base game and each mod, not between scopes.
  • Splitting the base game into several directories stays possible later (the plan says scopes must be re-cut with real build numbers), and it is a change here, not in the runtime.

Output goes outside Assets/: a content directory is build output, and under Assets/ Unity would import every file into the AssetDatabase (3049 files for a 100-prefab scope) and it would end up committed. The player gets it through StreamingAssets instead — ContentBuildPreprocessor injects it, and on Web it is preloaded into the VFS, both of which are how the runtime finds it at the path BaseDirectory expects.

Build target: a content directory is built for the active build target and fails per file in any other player ("File's Build target is: 19"). The caller must have switched the active target before calling.

ContentDirectorySmoke

Builds a deliberately tiny content directory so the loading path can be exercised for seconds instead of minutes. A real scope costs ~741 s and 341 MB per 100 prefabs, which is far too slow to iterate on, and the loading code does not care how many entries a directory has.

The three assets are chosen to cover the shapes the game actually stores: a GameObject prefab, a ScriptableObject that is not a GameObject (the majority of non-prefab records), and a skill animation asset.

ContentEncoder
ContentPipeline

Editor entry points for the content platform: rebuild the generated base content, inspect what would be written, and validate that the generated data still resolves.

Output (committed to git, shipped in StreamingAssets): Content/base/index.json hot index — identity, tags, addresses, tiers, queryable scalars Content/base/assets.json address catalogue (+ name lookup) — replaces whole-catalog prefab scans Content/base/defs/<kind>.json cold full mirror of every component field, keyed by id

ContentRegistrationCheck

Answers "is this asset registered as content?" for editor code that has to warn about an asset nothing can resolve, and lives in one place because two classes ask it on every inspector change.

It reads the content manifest, which is what the runtime resolves names against. The code it replaces asked Addressables whether an entry existed, and those are not the same question — an Addressable entry and a content key can disagree, and when they do the manifest is the one the game actually uses. Asking the manifest is also what let the callers stop forcing an address to equal a path, which was the other half of what they did.

ContentRootAssetEditor

The editor side of a content root: this is where content gets marked.

The point of the migration is that declaring an asset as content happens in the editor, on the asset, and never in a hand-written file. So this inspector is the authoring surface: drag assets into the list (or select them in the Project window and press a button), give them keys, and the entry's Loadable is derived from the path for you. Nothing here writes an address, a GUID or a label by hand — the entries also carry the path they came from, so the list stays readable.

Two operations matter and both are here rather than in a menu, because they are corrections to a list you are looking at: Resolve re-derives every entry's id from its path (after a reimport, a move, or an entry that arrived from a generator), and Drop unresolved removes the entries whose asset is gone, so a root cannot silently carry a hole into a build. A content build copies what the roots reach; an entry whose id never got built is a key that resolves to nothing at runtime, which is the failure this whole migration is about.

ContentRootAssetGenerator

Generates the ContentRootAsset set — the content declaration — from the generated content manifest.

Why this exists. Today the declaration of "what is content" lives in Addressables state: ContentScanner reads AddressableAssetSettingsDefaultObject.Settings, takes each entry's labels, and derives a kind. Two problems with that, both observed: forgetting to mark an asset is silent (Rock.prefab sat on disk with a MiningResourceNodeBehaviour and was never registered, so @asset:GameObject/Rock could not resolve and shipped unreachable), and it welds "what is content" to "who packages it", which is exactly what the content-directories migration retires.

A root asset is where a name becomes a loadable reference — the job the Addressables address table does today — so generating the roots from the manifest moves the declaration into the repository, as assets, in git, reviewable and diffable, while Addressables still does the building.

Scope == the existing group, for this first cut. The migration plan calls those groups "artifacts of one-off registration tools, not designed boundaries", and that is right — but re-cutting the grouping at the same time as changing the mechanism would confound two variables, and a wrong cut is a build-time mistake (100 prefabs measured 741 s / 341 MB when the grouping was wrong). So the first cut is deliberately behaviour-preserving: every address the manifest carries ends up in exactly one root, under a name derived from the group it is in today. Re-cut the scopes afterwards, with real build numbers.

ContentScanner

Scans the content declaration and its prefabs, producing the three generated artifacts of the content platform: index records (hot), full component mirrors (cold) and the asset catalogue.

The declaration used to be Addressables: a group and a set of labels per asset. It is now rules — see ContentDeclaration for what they are and why the marking had to go. The scan itself did not change: kinds still arrive as labels, values are still read out of the encoded component dictionaries by field name, so a new component, field or prefab still needs no change to this file.

ContentScanner.ScanResult
PreBakeCollisionTool

Finds the models a content-directory build refuses to cook, and (optionally) fixes their importers.

Why this exists: UnityEditor.BuildPipeline.BuildContentDirectory(UnityEditor.BuildContentDirectoryParameters) reports, per mesh, Pre-baked convex collision is not enabled on the mesh 'X' used by a Mesh Collider component … collision data cannot be baked at runtime and will be empty in a Content Directory build — an error, 104 of them in the first real build — and Read/Write is disabled on the mesh 'X' used by a Particle System … component. Built content without this is not merely lighter: the collision data is empty, so the object has no collider at runtime.

The switches are per model importer, not per mesh: SetPreBakeCollisionMesh(bool, bool)(bool isConvex, bool preBake) and isReadable. Enabling them for every model in the project would cook data nothing uses, so this tool only touches models whose meshes are actually referenced by a MeshCollider or by an enabled mesh-shaped ParticleSystem.

Scope of v1: prefabs only. Scenes are not walked (opening every scene in the project from a menu item is not something a tool should do to a working editor); a MeshCollider that exists only inside a scene will not be found. Scenes that end up inside a content directory will need their own pass.

Menu: GrindFest ▸ Content ▸ Collision pre-bake. Batch: -executeMethod GrindFest.Content.Editor.PreBakeCollisionTool.ApplyFromBatchMode.

Structs

ContentDeclaration.Declared

A declared asset: where it is, what it is called, and what it is.