Phase 0 of the handoff plan, as a marketplace rather than a flat skills/ directory. Content moved out of the LyraResearch archive and depersonalised: addresses stay in the archive, recipes ship. - plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the six required fields; catalog.json as the harness-neutral source of truth and .claude-plugin/ as one adapter over it. - _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a declared file cannot silently miss the line rules. - ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata). - LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as the material the licence decision grew from. Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their fixtures with a clean baseline and 2 root files reaching the line rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
Patterns: asset loading and residency in a measured reference
A worked reading of one shipped project's loading path, from asset-manager startup to feature-plugin activation, audited as source.
Individual defects are in failure modes with detection recipes. This file is about the shape: which decisions determine load cost, which determine residency, and why those are two different reviews.
Markers: [measured] — read in source or config; [derived] — conclusion from measured facts; [open] — not answerable without a packaged build or engine source, and left open.
1. The five budgets, and the one that is not a budget
| Budget | What it is | Measured by |
|---|---|---|
| A. Disk / cook | package bytes shipped | build and chunk sizes |
| B. CPU RAM | object graph plus bulk data | memory report, object list |
| C. GPU VRAM | live render resources | platform GPU profiler |
| D. Streaming pool | a limit, not consumption | streaming stats |
| E. Net objects | actors, channels, bandwidth | network profiler |
This skill owns A and B.
The most common analytical error in this area is reading D as B. A pool setting grants permission to use memory. It never reports use, it never causes use, and lowering it does not free anything that is already resident.
2. Reference type is the largest single lever
Three mechanisms, routinely conflated:
| Mechanism | Effect |
|---|---|
| a reflected property, an explicit GC reference, a retained load handle | owns — keeps resident |
| a weak pointer, an object key | observes — keeps nothing |
| a soft pointer | a path, not a reference |
The third line is where reasoning usually fails. A soft pointer saves nothing by itself. Residency is decided by where the loaded result is stored — and a soft reference resolved into a strong property is a hard reference with extra steps.
The measured spread
From the dependency graph of the audited project — 3 874 packages, 16 639 hard edges, 9 333 soft edges [measured]:
| Asset | Packages pulled by loading it |
|---|---|
| pawn archetype with a hard class-and-object chain | 1 015 |
| weapon pickup definition | 800 |
| ability set | 121 |
| input config | 14 |
| action set referencing by identifier and soft pointer | 2 |
| playlist referencing by primary asset identifier | 2 |
[derived] Three orders of magnitude, from one decision made per property. That is the whole argument for treating reference type as architecture rather than as style — and the reason a "make it soft" refactor has to start from the closure measurement rather than from the property list.
Choosing
| Need | Reference |
|---|---|
| address before load; cook and bundle identity | primary asset identifier |
| optional, feature-scoped or async content | soft object or class pointer |
| required whenever the owner is loaded, and small | hard pointer |
| data selects an implementation class | class reference |
| owner-exclusive polymorphic fragment | instanced object |
Rule of thumb: a definition that is a catalog entry references by identifier or soft pointer; a definition that is an archetype may reference hard, but its closure must be measured and budgeted.
3. Bundles declare role, and their absence declares nothing
Put the load role beside the reference:
UPROPERTY(EditAnywhere, meta=(AssetBundles="Client"))
TSoftClassPtr<UUserWidget> WidgetClass;
UPROPERTY(EditAnywhere, meta=(AssetBundles="Client,Server"))
TSoftClassPtr<UGameplayAbility> AbilityType;
Then request bundles per runtime role at load time.
[measured] In the audited project the bundle vocabulary is small — client and client-plus-server annotations only — and one declared bundle name is requested on every load while appearing on no property in source at all. It is either satisfied entirely by content, or it is dead. [derived] A bundle name that no property claims is indistinguishable from a typo, and neither the compiler nor the cooker will say which it is.
Gates worth adopting:
- every soft reference has an intentional bundle annotation;
- server-irrelevant presentation is client-only;
- prediction-relevant gameplay classes are client-and-server;
- a missing annotation means "loaded whenever the owner loads" — decide it, do not default into it;
- cook rules control budget A, not budget B. A label asset with thousands of soft references and no hard ones determines what ships and retains nothing.
4. Startup: the shape that works, and the shape that only looks like it
The audited project has a well-formed startup-job framework [measured]: named jobs, weights, per-job timing logs, a progress delegate, and boot-timing scopes that integrate with the engine's trace.
[measured] It also has: a macro that never fills the load handle it is designed to return, a progress throttle whose comparison can never be true, and a progress receiver whose body is a comment.
[derived] Three independent layers, each individually harmless, combining into a startup with no progress reporting at all — inside a framework built to report it. This is the single most instructive thing in the audit, and the general form is worth memorizing: scaffolding is not evidence of function. Recipes: AL-02, AL-03.
What to take from it anyway:
- name every startup job and log its duration. Per-job timing is nearly free and it is the only thing that answers "what is slow at boot".
- wrap boot phases in the engine's timing scopes, so the data lands in the same trace as everything else.
- fail fatally on genuinely required global data. The audited project does this for its global data asset, with a comment explaining that a soft failure here would be harder to diagnose than a hard one [measured]. That is the correct call, and it is rare.
5. The synchronous load that dominates everything else
[measured] The root definition of a game mode is resolved with a synchronous load in the game thread, and its own authors marked it for async conversion. That definition holds a hard pointer to a pawn archetype, which holds hard references to the pawn class, its ability sets, its input config, its tag policy and its camera mode — all hard.
[derived] One line therefore pulls the closure measured at over a thousand packages, before the elegant asynchronous bundle pipeline underneath it runs at all. An async pipeline that executes after the bulk of the work is decoration.
The general lesson is a review question rather than a rule: for every synchronous load on a boot or transition path, what is its transitive closure? If nobody has measured it, the loading architecture is unverified regardless of how much of it is asynchronous.
[measured] Note the asymmetry that makes this easy to miss: on a client the same definition arrives by replication and is resolved by the net driver, so the synchronous cost exists on one side only, and profiling the wrong side finds nothing.
6. Handle ownership: the difference between loading and controlling
[measured] In the audited experience loader, every load handle is a local variable. The component stores none of them.
[derived] Three consequences, none of which produce an error:
- Cancellation is impossible. If the mode changes or the world dies mid-load, there is no reference to cancel.
- Release is impossible. Residency is held inside the asset manager rather than by the component, and unloading requires the API that nothing in the project calls.
- The load survives only incidentally — because the completion delegate is bound to the handle and the streamable manager keeps it alive until it fires.
[measured] The same codebase contains the correct discipline elsewhere: UI async actions cancel their handles on teardown, and an async helper mixin cancels in its destructor. The pattern was known; it was not applied at the place with the largest closure.
Take the shape from the UI code, not from the loader.
7. Retention: six owners, audited by name
Anything that stays resident is held by one of:
- reflected containers on long-lived subsystems;
- explicit GC references added by native code;
- retained load handles;
- delegate bindings capturing strong references;
- component and actor ownership chains;
- class default objects reached through class references.
The convenience loader that defaults to permanent
[measured] A helper resolves a soft pointer, and a boolean parameter defaulting to true adds the result to a reflected set. There is no removal API, no call passing false, and no clearing anywhere in the project.
[derived] Every casual call therefore loads synchronously and roots the asset for the lifetime of the process. The soft pointer was chosen to defer loading; the helper converts it into a permanent hard reference at the call site, invisibly, by default.
The design that avoids it: make retention explicit at the call site or default it to false, provide a paired release, make the retained set inspectable from a console command, and give retention an owner and a scope rather than assigning it to "the asset manager". Recipe: AL-05.
Both directions of the pointer error
Strong keys pin more than intended — a map keyed by actor pointers keeps every key actor and its whole presentation graph alive on one missed unregister.
Raw pointers in a non-reflected struct do the opposite: the collector cannot see them, so the values can be collected while referenced and the keys can dangle.
[derived] Both errors appear in real codebases, sometimes in the same one. Audit direction, not just presence.
8. Unloading, or the six "never"s
[measured] In the audited project, searched across all source:
| Mechanism | Occurrences |
|---|---|
| primary asset unload | 0 |
| bundle removal | 0 |
| explicit collection | 0 |
| async flush | 0 |
| memory trim | 0 |
The one forced collection in the project is in the loading-screen hide path [measured], and the experience loader's own teardown carries a comment admitting it deactivated without unloading [measured].
[derived] The residency table for this project has "never" in the release column for six of its rows. For a session-based game restarted between matches that is an acceptable engineering position. For a service title that swaps modes in place it is not — and the difference is a product decision that must be made explicitly rather than discovered from a memory graph.
The honest options are two: implement release, or state plainly that mode changes require travel. What does not work is claiming runtime modularity while the release column is empty.
Watch for collection ping-pong
[measured] Hiding the loading screen forces a full purge; showing it again synchronously loads the widget class that purge just collected. A forced collection at a transition boundary must not collect what the next transition immediately needs. Context: AL-07.
9. Failure handling is a memory concern
- a cancelled load must not run the success path;
- the result of an asynchronous activation must be read;
- partially loaded state must be releasable;
- an unbounded wait converts a stall into a hang;
- a loading screen with no reason string is not error handling.
[measured] In the audited project the cancel delegate invokes the same callback as completion, and the plugin-activation result parameter is never read. [derived] A cancelled or failed load therefore transitions to "loaded", and downstream code resolves soft references that are not there — where, by design, a failed resolve is silently skipped. The result is graceful degradation with no diagnostic, which is the most expensive kind.
One tool worth copying outright
[measured] The project ships console variables that inject artificial delay into mode loading. That is a reproducible way to exercise slow-load and cancellation paths without a network or a cold cache, and almost nobody builds one.
10. Measurement plan
Static first, because it is cheap and finds structural problems:
- build the dependency graph from the asset registry: direct hard and soft counts, transitive hard closure per definition, cross-plugin edges;
- rank definitions by closure and compare against their architectural layer — a catalog entry with a large closure is the finding;
- search for synchronous loads on boot and hot paths;
- inventory retention owners by type (§7).
Runtime, once a packaged build exists:
- memory reports before and after a full mode cycle;
- object lists and reference queries on suspected roots — root-graph evidence, not process memory;
- fifty to a hundred spawn, despawn and travel cycles, looking for monotonic growth;
- allocation attribution from the engine's profiler;
- the allocator's dangling-pointer mode when corruption is suspected.
Do not measure residency in the editor
[measured] Editor builds can load both role bundles, by an explicit editor branch in bundle selection — a fact the project's own comments acknowledge, noting the resulting hitches.
[derived] An in-editor memory profile therefore represents neither shipping role. Use it for relative structural comparison only; absolute residency requires a packaged build.
11. When to stop
Optimize loading and residency when boot or transition time is a product problem, when a platform memory ceiling is real and near, when growth is monotonic across a session, or when a specific closure is measurably oversized.
Do not restructure references on aesthetics. A fourteen-package closure is not a problem. A thousand-package synchronous closure on the boot path is. Measure first, and keep the measurement, so the next change has something to be compared against.
Provenance
The analysis and every number above come from a source and configuration audit of Epic's Lyra Starter Game on Unreal Engine 5.6, plus a dependency graph extracted from its asset registry through the editor.
Two boundaries applied to that reading and bound these claims: the engine's own asset manager, streamable manager and feature subsystem are not part of the project, so statements about their internals are inference from call sites rather than from source; and no profiler was run, so there are no timing or memory-footprint numbers here by construction — the package counts are static graph measurements.
Source addresses stay in the research archive that produced this skill. Entry identifiers in failure modes resolve back to the audited locations, so any specific claim can be produced on request.
Evidence boundary
One project, one engine version, one workspace. The structure is transferable; the specific defects are evidence, not guarantees about other versions. Re-run the detection recipes against your own tree before acting on any specific claim.