Table of Contents

Class ContentDB

Namespace
GrindFest.Content
Assembly
GrindFest.dll

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).

public static class ContentDB
Inheritance
object
ContentDB

Properties

AddressByName
All
AllAssets

Every catalogued asset (requires EnsureAssetsLoaded()).

BuildStamp

Build stamp of the currently loaded base index (its generation time).

Count
IsLoaded

True once a load completed successfully at least once.

Items
LastReport

Report of the last load pass.

Monsters
Packages

Methods

ByKind(string)

All definitions of a kind (item, monster, …). Empty when unknown.

ByTag(string)

All definitions carrying an Addressables label / content tag.

Clear()

Drops all loaded state. Used by tests to prove a cold start works.

EnsureAssetsLoaded()

The generated asset catalogue (address → record + name → address). Loaded lazily so a headless or test context never pays for it. This replaces whole-catalog prefab scanning at boot.

EnsureAssetsLoadedAsync(CancellationToken)
Get(string)
GetSourcePackage(string)

Id of the package that contributed a definition (for diagnostics and mod tools).

Load(IProgress<string>)

Synchronous content load, for editor code and the test suites only.

The game has exactly one loading path - LoadAsync(IProgress<string>, CancellationToken), awaited by the boot pipeline on every platform. This wrapper exists because the editor tests and the DevTests call it synchronously, and it is safe only where every read completes without ever yielding. That holds on desktop and in the editor (every async primitive in ContentPaths resolves synchronously for a local path), and it does not hold in a browser - where this throws rather than silently loading nothing.

LoadAllBaseMirrorsAsync(CancellationToken)

Reads every base mirror now instead of on first use, and returns how many definitions that added.

This deliberately gives up half of D5's lazy-per-kind trade. The reason is where the lazy read lands: the first bake of each kind happens during normal play (a loot roll, a monster spawn, a skill), so a ~3 MB JSON parse and its object graph arrive at an arbitrary moment in the middle of gameplay, for a cost nobody scheduled and no one can see. Paying it once during the content stage instead moves it to a defined place in the boot, in front of the loading screen, and makes every later definition read a pure memory lookup. The measured cost is logged by the caller - if it ever stops being small, this is the line to re-examine.

Idempotent, and it shares GrindFest.Content.ContentDB._loadedMirrorKinds with the lazy path, so a kind read here is never read again there.

LoadAsync(IProgress<string>, CancellationToken)

Loads (or reloads) every package. This is the loading path, on every platform.

Whether a given read is a file read or a web request is decided per path by ContentPaths, not by an #if here: the desktop and the browser run the same code, so a platform cannot quietly diverge (that is how the browser used to "load" content with zero definitions). On a local platform nothing below yields - so Load(IProgress<string>) can call this and block - while in a browser the web requests genuinely await.

On failure the previous data is kept and LastReport says why.

NeedsBake(string)

True when gameplay must spawn this definition through the materialiser (which applies the merged values onto the instance) rather than trusting the archetype: component patches, added/removed components — or a full 'replace'. Everything else keeps prefab identity, which gameplay depends on (several formulas derive deterministic values from prefab references), so untouched content is never substituted.

RefsOf(string)

The assets address directly depends on, kept to those that are themselves addressable. Empty when the asset is unknown or when the catalogue predates format 2 - null and empty mean the same thing here, so callers never have to distinguish "no references" from "not recorded".

Reload()

Fire-and-forget reload, used by the file watcher and the editor menu.

ResolveAddressByName(string)

Resolves an asset address by file name, e.g. "Iron Sword".

ResolveAssetPath(string)

Project-relative asset path for an address, when known (editor tooling, diagnostics).

ScopeOf(string)

The transitive closure of RefsOf(string) - every address that should be resident before address is used, the asset itself included (BFS order, root first). Cycle-safe.

This is the "scope" a preload stage declares: the catalogue stores the edges and the closure is derived, which is why the data stays small. Returns an empty list when the address is not catalogued.

SetModLoadOrder(IReadOnlyList<string>)

Pushes the dependency-sorted mod order (folder names, dependencies first). Called by ModManager before content loads. Without it, mod packages merge in ordinal folder-name order.

TierOf(string)

Residency tier of an address, as recorded by the generator.

TryGet(string, out ContentDef)
TryGetAsset(string, out ContentAssetRecord)
TryGetDefinitionComponents(string, out Dictionary<string, object>)

Full component data of a definition — the cold half of the split (D5). A mod definition carries it from the hot pass; for base content the mirror file of that kind is read on first use and cached, so a headless session that never bakes anything never pays for the 3 MB item mirror. Pending patch ops are overlaid on a fresh copy on every call, so materialisation always sees the merged result and the cached source data stays untouched.

The boot pipeline now calls LoadAllBaseMirrors so the "first use" in that sentence is normally the content stage rather than whatever gameplay got there first. The lazy path below stays because it is also the correctness guarantee: whatever the eager pass missed is still read on demand.

Events

OnContentReloaded

Raised after every successful (re)load, so live systems can re-resolve definitions.