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:
@@ -0,0 +1,425 @@
|
||||
---
|
||||
name: ue-modular-gameplay
|
||||
description: >-
|
||||
Design or review modular gameplay in Unreal Engine: experience definitions,
|
||||
feature plugins, reusable action sets, feature actions, actor extension
|
||||
handlers, component init-state chains, primary asset bundles, and runtime
|
||||
activation and deactivation. Use when adding game modes, seasonal or
|
||||
downloadable features, modular components, abilities, input or UI, or when
|
||||
fixing initialization-order and feature teardown bugs.
|
||||
---
|
||||
|
||||
# UE modular gameplay
|
||||
|
||||
The invariant:
|
||||
|
||||
> A feature may select and attach behaviour without modifying the receiving base
|
||||
> class, but it owns every resource it adds and must remove exactly that
|
||||
> resource.
|
||||
|
||||
Measured patterns from a reference product: [patterns](references/patterns.md).
|
||||
Eighteen detection recipes for silent modularity failures:
|
||||
[failure modes](references/failure-modes.md).
|
||||
|
||||
Read the failure modes before adopting this architecture. Nearly all of them are
|
||||
teardown defects, and teardown defects share one property: **activation is what
|
||||
gets tested, so the half that is broken is the half nobody exercises.**
|
||||
|
||||
Related skills: `ue-data-driven-architecture`, `ue-input-architecture`,
|
||||
`ue-ui-architecture`, `ue-gas-architecture`, `ue-multiplayer-authority`.
|
||||
|
||||
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Separate the four layers
|
||||
|
||||
```text
|
||||
Playlist / session entry
|
||||
└─ Experience definition
|
||||
├─ feature plugins to activate
|
||||
├─ reusable action sets
|
||||
├─ mode-specific actions
|
||||
└─ default pawn data
|
||||
|
||||
Feature data asset
|
||||
└─ lifecycle and asset scanning of one plugin
|
||||
|
||||
Action set
|
||||
└─ reusable composition slice
|
||||
|
||||
Feature action
|
||||
└─ one apply/rollback operation
|
||||
```
|
||||
|
||||
Do not merge them:
|
||||
|
||||
- the playlist owns map, session and menu metadata, not gameplay composition;
|
||||
- the experience owns one runtime composition, not plugin lifecycle policy;
|
||||
- the feature data asset owns plugin registration and scanning, not a game mode;
|
||||
- an action set expresses reuse; it is not an inherited base experience;
|
||||
- an action performs one focused mutation and owns its rollback state.
|
||||
|
||||
### Prefer composition over experience inheritance
|
||||
|
||||
A mode should read as a sum:
|
||||
|
||||
```text
|
||||
SharedInput + StandardComponents + StandardHUD + ModeDelta
|
||||
```
|
||||
|
||||
not as a hidden defaults chain:
|
||||
|
||||
```text
|
||||
Base -> Shooter -> Team -> TeamDeathmatch
|
||||
```
|
||||
|
||||
Flat action sets keep dependencies, asset loading and teardown auditable. A
|
||||
reference product measured for this skill goes further and *forbids* the second
|
||||
form in validation, pointing authors at composition instead.
|
||||
|
||||
---
|
||||
|
||||
## 2. Experience loading is a state machine
|
||||
|
||||
```text
|
||||
Unloaded
|
||||
→ LoadingAssets
|
||||
→ LoadingGameFeatures
|
||||
→ ExecutingActions
|
||||
→ Loaded
|
||||
→ Deactivating
|
||||
→ Unloaded
|
||||
```
|
||||
|
||||
Each transition needs an explicit completion criterion. Never track this with a
|
||||
handful of booleans — `bAssetsReady && bPluginsReady && bActionsDone` cannot
|
||||
express "failed", and failure is the state you will need most.
|
||||
|
||||
### The server selects; every peer realises locally
|
||||
|
||||
Replicate the selected definition, never the result of loading it:
|
||||
|
||||
```text
|
||||
server selects experience ID
|
||||
→ replicated definition reaches the client
|
||||
→ server and client run the same load pipeline
|
||||
→ role-specific bundles and actions decide their local work
|
||||
```
|
||||
|
||||
This is "replicate intent, realise presentation locally" applied to composition.
|
||||
|
||||
### Document selection precedence
|
||||
|
||||
If an experience can come from matchmaking, a URL, developer settings, the
|
||||
command line, world settings and a default, define the exact precedence, log the
|
||||
winning source, validate the asset ID before loading, and provide an explicit
|
||||
fallback.
|
||||
|
||||
### Delay only for a named dependency
|
||||
|
||||
Deferring selection by one frame so that startup settings can initialise is
|
||||
legitimate **because the dependency has a name**. A next-tick timer with no
|
||||
identified prerequisite is initialization-order debt with a timer wrapped around
|
||||
it. Recipe: MG-12.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bundle and role boundaries
|
||||
|
||||
Collect the experience plus every action set as primary assets, then request
|
||||
named bundles: a shared bundle always, the client bundle when a client exists,
|
||||
the server bundle when authority exists.
|
||||
|
||||
Mark soft references at their declaration:
|
||||
|
||||
```cpp
|
||||
meta=(AssetBundles="Client")
|
||||
meta=(AssetBundles="Client,Server")
|
||||
```
|
||||
|
||||
| Action target | Expected role |
|
||||
|---|---|
|
||||
| HUD, layout, input icons | Client |
|
||||
| scoring authority, bots, team assignment | Server |
|
||||
| replicated scoring component | Client + Server |
|
||||
| ability classes and effects needed for prediction | usually both |
|
||||
|
||||
**A client/server flag on an action does not guarantee correct cook bundles.**
|
||||
Validate both, separately.
|
||||
|
||||
### Editor bundle loading is not shipping
|
||||
|
||||
A policy that loads client data when not a dedicated server and server data when
|
||||
not a client-only build loads **both** in the editor. Every memory and load-time
|
||||
figure taken in-editor therefore describes a configuration that ships to nobody.
|
||||
Use it for direction of change only. Recipe: MG-18.
|
||||
|
||||
---
|
||||
|
||||
## 4. Extend actors through receivers and extension handlers
|
||||
|
||||
Do not scan the world for actors and patch them. Use the component manager's
|
||||
extension protocol:
|
||||
|
||||
```text
|
||||
receiver actor registers itself
|
||||
action registers an extension handler for a target class
|
||||
manager invokes the handler for existing AND future receivers
|
||||
returned request handle owns the subscription
|
||||
```
|
||||
|
||||
The receiver lifecycle is symmetric and belongs in the base class:
|
||||
|
||||
```text
|
||||
PreInitializeComponents : add receiver
|
||||
BeginPlay : send ready events
|
||||
EndPlay : remove receiver
|
||||
```
|
||||
|
||||
Note what this buys: base gameplay classes register as receivers and **know
|
||||
nothing about features**. If a class does not inherit the modular base, those
|
||||
three calls are written by hand — and forgetting the third is a leak with no
|
||||
symptom until the second activation.
|
||||
|
||||
### Use semantic readiness events
|
||||
|
||||
A generic "extension added" event means only that the actor is known to the
|
||||
manager. It may not yet have its pawn data, ability system, controller or input.
|
||||
Emit named events at the real dependency boundary — abilities ready, bind inputs
|
||||
now, actor ready — and have handlers accept both the generic event and the
|
||||
semantic one, so activation order stops mattering. Recipe: MG-13.
|
||||
|
||||
### Retain request handles
|
||||
|
||||
Handler registration and component requests return ownership handles that are
|
||||
reference counted. **Dropping the handle is the unsubscribe operation**; there is
|
||||
no explicit removal call. Storing them per activation context is therefore not
|
||||
bookkeeping, it is the only teardown mechanism that exists.
|
||||
|
||||
---
|
||||
|
||||
## 5. Use an init-state chain
|
||||
|
||||
Networked actor composition has non-deterministic arrival order. Replace
|
||||
scattered checks in `BeginPlay`, possession callbacks and replication callbacks
|
||||
with a monotonic chain:
|
||||
|
||||
```text
|
||||
Spawned → DataAvailable → DataInitialized → GameplayReady
|
||||
```
|
||||
|
||||
- **Spawned** — the actor and component exist.
|
||||
- **DataAvailable** — required replicated and config data exist.
|
||||
- **DataInitialized** — every participating feature reached DataAvailable and
|
||||
cross-feature initialisation ran.
|
||||
- **GameplayReady** — safe for active gameplay.
|
||||
|
||||
The load-bearing barrier is "have all features on this actor reached
|
||||
DataAvailable", which synchronises components without any of them naming the
|
||||
others.
|
||||
|
||||
### Rules
|
||||
|
||||
- states only advance; they reset with the actor's lifecycle, not by backtracking;
|
||||
- every transition has an observable prerequisite;
|
||||
- replication callbacks re-enter the same check rather than duplicating init;
|
||||
- feature-specific work runs once, at a named transition;
|
||||
- test server, owning client and simulated proxy separately — a proxy never
|
||||
acquires a controller, and a gate that waits for one deadlocks it while working
|
||||
perfectly in a single-process test.
|
||||
|
||||
---
|
||||
|
||||
## 6. Action design contract
|
||||
|
||||
An action class implements behaviour; its instance stores targets and
|
||||
configuration. A world-aware base action should filter to game worlds, handle
|
||||
worlds that already exist at activation, subscribe for future ones, and release
|
||||
its world delegates on deactivation — leaving resource tracking to the concrete
|
||||
action.
|
||||
|
||||
### State must be keyed by activation context
|
||||
|
||||
One action object can be active in several contexts at once — client and server
|
||||
in a single-process test are the common case. **State in plain member fields is a
|
||||
cross-context leak.** Key it:
|
||||
|
||||
```cpp
|
||||
struct FPerContextData { /* ... */ };
|
||||
TMap<FGameFeatureStateChangeContext, FPerContextData> ContextData;
|
||||
```
|
||||
|
||||
Recipe: MG-11.
|
||||
|
||||
### One responsibility per action
|
||||
|
||||
Good: add components; add abilities; add input binding; add input mapping
|
||||
context; add widgets; add cue path.
|
||||
|
||||
Bad: "set up shooter mode".
|
||||
|
||||
A focused action has a testable rollback and can be reused in an action set.
|
||||
|
||||
### The mandatory ownership ledger
|
||||
|
||||
Write this table **before** implementing any action:
|
||||
|
||||
| Added resource | Stored ownership | Removal |
|
||||
|---|---|---|
|
||||
| extension callback | request handle | release the handle |
|
||||
| component | component request | remove the owned component |
|
||||
| ability, effect, attribute set | granted handles | revoke the handles |
|
||||
| input bind | bind handles per pawn | remove those exact binds |
|
||||
| input mapping context | player + context | remove from the same subsystem |
|
||||
| widget extension | extension handle | unregister |
|
||||
| layout | widget or layout handle | deactivate and remove |
|
||||
| global delegate | delegate handle | unbind |
|
||||
|
||||
**If the middle column is blank, reject the action.** Every teardown failure in
|
||||
the measured reference is a blank middle column that shipped. Recipes: MG-01
|
||||
through MG-05.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deactivation is not optional
|
||||
|
||||
"Modular" is not proven by successful activation. The round trip is the test:
|
||||
|
||||
```text
|
||||
inactive baseline
|
||||
→ activate → verify additions exactly once
|
||||
→ deactivate → verify exact baseline
|
||||
→ reactivate → verify no duplicates and no stale state
|
||||
```
|
||||
|
||||
Run it with: actors that existed before activation; actors spawned after it; an
|
||||
actor destroyed before the feature deactivates; two worlds in one process; client
|
||||
and server roles; a partial load failure; and a plugin required by two
|
||||
experiences at once.
|
||||
|
||||
### Removal must be idempotent
|
||||
|
||||
Resources come off by two independent paths: the explicit reset at deactivation,
|
||||
and reactively when the actor dies first and the manager reports it. Both must be
|
||||
safe to run, in either order, and must operate on "is there a record?" rather than
|
||||
assuming one. Recipe: MG-08.
|
||||
|
||||
### Reference-count shared plugin activation
|
||||
|
||||
If two experiences need one plugin, count activations. One owner deactivating
|
||||
must not unload a plugin the other still requires. **Check that the count is not
|
||||
editor-only** — a reference count compiled out of shipping builds is a reference
|
||||
count that protects development and nothing else. Recipe: MG-16.
|
||||
|
||||
### Diff requirements on experience change
|
||||
|
||||
```text
|
||||
old - new = deactivate and unload
|
||||
new - old = load and activate
|
||||
intersection = keep active
|
||||
```
|
||||
|
||||
Do not unload everything and reload it immediately. If this diff is not
|
||||
implemented, define experience changes as map-travel-only and say so in writing —
|
||||
that is a legitimate scope decision, and leaving it undefined is not.
|
||||
|
||||
---
|
||||
|
||||
## 8. Failure handling
|
||||
|
||||
A production loader must not assert its way through configuration failure.
|
||||
Represent failure as a state:
|
||||
|
||||
```text
|
||||
Loaded
|
||||
Failed(reason, failing asset / plugin / action)
|
||||
```
|
||||
|
||||
Handle: an invalid asset ID; a missing plugin URL; a cancelled load; plugin
|
||||
activation failure; action validation failure; timeout or partial completion; and
|
||||
a deactivation pauser that never completes.
|
||||
|
||||
The loading screen consumes loader state and reason. **A permanent loading screen
|
||||
is not error handling** — it is the absence of it, rendered.
|
||||
|
||||
---
|
||||
|
||||
## 9. Validation checklist
|
||||
|
||||
### Experience
|
||||
|
||||
- [ ] Asset ID registered and resolves.
|
||||
- [ ] Default pawn data valid, or explicitly no pawn.
|
||||
- [ ] Plugin names resolve to URLs.
|
||||
- [ ] Action sets non-null and duplicate-free.
|
||||
- [ ] Reusable behaviour in action sets; only the mode delta inline.
|
||||
- [ ] Client and server bundles cover every soft reference.
|
||||
|
||||
### Action
|
||||
|
||||
- [ ] Target actor class valid; addition list non-empty.
|
||||
- [ ] Existing and future actors both handled.
|
||||
- [ ] Semantic readiness event used where the generic one is too early.
|
||||
- [ ] Every request, delegate, grant, widget and input handle retained.
|
||||
- [ ] Deactivation reverses activation exactly.
|
||||
- [ ] Repeated activate/deactivate is idempotent.
|
||||
- [ ] Per-context state cannot leak into another world.
|
||||
- [ ] Index or key passed to a deferred callback matches the entry it names.
|
||||
|
||||
### Init states
|
||||
|
||||
- [ ] Every prerequisite is explicit.
|
||||
- [ ] Every participating feature can reach every state.
|
||||
- [ ] No feature waits on a signal produced only by a path it disabled.
|
||||
- [ ] Authority and local-control branches tested.
|
||||
- [ ] Late replication re-enters the same check.
|
||||
|
||||
---
|
||||
|
||||
## 10. Review questions
|
||||
|
||||
Before approving a modular feature:
|
||||
|
||||
1. Which definition selects it?
|
||||
2. Which plugin owns its code and content?
|
||||
3. Which action applies each effect?
|
||||
4. Which readiness state makes that effect safe?
|
||||
5. Which handle proves ownership?
|
||||
6. What is the exact inverse operation?
|
||||
7. What is loaded on client, on server, in the editor?
|
||||
8. Can another feature share the same plugin or resource?
|
||||
9. What happens on partial failure?
|
||||
10. Does activate → deactivate → activate return to the same observable state?
|
||||
|
||||
**If questions 4 to 6 have no precise answer, the feature is dynamically attached
|
||||
but not modular.** That distinction is the entire subject of this skill: attaching
|
||||
is easy and demonstrable, detaching is neither, and only one of them gets
|
||||
demonstrated.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
The findings behind the failure modes come from a line-by-line source audit of
|
||||
the feature-action layer of Epic's Lyra Starter Game on Unreal Engine 5.6, read
|
||||
rather than run. The engine-side component manager and feature subsystem are not
|
||||
part of that project, so every statement about them is inferred from call sites
|
||||
in the audited code and is marked as such rather than presented as measured.
|
||||
|
||||
That audit found the architecture sound and its teardown half incomplete in five
|
||||
separate actions — which is why this skill treats the ownership ledger and the
|
||||
round-trip test as mandatory rather than advisory. Several of the gaps are
|
||||
recorded by the original authors in their own comments, which is the strongest
|
||||
confirmation a source audit can produce.
|
||||
|
||||
Source addresses stay in the research archive; what ships is the detection
|
||||
recipe. Each entry carries a stable identifier (`MG-01`, `MG-02`, …) that
|
||||
resolves back to the audited location there.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
One project, one engine version, one workspace. Binary assets were not read, so
|
||||
which actions the shipped features actually configure is unknown and is recorded
|
||||
as open rather than guessed. Re-run the recipes against your own tree before
|
||||
trusting any specific claim.
|
||||
@@ -0,0 +1,701 @@
|
||||
# Failure modes: modular gameplay
|
||||
|
||||
Nineteen ways a feature attaches correctly and detaches incompletely.
|
||||
|
||||
The shared property is stated once, because it explains the whole list:
|
||||
**activation is what gets demonstrated.** A feature is switched on, someone
|
||||
checks the ability appeared, the widget rendered, the input worked, and the
|
||||
review is done. Deactivation is exercised by nobody during development, because
|
||||
in development you restart the editor instead. So the broken half is
|
||||
systematically the half nobody looks at, and its symptoms — a duplicate on the
|
||||
second activation, a bind that survives its feature — appear later and are
|
||||
attributed elsewhere.
|
||||
|
||||
A second property applies to most entries: the reference implementations of these
|
||||
actions **look symmetric**. There is an `Add` and there is a `Remove`, a reset
|
||||
loop and a reactive removal path. The defect is almost never a missing function;
|
||||
it is a function that removes something other than what was added, or that reads
|
||||
a container nobody fills.
|
||||
|
||||
Recipes use `rg` and are written to be run from a project's source root. Most are
|
||||
a pair: one search finds the addition, the other looks for the matching removal.
|
||||
|
||||
---
|
||||
|
||||
## Teardown
|
||||
|
||||
### MG-01 - Rollback function with an empty body
|
||||
|
||||
**Mechanism.** An action's removal path calls a method on a game component, and
|
||||
that method's body is a comment saying it is not implemented yet.
|
||||
|
||||
**Why it is silent.** Every caller succeeds. The call compiles, executes and
|
||||
returns; nothing distinguishes "removed the bindings" from "did nothing" at the
|
||||
call site, because neither returns a result.
|
||||
|
||||
**Why the obvious check misses it.** The action's teardown *is* implemented and
|
||||
reads correctly — it iterates its tracked pawns and calls a removal method on
|
||||
each. Reviewing the action finds a complete, symmetric implementation. The empty
|
||||
body is one call away, in a different file owned by a different subsystem, and it
|
||||
is the game component's problem rather than the feature's.
|
||||
|
||||
**Symptom.** Ability input bindings survive the deactivation of the feature that
|
||||
added them. The second activation adds a second set. Nothing errors.
|
||||
|
||||
**Detect.** Find removal functions whose body is only a comment:
|
||||
|
||||
```bash
|
||||
rg -n -A4 "void \w+::Remove\w+\(" --glob "*.cpp" . \
|
||||
| rg -B2 "^\s*//\s*@?TODO|^\s*\}"
|
||||
```
|
||||
|
||||
Read each hit: a `Remove` whose body contains no statements is the finding. In
|
||||
the measured reference the removal of an additional input config was exactly this
|
||||
— a three-line function containing one TODO comment.
|
||||
|
||||
**Guardrail.** A removal function with no body must not compile silently. Make it
|
||||
pure virtual, assert inside it, or delete the caller. An unimplemented rollback
|
||||
that is *called* is worse than one that does not exist, because the call site
|
||||
proves the intent was there.
|
||||
|
||||
---
|
||||
|
||||
### MG-02 - Bind handles discarded at the moment they are created
|
||||
|
||||
**Mechanism.** A binding call takes an out-parameter array of handles. The caller
|
||||
declares that array as a local variable and lets it go out of scope.
|
||||
|
||||
**Why it is silent.** The bindings work. Handles are only needed to undo, and
|
||||
nothing undoes during a normal session.
|
||||
|
||||
**Why the obvious check misses it.** The out-parameter is passed correctly and
|
||||
the call is idiomatic — some codebases even name the argument in a comment at the
|
||||
call site, which makes it look deliberate. The defect is the *storage duration*
|
||||
of a local variable, which no search for "is the API used correctly?" examines.
|
||||
This is also the root cause of MG-01: the removal function cannot be implemented,
|
||||
because the information it would need was thrown away one function earlier.
|
||||
|
||||
**Symptom.** Rollback is not merely unimplemented but *impossible* without
|
||||
changing the component that binds. An estimate of "implement the missing removal"
|
||||
comes out an order of magnitude low.
|
||||
|
||||
**Detect.** Find handle arrays declared as locals at a binding call:
|
||||
|
||||
```bash
|
||||
rg -n -B2 "BindAbilityActions|BindAction\(" --glob "*.cpp" . \
|
||||
| rg "TArray<uint32>\s+\w+;|TArray<FInputBindingHandle>\s+\w+;"
|
||||
```
|
||||
|
||||
Any handle container declared in the same scope as the call, rather than as a
|
||||
member, is discarded. In the measured reference this appeared at two separate
|
||||
call sites in one component.
|
||||
|
||||
**Guardrail.** Handles returned by a binding API are stored by the object whose
|
||||
lifetime governs the binding, in the same change that creates the binding. If
|
||||
there is nowhere to store them, the feature is not removable and that must be
|
||||
stated before it ships.
|
||||
|
||||
---
|
||||
|
||||
### MG-03 - Tracking container that is read but never written
|
||||
|
||||
**Mechanism.** An action declares a container of the actors it has affected,
|
||||
removes from it in the reactive path, iterates it during reset — and never adds
|
||||
to it. The function that would add takes the per-context data as a parameter and
|
||||
ignores it.
|
||||
|
||||
**Why it is silent.** The reset loop runs, finds the container empty, and
|
||||
completes successfully. An empty loop is a legal outcome; it looks exactly like a
|
||||
feature that affected nothing because no receivers existed.
|
||||
|
||||
**Why the obvious check misses it.** Every symptom of correctness is present: the
|
||||
container is declared, an `ensure` at activation asserts it starts empty, the
|
||||
reset loop pops from it correctly, and the reactive path removes from it. Four of
|
||||
the five operations exist. Only the write is missing, and "is this container
|
||||
used?" answers yes at four sites.
|
||||
|
||||
**Symptom.** Input mapping contexts are never removed at deactivation. They come
|
||||
off only if the receiving actor happens to die first and the reactive path fires.
|
||||
The result depends on teardown order, which makes it intermittent.
|
||||
|
||||
**Detect.** For every tracking container, compare reads against writes:
|
||||
|
||||
```bash
|
||||
C='ControllersAddedTo'
|
||||
rg -n "\b$C\b" --glob "*.cpp" --glob "*.h" . # all uses
|
||||
rg -n "\b$C\b\s*\.\s*(Add|AddUnique|Emplace|Push)" --glob "*.cpp" . # writes only
|
||||
```
|
||||
|
||||
Uses without writes is the finding. The measured reference is unusually clear
|
||||
here: a sibling action in the same directory tracks its pawns with
|
||||
`AddUnique` in exactly the place the broken one omits it, so the two files can be
|
||||
diffed against each other. The original authors had also left a comment on the
|
||||
broken line noting that the container is never modified — a known defect that
|
||||
shipped.
|
||||
|
||||
**Guardrail.** A container whose only writes are removals is a container with no
|
||||
producer. Assert non-empty after the add path runs, or derive the collection from
|
||||
the resource itself rather than maintaining a parallel list.
|
||||
|
||||
---
|
||||
|
||||
### MG-04 - Reset path narrower than the reactive removal path
|
||||
|
||||
**Mechanism.** An action has two removal routes: a reset invoked at deactivation,
|
||||
and a per-actor removal invoked when the manager reports a receiver going away.
|
||||
The per-actor route cleans up more than the reset does.
|
||||
|
||||
**Why it is silent.** The reset completes and clears its own bookkeeping, so the
|
||||
action's internal state is consistent afterwards. What is left behind lives in
|
||||
another subsystem — a pushed layout widget still on its layer — and that
|
||||
subsystem was never told.
|
||||
|
||||
**Why the obvious check misses it.** Both functions exist and both are correct
|
||||
for what they enumerate. Comparing them requires reading two loops in different
|
||||
parts of a file and noticing that one iterates two collections and the other
|
||||
iterates one. Nothing names the missing collection.
|
||||
|
||||
**Symptom.** A layout widget pushed by a feature stays on screen after the
|
||||
feature is deactivated. On reactivation there are two.
|
||||
|
||||
**Detect.** Enumerate what each removal path touches and diff the sets:
|
||||
|
||||
```bash
|
||||
rg -n -A20 "::Reset\(" --glob "*.cpp" . | rg "Empty\(|Unregister|Deactivate|Remove"
|
||||
rg -n -A20 "::Remove\w+\(" --glob "*.cpp" . | rg "Empty\(|Unregister|Deactivate|Remove"
|
||||
```
|
||||
|
||||
A collection handled in the second output and absent from the first is the
|
||||
finding.
|
||||
|
||||
**Guardrail.** One removal implementation, called by both routes. If they must
|
||||
differ, the reset calls the per-actor removal in a loop rather than reimplementing
|
||||
a subset of it.
|
||||
|
||||
---
|
||||
|
||||
### MG-05 - Two removal semantics for one logical resource
|
||||
|
||||
**Mechanism.** The same kind of resource is revoked differently depending on which
|
||||
field granted it: one path clears immediately, the other defers removal until the
|
||||
resource finishes naturally.
|
||||
|
||||
**Why it is silent.** Both are legitimate APIs with legitimate uses. Deferred
|
||||
removal is correct when an ability is mid-activation; immediate removal is
|
||||
correct when tearing down. Neither errors.
|
||||
|
||||
**Why the obvious check misses it.** Each call site is individually defensible,
|
||||
and they are in different functions. The inconsistency is only visible if you ask
|
||||
"what happens to a granted ability at deactivation?" and discover the answer is
|
||||
"it depends which field you used", which is not a question the code invites.
|
||||
|
||||
**Symptom.** Deactivation behaviour differs between two configurations that a
|
||||
designer considers equivalent. Timing-dependent, so it reproduces inconsistently.
|
||||
|
||||
**Detect.** List every revocation call for one resource kind and compare:
|
||||
|
||||
```bash
|
||||
rg -n "ClearAbility|SetRemoveAbilityOnEnd|RemoveActiveGameplayEffect|RemoveSpawnedAttribute" \
|
||||
--glob "*.cpp" .
|
||||
```
|
||||
|
||||
Two different revocation calls for the same resource kind in one teardown path is
|
||||
the finding.
|
||||
|
||||
**Guardrail.** Pick one semantic per resource kind and document it at the grant
|
||||
site. If both are genuinely needed, the choice belongs in the data, named, not in
|
||||
which code path happened to grant it.
|
||||
|
||||
---
|
||||
|
||||
### MG-06 - Removal that is not idempotent
|
||||
|
||||
**Mechanism.** Resources come off by two independent routes — explicit reset at
|
||||
deactivation, and reactive removal when the actor dies first. If removal assumes a
|
||||
record exists, running it twice or in the wrong order misbehaves.
|
||||
|
||||
**Why it is silent.** In the common ordering — feature deactivates while actors
|
||||
are alive — only one route runs. The double-run needs an actor destroyed during
|
||||
teardown, which is a shutdown-order accident.
|
||||
|
||||
**Why the obvious check misses it.** Each route is correct in isolation. Nothing
|
||||
in either function says "this may also be reached from the other path"; the
|
||||
coupling is a property of the manager's event dispatch, which is engine code.
|
||||
|
||||
**Symptom.** An assert or a stale entry during shutdown, in an ordering nobody can
|
||||
reproduce on demand.
|
||||
|
||||
**Detect.** Check that removal is guarded by record existence, and that reset
|
||||
loops tolerate mutation:
|
||||
|
||||
```bash
|
||||
rg -n -A8 "::Remove\w+\(" --glob "*.cpp" . | rg "Find\(|Contains\(|if\s*\("
|
||||
rg -n -A6 "while\s*\(!\w+\.IsEmpty\(\)\)" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
The second pattern is the correct shape: a reset written as "while not empty,
|
||||
take the top and remove it" survives a removal that mutates the same collection.
|
||||
A plain range-for over a collection that removal mutates does not.
|
||||
|
||||
**Guardrail.** Removal operates on the presence of a record, never on the
|
||||
assumption of one. Write reset loops as drain loops.
|
||||
|
||||
---
|
||||
|
||||
## Correctness of what gets attached
|
||||
|
||||
### MG-07 - Index incremented inside the guard it is guarding
|
||||
|
||||
**Mechanism.** A loop builds one deferred callback per configuration entry,
|
||||
passing the entry's index as payload. The increment sits inside the branch that
|
||||
skips invalid entries, so after any skip the payload index no longer matches the
|
||||
list.
|
||||
|
||||
**Why it is silent.** Every index remains in range and resolves to a real entry.
|
||||
The callback applies a valid, well-formed configuration — just the wrong one.
|
||||
There is no out-of-bounds access and nothing to assert on.
|
||||
|
||||
**Why the obvious check misses it.** Reading the loop shows an increment that
|
||||
appears to advance per iteration; it takes reading the brace structure to see it
|
||||
is inside the guard. Editor-time validation flags the invalid entry that triggers
|
||||
it, so the condition is believed impossible — but that validation is an editor
|
||||
check, not a runtime guarantee, and it does not run on cooked data.
|
||||
|
||||
**Symptom.** Actors receive another entry's abilities. Because both sets are
|
||||
valid, this presents as a design or data error and gets investigated in the
|
||||
content.
|
||||
|
||||
**Detect.** Find counters incremented inside a conditional within a loop:
|
||||
|
||||
```bash
|
||||
rg -n -B8 "\b\w*Index\+\+|\+\+\w*Index" --glob "*.cpp" . | rg "if\s*\(|continue"
|
||||
```
|
||||
|
||||
Read each hit's brace structure. In the measured reference the increment sat
|
||||
inside a null-class guard, six lines below the `if` that opened it.
|
||||
|
||||
**Guardrail.** Derive the index from the loop itself rather than maintaining a
|
||||
parallel counter. If a counter is needed, increment it in the loop header where
|
||||
its relationship to the iteration is structural.
|
||||
|
||||
---
|
||||
|
||||
### MG-08 - Soft reference resolved with a bare accessor and no fallback
|
||||
|
||||
**Mechanism.** An action resolves a soft class or asset pointer with a
|
||||
non-loading accessor. If the bundle did not load it, the accessor returns null
|
||||
and the entry is skipped.
|
||||
|
||||
**Why it is silent.** The skip is unconditional and unlogged. A feature that
|
||||
grants nothing looks identical to a feature whose grant list is empty, and both
|
||||
are legal.
|
||||
|
||||
**Why the obvious check misses it.** Every call site is correct given the
|
||||
precondition "bundles are loaded", which is true in the editor, where everything
|
||||
is loaded. The precondition is established in a completely different subsystem —
|
||||
asset bundle configuration — and the code that depends on it does not state that
|
||||
it does.
|
||||
|
||||
**Symptom.** A packaged build silently omits abilities, widgets or mappings that
|
||||
work in the editor. Bisecting finds nothing, because no code changed.
|
||||
|
||||
**Detect.** Compare loading resolutions against non-loading ones, and check for a
|
||||
fallback:
|
||||
|
||||
```bash
|
||||
rg -c "LoadSynchronous" --glob "*.cpp" .
|
||||
rg -n "\.Get\(\)" --glob "*.cpp" . -A2 | rg -v "ensure|check|if\s*\(|UE_LOG"
|
||||
```
|
||||
|
||||
In the measured reference the feature-action directory used three synchronous
|
||||
loads against ten bare accessors, none of the latter guarded or logged. Both
|
||||
numbers matter: the loads are a runtime hitch, the bare accessors are a silent
|
||||
omission, and they appear in the same files.
|
||||
|
||||
**Guardrail.** Either load, or assert, or log — never skip silently. A soft
|
||||
reference that is expected to be bundle-loaded should assert that it was.
|
||||
|
||||
---
|
||||
|
||||
### MG-09 - Unreachable code after a return
|
||||
|
||||
**Mechanism.** A validation function returns its result and is followed by a
|
||||
second, unreachable return.
|
||||
|
||||
**Why it is silent.** It is genuinely harmless. The compiler may not even warn.
|
||||
|
||||
**Why the obvious check misses it.** It is invisible to behaviour-driven review
|
||||
because it has no behaviour. It is included here for one reason: **it is evidence
|
||||
about the file rather than a defect in it.** Dead code after a return means
|
||||
someone edited this validation and did not read what followed. Treat it as a
|
||||
marker for where attention lapsed, and read the surrounding function more
|
||||
carefully than you otherwise would.
|
||||
|
||||
**Symptom.** None. Its value is diagnostic.
|
||||
|
||||
**Detect.**
|
||||
|
||||
```bash
|
||||
rg -n -A3 "^\s*return \w+;\s*$" --glob "*.cpp" . | rg -A1 "^\s*$" | rg "return"
|
||||
```
|
||||
|
||||
**Guardrail.** Enable and honour unreachable-code warnings. Where one appears,
|
||||
re-read the function rather than only deleting the line.
|
||||
|
||||
---
|
||||
|
||||
## Context and scope
|
||||
|
||||
### MG-10 - Action state in plain members rather than per-context
|
||||
|
||||
**Mechanism.** One action object can be active in several state-change contexts
|
||||
simultaneously — client and server in a single process is the ordinary case.
|
||||
State kept in plain member fields is shared between them.
|
||||
|
||||
**Why it is silent.** With one context, which is every standalone run, the
|
||||
behaviour is correct. The corruption requires two contexts, and the second
|
||||
context's writes look like legitimate updates.
|
||||
|
||||
**Why the obvious check misses it.** Member fields on an action object are the
|
||||
obvious place to keep state, and nothing in the base class signature suggests
|
||||
otherwise. The requirement is transmitted only by convention — a context
|
||||
parameter that every lifecycle method receives and that a single-context
|
||||
implementation can safely ignore.
|
||||
|
||||
**Symptom.** One process's client and server tread on each other's tracking data.
|
||||
Teardown removes the wrong context's resources, or removes them twice.
|
||||
|
||||
**Detect.** Find action state that is not keyed by context:
|
||||
|
||||
```bash
|
||||
rg -n -A12 "class \w*GameFeatureAction\w*" --glob "*.h" . \
|
||||
| rg "^\s*(TArray|TMap|TSet|TSharedPtr|bool|int32)\s+\w+" \
|
||||
| rg -v "FGameFeatureStateChangeContext"
|
||||
```
|
||||
|
||||
Any mutable member that is not inside a per-context map is a candidate. The
|
||||
correct shape — a per-context struct in a map keyed by the change context —
|
||||
appears in four separate actions in the measured reference and is worth copying
|
||||
verbatim.
|
||||
|
||||
**Guardrail.** All action state lives in a per-context map. Assert at activation
|
||||
that the context's slot is empty, and reset it if not — that assertion is what
|
||||
turns a silent leak into a visible one.
|
||||
|
||||
---
|
||||
|
||||
### MG-11 - Activation reference count compiled out of shipping
|
||||
|
||||
**Mechanism.** A count that prevents one owner from deactivating a plugin another
|
||||
still needs is wrapped in an editor-only guard, and additionally checked against
|
||||
an editor runtime flag inside.
|
||||
|
||||
**Why it is silent.** In the editor, where multiple worlds share a process, the
|
||||
count is present and correct. In a shipping build there is one world per process,
|
||||
so the case it protects against does not arise — until it does.
|
||||
|
||||
**Why the obvious check misses it.** The mechanism exists, is correct, and is
|
||||
tested every time anyone uses multi-world play-in-editor. Discovering that it does
|
||||
not ship means reading the preprocessor guard around it, which is at the top of a
|
||||
file nobody opens while reasoning about deactivation.
|
||||
|
||||
**Symptom.** No symptom in a single-world build. A latent hazard the moment any
|
||||
build hosts two experiences, which is exactly when a project starts caring about
|
||||
modularity.
|
||||
|
||||
**Detect.**
|
||||
|
||||
```bash
|
||||
rg -n -B4 -A10 "RequestToDeactivate|NotifyOfPluginActivation|ActivationCount" \
|
||||
--glob "*.cpp" . | rg "#if|GIsEditor|WITH_EDITOR"
|
||||
```
|
||||
|
||||
A reference count inside an editor guard, in code that ships, is the finding.
|
||||
|
||||
**Guardrail.** Reference counting is a correctness mechanism, not a development
|
||||
convenience. If it only exists in the editor, say so where it is declared and
|
||||
state what protects shipping instead.
|
||||
|
||||
---
|
||||
|
||||
### MG-12 - Plugins leak enabled between experiences
|
||||
|
||||
**Mechanism.** Experience teardown deactivates the plugins it listed, but a switch
|
||||
between experiences is not implemented as a diff — so plugins required by the old
|
||||
experience and not the new one may stay enabled.
|
||||
|
||||
**Why it is silent.** An enabled plugin that nothing uses has no symptom. Its
|
||||
content stays resident and its actions have already been deactivated, so the
|
||||
observable state is correct.
|
||||
|
||||
**Why the obvious check misses it.** Deactivation exists and works for the case it
|
||||
was written for: ending play. Experience-to-experience switching is a different
|
||||
lifecycle that reuses the same teardown, and nothing in that teardown announces
|
||||
its scope.
|
||||
|
||||
**Symptom.** Memory that does not return to baseline across mode changes, and a
|
||||
second experience that inherits capabilities from the first.
|
||||
|
||||
**Detect.** Look for the diff, and for the authors' own admissions:
|
||||
|
||||
```bash
|
||||
rg -n -i "//\s*@?TODO.*(leak|deactivat|unload|experience)" --glob "*.cpp" .
|
||||
rg -n "GameFeaturePluginURLs" --glob "*.cpp" . -B4 -A8 | rg "Difference|Intersect|Diff"
|
||||
```
|
||||
|
||||
The first search matters more than the second. In the measured reference the
|
||||
experience manager carries an eight-item block of the authors' own TODOs, one of
|
||||
which states plainly that plugins are leaked enabled and that diffing requirements
|
||||
is the intended fix. **A defect the authors documented is the cheapest one you
|
||||
will ever find** — read those blocks first in any codebase you are adopting.
|
||||
|
||||
**Guardrail.** Implement the requirement diff, or define experience changes as
|
||||
map-travel-only and write that decision down. An undefined middle is what leaks.
|
||||
|
||||
---
|
||||
|
||||
### MG-13 - Editor loads both role bundles
|
||||
|
||||
**Mechanism.** The loading policy loads client data when not a dedicated server
|
||||
and server data when not a client-only build. In the editor neither condition
|
||||
excludes, so both load.
|
||||
|
||||
**Why it is silent.** It is intentional and it is correct — the editor must be
|
||||
able to run either role. Nothing is wrong; the number it produces simply describes
|
||||
no shipping configuration.
|
||||
|
||||
**Why the obvious check misses it.** Profiling produces a figure, and figures from
|
||||
a profiler carry an authority their provenance does not. The condition that
|
||||
inflates it is two boolean expressions in a policy class, in a different
|
||||
subsystem from the one being measured.
|
||||
|
||||
**Symptom.** Memory and load-time budgets built on editor measurements, wrong in a
|
||||
direction that feels safe until a platform limit is real.
|
||||
|
||||
**Detect.**
|
||||
|
||||
```bash
|
||||
rg -n -A6 "GetGameFeatureLoadingMode|bLoadClientData|bLoadServerData" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
If neither flag excludes in the editor, both bundle sets load. In the measured
|
||||
reference the authors noted the resulting editor hitching in a comment beside it.
|
||||
|
||||
**Guardrail.** Editor asset measurements are directional only. Any absolute budget
|
||||
requires a packaged build of the target role, and any budget quoted without one
|
||||
carries that caveat in writing.
|
||||
|
||||
---
|
||||
|
||||
## Timing and readiness
|
||||
|
||||
### MG-14 - A delay with no named dependency
|
||||
|
||||
**Mechanism.** Selection or initialisation is deferred by a tick to let something
|
||||
else finish first.
|
||||
|
||||
**Why it is silent.** It works. One frame is enough today, on this machine, for
|
||||
this content set.
|
||||
|
||||
**Why the obvious check misses it.** A next-tick deferral is a common, accepted
|
||||
idiom, and the code around it is correct. Whether it is legitimate depends
|
||||
entirely on whether the thing being waited for is *named* — and an unnamed wait
|
||||
looks identical to a named one.
|
||||
|
||||
**Symptom.** Initialisation order breaks on a slower machine, a larger content
|
||||
set, or after an unrelated subsystem changes its own timing. The failure appears
|
||||
far from the deferral.
|
||||
|
||||
**Detect.** Find deferrals and check for a stated prerequisite:
|
||||
|
||||
```bash
|
||||
rg -n -B6 "SetTimerForNextTick|GetTimerManager\(\)\.SetTimer" --glob "*.cpp" . \
|
||||
| rg -i "//|comment|wait|until|so that"
|
||||
```
|
||||
|
||||
A deferral whose surrounding comment names what it waits for is debt with a
|
||||
receipt. One with no comment is debt with a timer wrapped around it. In the
|
||||
measured reference the one-frame delay before experience selection *is*
|
||||
documented — it waits for startup settings — which is the correct form.
|
||||
|
||||
**Guardrail.** Every delay names its prerequisite in a comment at the call site,
|
||||
and is replaced by an explicit readiness signal when one becomes available.
|
||||
|
||||
---
|
||||
|
||||
### MG-15 - Handler attached to the generic extension event
|
||||
|
||||
**Mechanism.** An action registers for "extension added", which fires when the
|
||||
manager first learns of the actor — before its pawn data, ability system,
|
||||
controller or input exist.
|
||||
|
||||
**Why it is silent.** The handler runs, finds what it needs missing, and returns
|
||||
without acting. Returning early on a missing prerequisite is defensive
|
||||
programming, and looks like it.
|
||||
|
||||
**Why the obvious check misses it.** The registration is correct, the handler is
|
||||
correct, and the early return is correct. Nothing is wrong at any single point.
|
||||
The feature simply never applies, because the one event that would have carried it
|
||||
arrived too early and no later event re-triggers it.
|
||||
|
||||
**Symptom.** A feature that works when activated after the actor exists and does
|
||||
nothing when activated before, or the reverse — order-dependent, and the order
|
||||
differs between the editor and a packaged run.
|
||||
|
||||
**Detect.** List which events each handler responds to:
|
||||
|
||||
```bash
|
||||
rg -n -A12 "HandleActorExtension|FExtensionHandlerDelegate" --glob "*.cpp" . \
|
||||
| rg "EventName ==|NAME_"
|
||||
```
|
||||
|
||||
A handler that responds only to the generic added/removed events, with no
|
||||
semantic ready event, is the finding. The correct shape in the measured reference
|
||||
dispatches on both: the generic event *and* a named readiness event such as
|
||||
"abilities ready" or "bind inputs now", so whichever arrives second does the work.
|
||||
|
||||
**Guardrail.** Handlers accept both the generic event and a semantic readiness
|
||||
event, making activation order irrelevant. Emitting a new readiness event costs a
|
||||
name and one call — it requires no change to the feature infrastructure.
|
||||
|
||||
---
|
||||
|
||||
### MG-16 - Receiver registration written by hand and left incomplete
|
||||
|
||||
**Mechanism.** Actors register with the component manager in three places: add
|
||||
receiver early, send a ready event at begin play, remove receiver at end play.
|
||||
Classes inheriting a modular base get all three. Classes that do not inherit it
|
||||
have them written by hand.
|
||||
|
||||
**Why it is silent.** Missing the removal leaks a registration whose owner is
|
||||
gone. Weak references prevent a crash; the record remains. Missing the ready
|
||||
event means features silently never attach to that actor class.
|
||||
|
||||
**Why the obvious check misses it.** The class works. It renders, it ticks, it
|
||||
does its job. The two or three lines that make it visible to features are
|
||||
infrastructure boilerplate, and boilerplate absent from a file looks like
|
||||
boilerplate that was not needed there.
|
||||
|
||||
**Symptom.** A feature attaches to every actor class except one, with no error —
|
||||
or registrations that accumulate across level transitions.
|
||||
|
||||
**Detect.** Compare the three calls per class:
|
||||
|
||||
```bash
|
||||
rg -l "AddGameFrameworkComponentReceiver" --glob "*.cpp" . > /tmp/add
|
||||
rg -l "RemoveGameFrameworkComponentReceiver" --glob "*.cpp" . > /tmp/rem
|
||||
comm -23 <(sort /tmp/add) <(sort /tmp/rem)
|
||||
```
|
||||
|
||||
Any file that adds and never removes is the finding. In the measured reference
|
||||
one HUD class inherits a non-modular engine base and therefore carries all three
|
||||
calls by hand — correctly, but by hand, which is the fragile arrangement.
|
||||
|
||||
**Guardrail.** Put the three calls in a base class and inherit it. Where that is
|
||||
impossible, add a test that asserts the receiver count returns to baseline after a
|
||||
level transition.
|
||||
|
||||
---
|
||||
|
||||
## Composition
|
||||
|
||||
### MG-17 - Handle dropped without realising it was the unsubscribe
|
||||
|
||||
**Mechanism.** Extension handler and component requests return reference-counted
|
||||
handles. There is no explicit removal API — releasing the last reference *is* the
|
||||
removal.
|
||||
|
||||
**Why it is silent.** Keeping a handle costs nothing visible, and dropping one
|
||||
produces no event. Both directions of the mistake are quiet: holding too long
|
||||
leaks a subscription, releasing too early silently detaches a live feature.
|
||||
|
||||
**Why the obvious check misses it.** Nothing in the calling code says
|
||||
"unsubscribe". Reviewing teardown for removal calls finds none — correctly, because
|
||||
there are none — and it takes knowing the ownership model to recognise that
|
||||
emptying an array of handles is the teardown.
|
||||
|
||||
**Symptom.** Either features that cannot be removed, or features that detach when
|
||||
an unrelated container is cleared.
|
||||
|
||||
**Detect.** Confirm every handle-producing call has a stored destination:
|
||||
|
||||
```bash
|
||||
rg -n "AddExtensionHandler|AddComponentRequest" --glob "*.cpp" . -A3 \
|
||||
| rg -v "\.Add\(|=\s*\w+\.Add"
|
||||
```
|
||||
|
||||
A call whose result is not stored is a subscription that ends immediately.
|
||||
|
||||
**Guardrail.** Name the container for what it does — a handle array is an
|
||||
ownership ledger, not a cache — and comment at its declaration that clearing it is
|
||||
the unsubscribe operation.
|
||||
|
||||
---
|
||||
|
||||
### MG-18 - Plugin activation order assumed
|
||||
|
||||
**Mechanism.** Plugin URLs from an experience and from each of its action sets
|
||||
merge into one list and activate in parallel. Action *execution* order is
|
||||
deterministic; plugin *activation* order is not.
|
||||
|
||||
**Why it is silent.** Parallel activation usually completes before anything
|
||||
depends on the result, and when it does not, the dependent code has its own
|
||||
readiness check that eventually passes.
|
||||
|
||||
**Why the obvious check misses it.** Action ordering *is* deterministic and
|
||||
documented — experience actions first, then action sets in order. Having verified
|
||||
that, it is natural to assume the same of plugins, and nothing contradicts it.
|
||||
|
||||
**Symptom.** A feature that depends on another plugin's registration works
|
||||
consistently until content or load timing changes.
|
||||
|
||||
**Detect.** Check whether the plugin list is loaded as a batch:
|
||||
|
||||
```bash
|
||||
rg -n -A10 "GameFeaturePluginURLs" --glob "*.cpp" . | rg "for\s*\(|LoadGameFeaturePlugin"
|
||||
```
|
||||
|
||||
A loop issuing loads without sequencing between them means order is not
|
||||
guaranteed.
|
||||
|
||||
**Guardrail.** Express cross-plugin dependencies as readiness states rather than
|
||||
activation order. If ordering is genuinely required, sequence the loads and say
|
||||
why in the code.
|
||||
|
||||
---
|
||||
|
||||
## Failure handling
|
||||
|
||||
### MG-19 - Asynchronous deactivation counted but not honoured
|
||||
|
||||
**Mechanism.** The deactivation context supports pausers for asynchronous
|
||||
teardown. The implementation counts them and, if any are outstanding, logs an
|
||||
error and proceeds.
|
||||
|
||||
**Why it is silent.** It is not entirely silent — it logs. But it logs and
|
||||
*continues*, so teardown completes and the sequence appears successful. A log line
|
||||
during shutdown competes with everything else logged during shutdown.
|
||||
|
||||
**Why the obvious check misses it.** The support looks present: the context is
|
||||
constructed with a pauser callback, the count is tracked, the branch exists.
|
||||
Reviewing for "is async deactivation handled?" finds all the machinery. Reading
|
||||
what the branch *does* is a separate step.
|
||||
|
||||
**Symptom.** An action with asynchronous teardown is cut short. Whatever it was
|
||||
waiting to release stays held, and the failure is attributed to whichever
|
||||
subsystem later notices the leak.
|
||||
|
||||
**Detect.** Find pauser handling and read the branch:
|
||||
|
||||
```bash
|
||||
rg -n -A8 "NumExpectedPausers|DeactivatingContext" --glob "*.cpp" . \
|
||||
| rg "UE_LOG|Error|return|check"
|
||||
```
|
||||
|
||||
An error log with no wait and no abort is the finding. The measured reference
|
||||
states its own limitation in the log text — that asynchronous deactivation is not
|
||||
fully supported — which makes it a documented gap rather than an unknown one.
|
||||
|
||||
**Guardrail.** Either wait for pausers before completing teardown, or reject
|
||||
actions that request asynchronous deactivation at validation time. Logging and
|
||||
continuing converts a design gap into a runtime one.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Patterns: a feature-action layer read end to end
|
||||
|
||||
A worked reading of one real modular gameplay implementation: how a feature
|
||||
attaches behaviour to actors it does not own, and what happens when it detaches.
|
||||
|
||||
Individual defects are in [failure-modes.md](failure-modes.md), one entry each,
|
||||
with a recipe. This file is about the shapes worth copying, and about one
|
||||
finding that only becomes visible when you count.
|
||||
|
||||
Markers: **[measured]** — read in source; **[derived]** — conclusion from measured
|
||||
facts; **[open]** — not answerable from the available source, and left open.
|
||||
|
||||
**A scope caveat that shapes everything below.** The engine-side component
|
||||
manager, request handle, feature action base and feature subsystem are not part
|
||||
of the audited project — they live in engine plugins that were not in the tree.
|
||||
Every statement about them here is inferred from call sites **[derived]**, and is
|
||||
marked so. This is the honest version of a common situation: you can audit how a
|
||||
project *uses* an engine subsystem far more cheaply than you can audit the
|
||||
subsystem, and the usage is where your defects will be anyway.
|
||||
|
||||
---
|
||||
|
||||
## 1. The core idea: the action does not look for actors
|
||||
|
||||
The mechanism that makes this architecture work is one inversion.
|
||||
|
||||
A feature action does **not** scan the world for actors to modify. It registers
|
||||
an extension handler for a target *class* with a component manager, and the
|
||||
manager invokes that handler for every matching receiver — the ones that already
|
||||
exist and the ones that appear later **[measured]**.
|
||||
|
||||
```text
|
||||
receiver actor registers itself with the manager
|
||||
action registers an extension handler for a class
|
||||
manager invokes the handler for existing AND future receivers
|
||||
returned request handle owns the subscription
|
||||
```
|
||||
|
||||
The receiver side is three calls, and in the audited project they live in modular
|
||||
base classes rather than in gameplay code **[measured]**: add receiver in
|
||||
pre-initialize, send a ready event at begin play, remove receiver at end play.
|
||||
|
||||
**[derived]** The consequence is the whole point of the architecture: base
|
||||
gameplay classes register as receivers and **know nothing about features at all**.
|
||||
Nothing in the character, player state or game mode references the feature system.
|
||||
A feature plugin can be added to the project without touching any of them.
|
||||
|
||||
The audited project contains one instructive exception. A HUD class inherits a
|
||||
non-modular engine base, so those three calls are written out by hand in it
|
||||
**[measured]**. Correct — and fragile in a way the inherited version is not,
|
||||
because the third call is easy to omit and its omission has no symptom until
|
||||
something counts registrations. Recipe: MG-16.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ownership is a reference count, and nothing says so
|
||||
|
||||
Handler registration and component requests return shared handles **[measured]**.
|
||||
There is no explicit unregister call anywhere in the audited code
|
||||
**[measured]** — the only way to detach is to release the last reference.
|
||||
|
||||
Every reset path therefore begins by emptying an array of handles **[measured]**,
|
||||
and that line *is* the unsubscribe operation even though it reads as ordinary
|
||||
container cleanup.
|
||||
|
||||
**[derived]** Two failure directions follow, and both are quiet. Hold a handle too
|
||||
long and the subscription leaks. Clear the array early and a live feature detaches
|
||||
with no event. Reviewing teardown for "removal calls" finds none — correctly,
|
||||
because there are none — so the reviewer must already know the ownership model to
|
||||
see that teardown is present. Recipe: MG-17.
|
||||
|
||||
One detail in the audited code confirms the model from the inside: when a
|
||||
component already exists, the action still issues a request for it in some cases,
|
||||
with a comment explaining that requests are reference counted **[measured]**.
|
||||
**[derived]** That is a project telling you, in its own code, how the engine
|
||||
subsystem it depends on behaves — which is the best evidence available when the
|
||||
subsystem's source is not in the tree.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-context state, and why it is not optional
|
||||
|
||||
One action object can be active in several state-change contexts at once — client
|
||||
and server in a single process being the ordinary case. Every lifecycle method
|
||||
receives a context, and four separate actions in the audited project key their
|
||||
state by it **[measured]**:
|
||||
|
||||
```cpp
|
||||
struct FPerContextData { /* tracked resources */ };
|
||||
TMap<FGameFeatureStateChangeContext, FPerContextData> ContextData;
|
||||
```
|
||||
|
||||
This is the pattern to copy verbatim. **[derived]** State in plain member fields
|
||||
works perfectly with one context and corrupts silently with two, and the second
|
||||
context's writes look like legitimate updates.
|
||||
|
||||
Two supporting habits appear consistently and are worth copying with it
|
||||
**[measured]**:
|
||||
|
||||
- at activation, `ensure` that this context's slot is empty and force a reset if
|
||||
not, **before** calling the base implementation — an assertion that converts a
|
||||
leak into a visible failure;
|
||||
- at deactivation, call the base implementation **first**, then reset the
|
||||
context's data.
|
||||
|
||||
The base class itself keeps its delegate handles in a map keyed the same way
|
||||
**[measured]**, which is how it supports being active in two contexts at once.
|
||||
|
||||
Recipe: MG-10.
|
||||
|
||||
---
|
||||
|
||||
## 4. Semantic readiness events
|
||||
|
||||
A generic "extension added" event means only that the manager knows about the
|
||||
actor. **[derived]** It does not mean the actor has its pawn data, ability system,
|
||||
controller, or initialised input — so a handler attached to that event alone will
|
||||
find its prerequisites missing, return early, and never run again.
|
||||
|
||||
The audited project solves this by emitting its own named events at the real
|
||||
dependency boundaries — abilities ready, bind inputs now **[measured]** — and by
|
||||
having every handler dispatch on **both** the generic event and the semantic one
|
||||
**[measured]**:
|
||||
|
||||
```text
|
||||
extension removed / receiver removed -> remove
|
||||
extension added / <semantic event> -> add
|
||||
```
|
||||
|
||||
**[derived]** Whichever arrives second does the work, so activation order stops
|
||||
mattering. This is the single most transferable idea in the file, and it costs a
|
||||
`static const FName` plus one call to emit — no change to the feature
|
||||
infrastructure is required. Recipe: MG-15.
|
||||
|
||||
---
|
||||
|
||||
## 5. The finding that only appears when you count
|
||||
|
||||
Eight actions were audited. Five have incomplete teardown **[measured]**:
|
||||
|
||||
| Action | Attaches | Detaches |
|
||||
|---|---|---|
|
||||
| add abilities | grants abilities, attribute sets, ability sets; stores every handle | mostly correct — but two revocation semantics for one resource kind, depending on which field granted it |
|
||||
| add widgets | pushes layouts, registers extensions, stores handles | reset unregisters extensions and does **not** deactivate layouts; the per-actor path does both |
|
||||
| add input binding | delegates to a game component | the component's removal method has an empty body |
|
||||
| add input mapping context | adds contexts to the input subsystem | tracks affected controllers in a container **nothing ever writes to** |
|
||||
| splitscreen config | votes to disable | correct — decrements the vote |
|
||||
|
||||
**[derived]** Read one at a time, each looks like an isolated oversight. Read
|
||||
together, they are one pattern: **the add path is complete in all eight, and the
|
||||
remove path is complete in three.** That ratio is the argument for the ownership
|
||||
ledger in the skill — not a rule someone invented, but the shape the evidence
|
||||
takes.
|
||||
|
||||
Two of these deserve their detail, because the detail is what makes them
|
||||
recognisable elsewhere.
|
||||
|
||||
### The container nobody fills
|
||||
|
||||
An action declares a container of the controllers it has affected. An `ensure` at
|
||||
activation asserts it starts empty. The reset loop drains it correctly. The
|
||||
reactive path removes from it. **Nothing adds to it** — the function that would
|
||||
takes the per-context data as a parameter and ignores it **[measured]**.
|
||||
|
||||
Four of five operations exist, so any search for "is this container used?"
|
||||
answers yes emphatically. The one missing operation is the producer.
|
||||
|
||||
Two things make this the clearest entry in the file. First, a **sibling action in
|
||||
the same directory** does it correctly, with `AddUnique` in exactly the place the
|
||||
broken one omits **[measured]** — so the two files diff against each other.
|
||||
Second, the original authors left a comment on the broken line stating that the
|
||||
container is never modified **[measured]**. A known defect that shipped. Recipe:
|
||||
MG-03.
|
||||
|
||||
### The rollback that cannot be written
|
||||
|
||||
The input-binding action's teardown iterates its tracked pawns and calls a removal
|
||||
method on the game component. That method's body is a single TODO comment
|
||||
**[measured]**.
|
||||
|
||||
But the cause is one level further down: the function that creates the bindings
|
||||
declares the handle array as a **local variable** and lets it go out of scope
|
||||
**[measured]**, at two separate call sites.
|
||||
|
||||
**[derived]** So the removal is not merely unimplemented, it is impossible to
|
||||
implement without changing the component — the information it would need was
|
||||
discarded at creation. An estimate of "implement the missing rollback" made from
|
||||
reading the empty function comes out an order of magnitude low. Recipes: MG-01,
|
||||
MG-02.
|
||||
|
||||
---
|
||||
|
||||
## 6. What the reference got right
|
||||
|
||||
Stated deliberately, because the section above is not an argument against the
|
||||
architecture:
|
||||
|
||||
1. **Actions do not search for actors.** The extension handler protocol handles
|
||||
existing and future receivers uniformly **[measured]**.
|
||||
2. **Per-context state in all four attaching actions** **[measured]**, with the
|
||||
activation-time emptiness assertion.
|
||||
3. **Reset loops written as drain loops** — "while not empty, take the top,
|
||||
remove it" **[measured]** — which survives a removal that mutates the same
|
||||
collection. A range-for would not.
|
||||
4. **Two removal routes, both idempotent**: explicit reset at deactivation, and
|
||||
reactive removal when the actor dies first, keyed on whether a record exists
|
||||
**[measured]**.
|
||||
5. **Composition enforced over inheritance.** Validation rejects deriving one
|
||||
scripted experience from another and points the author at action sets
|
||||
**[measured]**. That is a design rule with a compiler behind it.
|
||||
6. **Action sets are separate primary assets** with their own bundle data
|
||||
**[measured]**, which is what allows them to be loaded alongside the experience
|
||||
rather than through it.
|
||||
7. **A one-frame deferral with a named reason** **[measured]** — it waits for
|
||||
startup settings. A delay whose prerequisite is named is debt with a receipt;
|
||||
an unnamed one is debt with a timer around it. Recipe: MG-14.
|
||||
8. **A policy class as the place for pre-activation work.** Logic that must run
|
||||
before activation, is process-global rather than per-world, or needs to see all
|
||||
of a plugin's actions at once, belongs in a lifecycle observer rather than in an
|
||||
action **[measured]**. One action in the audited set is pure data with a getter,
|
||||
executed entirely by such an observer **[measured]** — a clean separation worth
|
||||
copying.
|
||||
|
||||
---
|
||||
|
||||
## 7. The authors' own TODO block
|
||||
|
||||
The experience manager carries a block of eight TODOs from its authors
|
||||
**[measured]**, covering: asynchronous experience loading; explicit error
|
||||
handling instead of assertions; running actions in phases rather than all at
|
||||
once; support for deactivating an experience; and — stated plainly — that plugins
|
||||
are **leaked enabled** between experiences, with diffing requirements named as the
|
||||
intended fix.
|
||||
|
||||
**[derived]** This is the cheapest finding in any audit, and the most reliable.
|
||||
The authors know; they wrote it down; nobody adopting the code reads it, because
|
||||
adoption reads the class list rather than the comments.
|
||||
|
||||
Two practical consequences:
|
||||
|
||||
- **Read the TODO blocks first** in any codebase you are adopting. They are a
|
||||
defect list written by the people best placed to write one.
|
||||
- Treat "the authors documented this gap" as *stronger* evidence than a static
|
||||
finding of your own, not weaker. It removes the possibility that you have
|
||||
misread the intent.
|
||||
|
||||
Recipe: MG-12. The same reading also surfaces asynchronous deactivation: pausers
|
||||
are counted, and when any are outstanding the code logs an error and proceeds
|
||||
**[measured]**. The limitation is stated in the log text itself. Recipe: MG-19.
|
||||
|
||||
---
|
||||
|
||||
## 8. Editor loading is not shipping loading
|
||||
|
||||
The loading policy loads client data when not a dedicated server, and server data
|
||||
when not a client-only build **[measured]**. **[derived]** In the editor neither
|
||||
condition excludes, so both bundle sets load — and the authors noted the resulting
|
||||
hitching in a comment beside it **[measured]**.
|
||||
|
||||
Every in-editor memory and load-time figure therefore describes a configuration
|
||||
that ships to nobody. Directionally useful, absolutely wrong. Recipe: MG-13.
|
||||
|
||||
---
|
||||
|
||||
## 9. What source reading could not settle
|
||||
|
||||
- **The engine subsystems were not in the tree.** Component manager, request
|
||||
handle, feature action base, feature subsystem, and the add-components action
|
||||
**[open]**. Everything above about them is inferred from call sites.
|
||||
- **Binary assets were not read.** Which actions the shipped feature plugins
|
||||
actually configure, and with what data, is **[open]**. What *is* measured: those
|
||||
plugins contain no feature-action C++ of their own, so they use only actions
|
||||
from the base module and the engine **[measured]**.
|
||||
- **One teardown question is genuinely undecidable from this tree.** Whether
|
||||
clearing the handle array causes the manager to dispatch removal events — which
|
||||
would make the narrower reset path in MG-04 harmless — depends on engine code
|
||||
that was not available **[open]**. It is recorded as an open question rather than
|
||||
resolved in either direction, because a plausible answer either way would be a
|
||||
guess.
|
||||
|
||||
That last item is worth its space. It would have been easy to state MG-04 as a
|
||||
confirmed defect; the honest form is "this is a defect unless an engine behaviour
|
||||
we could not read compensates for it", and a reader adopting the code can settle
|
||||
it in ten minutes with the engine source that they have and the audit did not.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
Measured against the feature-action layer of Epic's Lyra Starter Game on Unreal
|
||||
Engine 5.6, read as source in a single workspace. Source addresses stay in the
|
||||
research archive that produced this skill; each `MG-` identifier resolves back to
|
||||
the audited location there, so any specific claim above can be produced on
|
||||
request.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
One project, one engine version, one workspace, with the engine-side subsystems
|
||||
outside the readable tree. These are examples and failure evidence, not
|
||||
guarantees about other versions. Claims marked open stay open. Re-run the recipes
|
||||
in [failure-modes.md](failure-modes.md) against your own tree before acting on
|
||||
anything here.
|
||||
Reference in New Issue
Block a user