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:
+389
@@ -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.
|
||||
Reference in New Issue
Block a user