ecd87ac96d
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>
361 lines
15 KiB
Markdown
361 lines
15 KiB
Markdown
# 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](failure-modes.md) 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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
1. **Cancellation is impossible.** If the mode changes or the world dies mid-load,
|
|
there is no reference to cancel.
|
|
2. **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.
|
|
3. **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:
|
|
|
|
1. reflected containers on long-lived subsystems;
|
|
2. explicit GC references added by native code;
|
|
3. retained load handles;
|
|
4. delegate bindings capturing strong references;
|
|
5. component and actor ownership chains;
|
|
6. 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:
|
|
|
|
1. build the dependency graph from the asset registry: direct hard and soft
|
|
counts, transitive hard closure per definition, cross-plugin edges;
|
|
2. rank definitions by closure and compare against their architectural layer —
|
|
a catalog entry with a large closure is the finding;
|
|
3. search for synchronous loads on boot and hot paths;
|
|
4. 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](failure-modes.md) 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.
|