Table of Contents

Class AssetLibrary

Namespace
GrindFest.Content
Assembly
GrindFest.dll

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.

public static class AssetLibrary
Inheritance
object
AssetLibrary

Properties

CaptureRequester

Whether a synchronous load records LastRequester. Editor and development builds only: the walk is the one part of the miss path that is not a dictionary insert, and a shipped build has no reader for it.

LastRequester

Who asked for the address the last OnSyncLoad reported, as "Nearest.Caller (File.cs:12) <- Caller.Below (File.cs:34)", or null when CaptureRequester is off or no frame was available.

Why this exists: an address list cannot tell "this came from the boot pass over every ItemBehaviour" from "this came from one skill that ran once", and that difference is what decides whether an asset belongs in a tier, in a scope, or on the async path (CONTENT_SYNC_LOAD_AUDIT.md section 7). Without it a single session's list gets mistaken for a specification.

Why a stack walk instead of a parameter: the immediate caller is often a wrapper rather than the context that matters, and a miss is supposed to be rare enough that the walk is not a hot path. The target state is zero of them.

LoadedCount

Number of distinct assets currently held. Used by the "did we load the whole catalogue?" gate.

NotResidentCount

How many times code required an asset that nothing had acquired. The successor of SyncLoadCount: that one counted "loaded synchronously instead", this one counts "demanded something nobody acquired". The target is 0 in normal play, and unlike the old metric it cannot be satisfied by loading on the spot.

SyncLoadCount

Total synchronous loads (i.e. misses against the preload set) — the metric to drive to zero.

Methods

AddressesWithLabel(string, AssetTier?)

Every address in the manifest that carries label, optionally restricted to one tier (null = every tier).

This replaces Addressables.LoadAssetsAsync<T>(label), which has no content-directory equivalent: a content directory is looked up by key and is never queried by label. The information was always in the manifest — GetPreloaded<T>(AssetTier, string) already reads it to filter — so the query belongs beside the preload, and a caller that wants the assets acquires them through the ordinary door after.

Every label query left in the project is an editor-side inspector list (a ValueDropdown that needs real references), and those read this and then load from the AssetDatabase. That is not a workaround for the missing label load: it makes an inspector list stop depending on a content build being present.

GetOrLoadAsync<T>(string, AssetTier, CancellationToken)

The only way to load: waits for the asset (async, never blocks) and returns it, cache and reference counting included. A cache hit returns the resident object without touching Addressables at all.

There is deliberately no synchronous counterpart. The blocking path does not exist on WebGL - AsyncOperationBase.WaitForCompletion() throws on any platform without threads - so a sync loader can only ever fail there, and it fails quietly: the old GetOrLoad caught that throw, logged and returned null, which is how 34 item prefabs came back null in a browser on code that worked on the desktop. Two verbs are the whole API: Get<T>(string) for something a preload stage already owns, and this for everything else.

GetOrLoadSubAssetAsync<T>(string, string, AssetTier, CancellationToken)

A sub-object selected by name from a parent asset, loaded asynchronously — the only sub-asset load there is now that the synchronous door was deleted along with Addressables.

It needs no name matching of its own: a sub-asset is a content entry under parent[Name] like any other, so this is the ordinary load. That is what lets one .fbx hold several clips (Shield@Block01.fbx holds _Hit and _Hold) without either of them shadowing the model node that carries the same name.

GetOrLoad<T>(string, AssetTier)

Synchronous load. GONE — it throws on every platform, on purpose.

It is wrong twice over: WaitForCompletion throws where there is no JIT (WebGL), and where it does work it is the thing that forces every consumer to be prefetched up front, which is what the tier taxonomy was invented to paper over.

It throws on DESKTOP TOO deliberately. A door that only fails on one platform is worse than no door: the editor would keep working while the browser broke, and the miss would be found in a WebGL build instead of in the editor. Same code path, same failure, both platforms.

Replace with Get<T>(string) (must already be resident), GetOrLoadAsync<T>(string, AssetTier, CancellationToken) (the caller can await), or WhenResident<T>(string, Action<T>) (the caller cannot wait and must not block).

GetPreloaded<T>(AssetTier, string)

Every resident asset of tier that carries label, in catalogue order. Pass label as null for the whole tier.

The read side of PreloadTierAsync(AssetTier, IProgress<float>, CancellationToken): a caller that needs a whole kind (the class list, the room shapes) reads back what the loading screen already preloaded instead of issuing a load of its own. That is not politeness, it is the only thing that works on WebGL. A fresh Addressables.LoadAssetsAsync<T>(label) is a different operation even when the same assets are resident, because Addressables keys its operation cache on the location and the requested type (LocationCacheKey.Equals) - and the preload asks for Object, while a definition reader asks for CharacterClass. A brand new operation is not complete, and on WebGL WaitForCompletion throws for anything that is not already complete, so "preload it" on its own does not make a label load safe.

Get<T>(string)

Fail-fast accessor: a missing asset in a resident tier is a bug, not a hiccup.

ParseTier(string)
PreloadAsync(IReadOnlyList<string>, AssetTier, IProgress<float>, CancellationToken)

Preloads a set of addresses, reporting 0..1. Used by the loading pipeline (Boot tier).

PreloadTierAsync(AssetTier, IProgress<float>, CancellationToken)

Preloads every asset whose generated tier is tier. This is the "global" half of what a loading screen awaits — the contextual half comes from a scope (world source, area), which resolves to addresses the same way. Deliberately no hand-written preload list exists (plan §7.2, decision D20).

Release(string, int)
ReleaseAll()

Drops every entry (tests, and the teardown path on a full content reload).

ReleaseTier(AssetTier)

Releases a whole tier (e.g. leaving an area releases its Definition set).

RequireCompleted<T>(Task<T>, string)

Reads the result of a task that must already be complete, and fails loudly instead of waiting when it is not.

This is the "assert, do not block" companion to the counted doors above. It exists for the few synchronous entry points that are documented as never yielding — ContentDB.EnsureAssetsLoaded reads local files in the editor and on desktop, and returns before reaching this on a platform where a read would become a web request. Blocking there would be the very hang the invariant exists to prevent, so an incomplete task is a broken contract and must surface as one. (The result is read only after IsCompleted, which is why this is not a blocking read — it is the safe idiom, not an escape from the rule.)

RequireSubAsset<T>(string, string)

A sub-object by name that MUST already be resident — the Get<T>(string) of sub-assets, with the same contract and the same loud failure. Acquire it with GetOrLoadSubAssetAsync<T>(string, string, AssetTier, CancellationToken) (typically when the thing that owns it is set up) and this becomes a dictionary hit.

SubAssetKey(string, string)

The address of an object inside an asset: Assets/…/Drink Potion.fbx[Drink Potion].

A sub-asset is a content entry like any other under this key, which is why sub-asset lookup has no loading path of its own any more. The bracket is not new to this file either: RequireSubAsset<T>(string, string) has always built its error address this way, and it is what the declaration writes for a clip entry.

TryGetOrRequest<T>(string, out T)

"Give me this if it is resident; if it is not, start acquiring it so the next call finds it."

The third accessor, for the cases that are neither "must have it" (Get<T>(string), which throws) nor "just asking" (TryGet<T>(string, out T), which is silent). A miss is reported once per address and the work carries on without the piece — a generator loses one prop, a drop loses one item — instead of blocking a frame or throwing where the caller cannot recover.

What the warning lists is exactly the set some scope should acquire, so the list is a work queue rather than a mystery. Both platforms take this path identically: there is no version of this that works on desktop and fails in a browser.

TryGetSubAsset<T>(string, string, out T)

A sub-object selected BY NAME from a parent asset, for a caller that cannot wait. True only when it is already resident — nothing loads here.

The name is part of the address now (parent[Name]), not a second cache dimension: a sub-asset is a content entry like any other, so it is stored and found through the ordinary key path. That is what lets one .fbx hold several clips without a collision — Shield@Block01.fbx has _Hit and _Hold — and it is the same shape the declaration writes and the generator stores.

TryGet<T>(string, out T)
TryLoadBlocking<T>(string, out T)

Blocking acquire for development tooling only — cheats, test helpers, and editor-only inspector lists. Deliberately not a fourth door for gameplay; the three above stay the whole runtime contract.

It exists because those callers genuinely cannot await: a cheat is a UI button, a test spawn helper is called from inside a synchronous test body, and the editor quit tidy-up must not await the player loop. Their old code was Addressables.LoadAssetAsync(...).WaitForCompletion(), which is the same stall with a worse failure mode, so nothing gets slower here. What changes is that the failure is now legible (false plus a caller-reportable address) instead of a null nobody expected, and the load goes through the same provider as everything else.

Resident first: a caller that acquired the asset properly pays nothing.

WhenResident<T>(string, Action<T>)

"Give me this when it is ready" — the deferred form of Get<T>(string), and the answer to a synchronous miss.

onReady runs IMMEDIATELY (same frame, no await, no change in ordering) when the asset is already resident, which is the normal case for anything the boot stage or an active scope acquired. Only a miss pays for a load, and it pays with a callback instead of a blocked frame — which is what lets a spawner keep its "spawn now" shape without requiring the asset to be resident in advance.

A load that fails does not call back at all: the caller sees nothing spawn, and the error is loud. Silence here would be a monster that never appears with nothing in the log to explain it.

Events

OnSyncLoad

Raised on every synchronous load, with the address. The preload manifest is derived from this.