feat(skills): ship ue-design-skills bundle, licensing and delivery gate

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>
This commit is contained in:
ue-toolchain
2026-09-05 23:48:55 +07:00
parent 9e2194c298
commit dab3f35079
67 changed files with 21630 additions and 0 deletions
@@ -0,0 +1,298 @@
---
name: ue-asset-loading-and-memory
description: >-
Design or audit asset loading and CPU memory residency in Unreal Engine: the
asset manager and primary asset identifiers, asset bundles and cook rules,
hard versus soft versus weak reference semantics, streamable handle ownership,
synchronous load hitches, startup job sequencing, garbage-collection
reachability, retention leaks and unload paths. Use when reducing load times
or memory, choosing reference types, adding content-pulling definitions,
diagnosing hitches, or investigating objects that are never collected.
---
# UE asset loading and CPU memory
The invariant:
> An asset stays in memory because something reachable **owns** it. Loading is a
> policy decision; residency is an ownership fact. They are set by different code
> and must be reviewed separately.
Measured patterns from a reference product: [patterns](references/patterns.md).
Detection recipes: [failure modes](references/failure-modes.md).
Related skills: `ue-data-driven-architecture`, `ue-modular-gameplay`,
`ue-streaming-and-platform-budgets`, `ue-runtime-allocation-and-caching`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. Separate the five budgets before optimizing anything
| Budget | What it is | Measured by |
|---|---|---|
| A. Disk / cook | package bytes shipped | build size, chunk sizes |
| B. CPU memory | object graph plus bulk data | memory report, object list |
| C. GPU memory | 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. C and D belong to
`ue-streaming-and-platform-budgets`; E to `ue-runtime-allocation-and-caching`.
**The most common analytical error is treating D as B.** A pool setting grants
permission to use memory. It never reports use.
---
## 2. Reference semantics decide load cost
Three mechanisms, routinely conflated:
| Mechanism | Effect |
|---|---|
| reflected property, manual reference collection, a retained streamable handle | **owns** — keeps resident |
| weak pointer, object key | **observes** — keeps nothing |
| soft object or class pointer | **a path, not a reference** |
The third line is where most reasoning fails. **A soft pointer saves nothing by
itself.** Residency is decided by where the loaded result is stored — a soft
reference resolved into a reflected property is a hard reference with extra
steps.
### Measure the transitive closure, not the direct count
Direct reference counts are misleading. What matters is how many packages load
when this one loads. In the audited reference the same architectural layer
spanned three orders of magnitude depending on reference type alone: an archetype
holding hard references pulled over a thousand packages, while a catalog entry
addressing by identifier pulled two.
That single contrast is the argument for treating reference type as
architecture rather than style. Recipe: AL-01.
### 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 softly; a definition that is an **archetype** may reference hard — but its
closure must be measured and budgeted, not assumed.
---
## 3. Bundles express role, not grouping
Declare the load role beside the reference, then request bundles per runtime role
at load time.
Gates:
- every soft reference carries an intentional bundle tag;
- server-irrelevant presentation is client-only;
- prediction-relevant gameplay classes are client and server;
- **a missing bundle tag means "always loaded with the owner"** — decide it, do
not default into it;
- cook rules control budget **A**, never budget **B**.
A label asset with thousands of soft references and no hard references determines
what ships and retains nothing at runtime. Do not read cook rules as residency.
Watch for a declared bundle name that no property ever uses: it is requested at
load time, matches nothing, and looks like a working tier. Recipe: AL-02.
---
## 4. Startup discipline
### Never synchronously load a graph you have not measured
The dominant boot hitch in a data-driven project is one synchronous load whose
hard closure is large. An elegant async bundle pipeline that runs *after* it is
decoration.
- [ ] every synchronous load on the boot path is justified;
- [ ] its transitive closure is measured, not assumed;
- [ ] an async alternative was considered, with an explicit wait state;
- [ ] the loading screen carries a reason string;
- [ ] cancellation is distinguishable from completion.
### Startup jobs must own handles and report truthfully
A progress bar that never moves is a bug, not a cosmetic issue — it hides which
job is slow. Three independent layers can each break it: a macro that never
fills the handle, a throttle comparison with swapped operands, and an empty
progress receiver. Any one of them is enough. Recipe: AL-03.
### Global data assets need bundles too
Soft class pointers on a globally-loaded data asset with **no** bundle tags load
synchronously at first use — typically in the frame of first damage. Tag them and
preload them with the mode. Recipe: AL-04.
---
## 5. Retention: what actually keeps objects alive
Audit six owners explicitly: reflected containers on long-lived subsystems;
manual reference collection in native code; retained streamable handles; delegate
bindings capturing strong references; component and actor ownership chains; class
default objects reached through class references.
### A convenience loader must not default to permanent
The worst shape in this area is a helper that loads an asset and adds it to a
reflected set, with the keep-in-memory parameter **defaulting to true** and no
release API anywhere. Every casual call then synchronously loads *and permanently
roots* the asset. The soft pointer was chosen for deferred loading; the API turns
it into a process-lifetime hard reference.
Required design: the flag is explicit at every call site or defaults to false; a
paired release exists and is used; the retained set is inspectable from a console
command; retention has an owner and a scope. Recipe: AL-05.
### Strong keys pin object graphs — and raw pointers lose them
```text
map<strong actor pointer, strong value> one missed unregister pins the actor,
its meshes, materials, sounds, effects
map<raw actor pointer, non-reflected struct of object pointers>
garbage collection cannot see the
values; the key can dangle
```
Both errors can coexist in one codebase. **Audit direction as well as presence.**
Prefer object keys with explicitly-owned values plus a periodic prune. Recipe:
AL-06.
---
## 6. Unloading must exist before you claim modularity
If a project cannot answer "what is released when a mode ends", its memory
profile only grows. Either implement release, or state plainly that mode changes
require map travel — that is a legitimate scope decision, and leaving it
undefined is not.
The measurement is blunt and takes one command: count unload calls, bundle
releases and explicit collection requests across the whole project. In the
audited reference every one of those counts was **zero**. Recipe: AL-07.
### Avoid collection ping-pong
A forced collection at a transition boundary must not collect what the next
transition immediately needs. Options: keep transition-critical classes rooted,
defer collection past the next load, or do not force it and let the budget drive
it.
---
## 7. Failure handling changes memory behaviour
- a cancelled load must not run the success path;
- asynchronous results must be read, not discarded;
- partially loaded state must be releasable;
- waiting without a timeout converts a stall into a hang;
- a permanent loading screen with no reason string is not error handling.
The sharpest instance: binding the cancel delegate to the **same callback** as
completion. A cancelled load then reports success, downstream code dereferences
unloaded assets, and each of those dereferences is individually silent. Recipes:
AL-08, AL-09.
---
## 8. Review checklist
### References
- [ ] reference type chosen per lifecycle and documented in metadata;
- [ ] transitive hard closure measured for every archetype-level definition;
- [ ] no soft reference resolved into an unbounded reflected cache;
- [ ] bundle tags on all soft references, and every requested bundle is used;
- [ ] cross-plugin edges intentional and acyclic.
### Loading
- [ ] no unmeasured synchronous load on boot or in a hot path;
- [ ] async loads own their handles and can be cancelled;
- [ ] progress reporting is verified to move, not merely implemented;
- [ ] cancellation and failure paths are distinct from success;
- [ ] global data assets are bundled, not lazily loaded at first use.
### Retention
- [ ] every long-lived container has an owner, a bound and a prune policy;
- [ ] strong keys justified; object keys used where identity suffices;
- [ ] non-reflected object pointers eliminated or documented as weak by design;
- [ ] register and unregister pairs verified across teardown paths;
- [ ] a release API exists for anything deliberately kept resident.
---
## 9. Measurement plan
Static first — 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;
3. search for synchronous loads on boot and hot paths;
4. inventory retention owners by type.
Runtime, once a packaged build exists: memory reports before and after a mode
cycle; object listings on suspected roots — root-graph evidence, not process
memory; fifty to a hundred spawn and travel cycles looking for monotonic growth;
an allocation profiler for attribution.
### Do not measure residency in the editor
Editor builds commonly load **both** client and server bundles, so an in-editor
memory profile represents neither shipping role. Residency numbers require a
packaged build; use the editor for relative structural comparison only. Recipe:
AL-10.
---
## 10. When to stop
Optimize loading and residency when boot or transition time is a product problem,
a platform ceiling is real and near, growth is monotonic across a session, or 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 can be compared against
it.
---
## Provenance
The findings come from a source and dependency-graph audit of Epic's Lyra Starter
Game on Unreal Engine 5.6. The engine's own asset manager and streamable manager
are not part of that project, so every claim about their internals is inferred
from call sites and marked as such rather than presented as measured.
No profiler was run and no timing was captured: this material is structural by
construction, and it deliberately contains no performance numbers. What it
contains is counts — of call sites, of unload paths, of packages in a closure —
which is a different kind of evidence and a more portable one.
Source addresses stay in the research archive. Each entry carries a stable
identifier (`AL-01` and up) resolving back to the audited location there.
## Evidence boundary
One project, one engine version. Binary assets were not read, so the contents of
any specific closure are structural rather than byte-measured. Re-run the recipes
against your own tree; the counts quoted show the shape of a contrast, not a
target.
@@ -0,0 +1,389 @@
# Failure modes: asset loading and CPU memory
Ten ways a project loads more than it meant to, keeps it forever, and reports
nothing.
The shared property here is different from the other skills in this bundle, and
it is worth stating because it changes what the recipes look like: **memory
defects are not events.** There is no moment at which something goes wrong. An
asset is loaded because a reference exists; it stays because a reference still
exists. Both are correct behaviour at every instant, and the failure is a
property of an aggregate that no single frame contains.
That has two consequences for detection. First, **counting is the primary
instrument** — call sites, unload paths, packages in a closure. Second, a count of
**zero** is often the finding: no release API, no unload call, no bundle tag.
Several recipes below are therefore searches you expect to come back empty, and
the empty result is the evidence rather than the absence of it.
Recipes use `rg` from a project source root, and were executed against the
audited project while this file was written.
---
## References
### AL-01 - A reference type chosen by convenience, costing three orders of magnitude
**Mechanism.** A definition holds hard references to the things it selects — a
class, a data asset, a package of capabilities — because hard references are
simpler and always resolve. Loading the definition therefore loads its entire
transitive closure.
**Why it is silent.** Everything works, immediately and correctly. Hard
references never fail, never arrive late and never need a fallback. The cost is
paid once, at load, in a place nobody attributes to this asset.
**Why the obvious check misses it.** Reviewing the definition shows a handful of
properties — five or six references, all necessary. The cost is not the direct
count but the closure, and the closure lives in assets this file never mentions.
Nothing at the declaration hints at its size.
**Symptom.** A boot or transition hitch attributed to "the level" or "shaders".
In the audited reference the same architectural family ranged from **two**
packages when addressing by identifier to **over a thousand** when holding a hard
chain — a difference produced entirely by reference type.
**Detect.** Direct counts are not the measurement; build the closure. From
source you can only find the candidates:
```bash
rg -n "TObjectPtr<|TSubclassOf<" --glob "*.h" . | rg -i "definition|data|archetype"
rg -n "TSoftObjectPtr<|TSoftClassPtr<" --glob "*.h" . | rg -i "definition|data"
```
Then, from the editor or a registry dump, compute the transitive hard closure per
definition and rank by size. **Compare each against its architectural layer**: a
catalog entry with a large closure is the finding; an archetype with one may be
correct and budgeted.
**Guardrail.** Write the intended reference type into the property metadata and
validate it. Re-measure the closure of every archetype-level definition when it
changes; a closure without a recorded baseline cannot be reviewed.
---
### AL-02 - A bundle name that no property uses
**Mechanism.** A bundle tier is declared as a constant and requested at load time
alongside the role bundles. No property in the codebase is annotated with it.
**Why it is silent.** Requesting a bundle that matches nothing is not an error.
The load succeeds, the other bundles arrive, and the tier appears to be part of
the loading strategy.
**Why the obvious check misses it.** The constant is declared, referenced at the
request site, and named meaningfully. "Is this bundle used?" answers yes at two
places. The absent half is the **annotation**, and nothing connects a request to
the properties it is meant to gather.
**Symptom.** No runtime symptom at all. The cost is a false model: the team
believes there is an equipped-content tier, plans around it, and the loading
strategy has one fewer dimension than it appears to.
**Detect.** Compare declared bundle names against annotated ones:
```bash
rg -n "AssetBundles=\"[^\"]*\"" --glob "*.h" . -o | sed 's/.*AssetBundles=//' | sort -u
rg -n "BundlesToLoad.Add|LoadStateClient|LoadStateServer|FName\(TEXT\(\"" --glob "*.cpp" . \
| rg -i "bundle"
```
Any name in the second list and not the first is requested and unpopulated. In
the audited project one such tier is requested on every load regardless of net
mode, and appears in no annotation in the entire source tree.
**Guardrail.** Assert at startup that every requested bundle name matches at
least one annotated property, or remove the tier. A bundle nobody fills is a
plan, not a mechanism.
---
## Startup
### AL-03 - Progress scaffolding where every layer is independently broken
**Mechanism.** A startup framework reports per-job progress. Three things must
work: the job must hand back a load handle, a throttle must permit an update, and
a receiver must render it.
**Why it is silent.** A progress bar that does not move looks like a fast load or
a coarse-grained one. There is no error state for "progress was never reported",
and the framework's logs still print per-job timings, so it looks instrumented.
**Why the obvious check misses it.** Each layer is individually plausible. The
macro compiles and runs the job. The throttle has a sensible-looking comparison.
The receiver is a real function with a real name. Only reading all three together
shows that the handle is never assigned, the comparison can never be true, and
the receiver's body is a comment.
**Symptom.** No visible progress during startup, so the slowest job is unknown.
The team optimizes what it guesses rather than what it measured.
**Detect.** Check the three layers separately, and expect each to look fine:
```bash
# 1. does anything assign the out-parameter handle?
rg -n -B4 -A8 "TSharedPtr<FStreamableHandle>& \w+" --glob "*.h" --glob "*.cpp" .
# 2. is the throttle comparison the right way round?
rg -n -B2 -A6 "LastUpdate|LastReport" --glob "*.h" --glob "*.cpp" .
# 3. does the receiver have a body?
rg -n -A4 "UpdateInitial\w*Percent|::UpdateProgress" --glob "*.cpp" .
```
In the audited project the throttle reads `LastUpdate - Now > interval`, where
the elapsed value is monotonically increasing and the stored value starts at
zero — so the difference is never positive and the branch is unreachable.
**Guardrail.** Test that progress fires at all before trusting it. A callback
that has never been observed to run is indistinguishable from one that cannot.
---
### AL-04 - Global data with soft references and no bundle tags
**Mechanism.** A globally-loaded data asset holds soft class pointers to effects
or classes used across the game. The asset is loaded at startup; its soft targets
are not, because nothing tags them into a bundle.
**Why it is silent.** The pointers resolve on demand and everything works. The
first resolution is a synchronous load, and it lands in whatever frame first
needs it — commonly the first damage event, which is already a busy frame.
**Why the obvious check misses it.** The asset is loaded at startup, which
answers "is the global data preloaded?" with yes. Whether its *contents* are
preloaded is a different question, decided by annotations that are absent — and
absent annotations look exactly like properties that do not need them.
**Symptom.** A hitch at first damage, first dynamic tag, or first use of any
global class. Reproduces once per session, which makes it easy to dismiss.
**Detect.** Find globally-loaded data assets and check their soft properties for
tags:
```bash
rg -n -A20 "class \w*GameData" --glob "*.h" . | rg "TSoftClassPtr|TSoftObjectPtr|AssetBundles"
```
Soft properties with no bundle annotation on a startup-loaded asset is the
finding. Then find where they first resolve:
```bash
rg -n "GetSubclass\(|GetAsset\(" --glob "*.cpp" . | rg -i "gamedata"
```
**Guardrail.** Tag them and load them with the mode. A soft reference on a global
asset without a bundle is a deferred synchronous load with an unpredictable
trigger.
---
## Retention
### AL-05 - A keep-in-memory pool with no way out
**Mechanism.** A convenience loader resolves a soft pointer, and adds the result
to a reflected set so it stays alive. The keep-in-memory parameter **defaults to
true**. No removal API exists.
**Why it is silent.** It is a cache doing its job. Every call is faster than the
last, nothing is ever missing, and the set has no size at which it complains.
**Why the obvious check misses it.** The parameter is visible in the signature
and reads as a considered choice — which it is, at the declaration. At every call
site it is invisible, because it is defaulted. Reviewing a call shows a load; the
retention is in the default argument of a function in another file.
**Symptom.** Residency that only grows across a session, unaffected by mode
changes, map travel or anything else. Because the growth is monotonic and
attributable to nothing, it is usually first noticed as "we are over budget"
rather than as a leak.
**Detect.** Count additions against removals, and read the default:
```bash
rg -n "LoadedAssets|KeptAssets|RetainedAssets" --glob "*.h" --glob "*.cpp" .
rg -n "bKeepInMemory" --glob "*.h" . | head
```
In the audited project the set is added to at one site, read at two — both inside
a dump command — and **never removed from anywhere**, with the flag defaulting to
true and no call site in the project passing false.
**Guardrail.** Default to false, or make the flag explicit at every call site.
Ship a paired release API and a console command that prints the set's size. A
cache without an eviction policy is a leak with a nicer name.
---
### AL-06 - Strong keys pin graphs; raw keys lose them
**Mechanism.** Two opposite errors in the same area. A map keyed by a **strong
object pointer** keeps its key alive, so one missed unregister pins an actor and
everything it owns. A map holding object pointers inside a **non-reflected**
struct is invisible to garbage collection, so the values can be collected while
still referenced.
**Why it is silent.** The first has no symptom until memory is measured — the
actor is dead in gameplay terms and alive in memory. The second has no symptom
until collection happens to run at the wrong moment, which is rare and
non-deterministic.
**Why the obvious check misses it.** Both look like ordinary containers. The
difference between a reflected and a non-reflected struct is one macro, and the
difference between a strong and a weak key is one wrapper type. Neither reads as
a memory decision at the declaration site.
**Symptom.** Actors that never disappear from a memory report, or — from the
other error — a crash on a pointer that was valid a moment ago. Both are usually
investigated far from the container.
**Detect.** Audit direction as well as presence:
```bash
# strong keys
rg -n "TMap<TObjectPtr<\w+>|TMap<A\w+\*" --glob "*.h" .
# object pointers outside the reflection system
rg -n -B6 "TArray<U\w+\*>|U\w+\* \w+;" --glob "*.h" . | rg -v "UPROPERTY"
```
Both patterns appearing in one codebase is common and is worth stating plainly in
a review: they are not variants of one mistake, they are opposite mistakes, and a
fix for one does not address the other.
**Guardrail.** Prefer object keys with explicitly-owned values plus a periodic
prune. Every object pointer that must survive collection is reflected; every one
that must not is weak. There is no third category.
---
### AL-07 - No unload path anywhere
**Mechanism.** Content is loaded per mode through bundles. Nothing releases it —
no unload call, no bundle removal, no explicit collection request.
**Why it is silent.** For a session-based game with a process restart between
matches, it is correct and cheap. The absence only becomes a defect when modes
change within one process, which is a product decision made later.
**Why the obvious check misses it.** There is nothing to see. Reviewing the
loading code finds a complete, well-built loading path; the absence of a
symmetric release path is not a line anyone reads. The authors of the audited
project recorded it themselves, in comments, and shipped it.
**Symptom.** Memory that never returns to baseline across mode changes. The first
mode's content is resident for the whole session alongside the second's.
**Detect.** One command, and the expected answer is a row of zeros:
```bash
for p in UnloadPrimaryAsset bRemoveAllBundles CollectGarbage FlushAsyncLoading TrimMemory; do
printf "%-24s %s\n" "$p" "$(rg -c "$p" --glob '*.cpp' --glob '*.h' . | wc -l)"
done
```
In the audited project every one of those is **zero**. That table is the finding,
and it takes ten seconds to produce in any project.
**Guardrail.** Either implement release, or write down that mode changes require
map travel. An undefined middle is what grows.
---
## Failure handling
### AL-08 - Cancellation bound to the completion callback
**Mechanism.** An asynchronous load binds a completion delegate, and binds the
**same** delegate to cancellation.
**Why it is silent.** Cancellation is rare — it needs a mode change or a world
teardown mid-load. When it does happen, the system reports success and proceeds,
and every downstream consumer of a missing asset fails silently in its own way.
**Why the obvious check misses it.** Both delegates are bound, which is more
than most code does. The lambda is short and reads as deliberate. Nothing
distinguishes "handle cancellation" from "handle cancellation *correctly*" at the
binding site.
**Symptom.** A mode reports loaded with partially loaded content. Downstream,
unloaded classes resolve to null and are skipped without logging, so the result
is a mode missing several features and no diagnostic anywhere.
**Detect.** Compare the two bindings:
```bash
rg -n -B6 -A6 "BindCancelDelegate" --glob "*.cpp" .
```
If the cancel lambda executes the completion delegate, that is this defect. In
the audited project it does, in three lines, immediately below the completion
binding.
**Guardrail.** Cancellation enters a distinct state — failed, or cancelled — that
the loading screen and the consumers can observe. Success is a claim; make it
provable.
---
### AL-09 - An asynchronous result that is never read
**Mechanism.** A plugin or asset load completes with a result parameter carrying
success or failure. The callback decrements a counter and ignores the parameter.
**Why it is silent.** The counter reaches zero either way, so the pipeline
completes. A failed load is indistinguishable from a successful one at every
point downstream.
**Why the obvious check misses it.** The callback has the right signature and is
bound correctly. An unused parameter in a delegate signature produces no warning,
because the signature is imposed by the API rather than chosen.
**Symptom.** A feature that silently does not activate, in a build where one of
its assets failed to load. The failure is attributed to the feature.
**Detect.** Find completion callbacks whose result parameter is unused:
```bash
rg -n -A8 "::On\w*LoadComplete\(const \w+::\w+& Result\)" --glob "*.cpp" . \
| rg -c "Result"
```
A count of one — the signature only — is the finding.
**Guardrail.** Read the result, log the failure with the identifier that failed,
and enter a failed state. An ignored result is a decision to be surprised later.
---
## Measurement
### AL-10 - Measuring residency in the editor
**Mechanism.** The editor loads both client and server bundles, because it must
be able to run either role.
**Why it is silent.** The measurement completes and produces a number. The number
is real; it just describes a configuration that ships to nobody.
**Why the obvious check misses it.** It is intentional and correct — nothing to
find in the memory system. The inflating condition is a boolean expression in the
bundle-selection code, and nobody profiling memory reads the loading policy.
**Symptom.** Memory budgets built on editor numbers, wrong in a direction that
feels safe until a platform limit is real.
**Detect.** Read the bundle-selection condition before trusting any in-editor
number:
```bash
rg -n -B2 -A6 "bLoadClient|bLoadServer|LoadStateClient" --glob "*.cpp" . | rg "GIsEditor"
```
An editor branch that unions both sets means editor residency is directional
only. The audited project does this in two places, and one of them carries a
comment from its authors noting the resulting hitching.
**Guardrail.** Residency numbers require a packaged build of the target role. Use
the editor for relative structural comparison, and say so whenever an in-editor
number is quoted.
@@ -0,0 +1,360 @@
# 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.