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

Phase 0 of the handoff plan, as a marketplace rather than a flat skills/
directory. Content moved out of the LyraResearch archive and depersonalised:
addresses stay in the archive, recipes ship.

- plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the
  six required fields; catalog.json as the harness-neutral source of truth and
  .claude-plugin/ as one adapter over it.
- _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a
  declared file cannot silently miss the line rules.
- ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split
  licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata).
- LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as
  the material the licence decision grew from.

Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their
fixtures with a clean baseline and 2 root files reaching the line rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ue-toolchain
2026-09-05 23:48:55 +07:00
parent 9e2194c298
commit dab3f35079
67 changed files with 21630 additions and 0 deletions
@@ -0,0 +1,376 @@
---
name: ue-gameplay-cues
description: >-
Design, implement or review the GameplayCue presentation layer in Unreal
Engine GAS: what a cue is and when it is the right mechanism, Executed versus
OnActive/WhileActive/OnRemove forms, the three addressing paths, Static versus
Actor notifies, the dedicated-server rule, cue asset discovery and loading
cost, registering cue paths from feature plugins, teardown and leaked looping
cues, and the tag-to-asset validation gap. Use when adding VFX or audio
feedback to abilities or effects, decoupling presentation from gameplay
assets, debugging a cue that never fires or never stops, or auditing cue
loading cost.
---
# UE gameplay cues
The invariant:
> A cue is a one-way, cosmetic, tag-addressed broadcast. Gameplay must produce
> exactly the same result if every cue in the project fails to fire.
Worked analysis of a real cue subsystem, including the parts that were built and
never reached: [patterns](references/patterns.md).
Twenty detection recipes for silent cue failures: [failure modes](references/failure-modes.md).
Read the failure modes before adopting or reviewing a cue-based design. Each entry
names the mechanism, why it stays silent, why the obvious check misses it, and a
command you can run against your own tree.
Related skills: `ue-gas-architecture`, `ue-gameplay-tag-governance`,
`ue-modular-gameplay`, `ue-asset-loading-and-memory`, `ue-gameplay-messaging`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. What a GameplayCue is, and the problem it solves
A GameplayCue is a **presentation event addressed by a GameplayTag** rather than
by an asset reference.
Without it, the chain looks like this:
```text
GE_Fire_Damage --hard reference--> NS_Fire_Impact (Niagara)
SW_Fire_Impact (audio)
```
The gameplay asset now owns the VFX and the audio. Loading the effect loads the
particles. A dedicated server, which needs the damage numbers and nothing else,
loads them too. An artist changing the impact effect touches a gameplay asset.
With a cue, the chain is cut:
```text
GE_Fire_Damage --tag--> "GameplayCue.Fire.Impact" <--tag-- GCN_Fire_Impact
```
The gameplay asset references a **string-like identifier**. A separate registry
maps that tag to a notify asset, discovered by scanning content directories. The
gameplay asset has no idea the presentation exists.
What this buys:
- gameplay assets carry no presentation payload, so they are small and
server-safe;
- presentation can be added, replaced or shipped in a separate plugin without
touching gameplay;
- the whole presentation layer can be absent on a dedicated server.
What it costs — and this is the part that gets skipped:
- **the join is no longer checked by anything.** A tag with no notify is silence.
A notify with no matching tag is a never-loaded asset. Neither is an error.
- discovery is directory-scan based, so cue assets must be found, indexed and
loaded by a manager that has its own lifecycle and its own failure modes;
- the mechanism is one-way and unreliable by design, which makes it wrong for
anything that must be correct.
Treat cues as an application of `ue-gameplay-tag-governance` with a dedicated
loading subsystem attached. Every rule about tag joins applies here first.
---
## 2. The three forms
| Form | Fires when | Use for |
|---|---|---|
| **Executed** | a one-shot event | impacts, hit reactions, one-off flashes |
| **OnActive** | a duration effect starts, on clients present at that moment | start of a looping effect |
| **WhileActive** | an actor becomes relevant while the effect is already running | joining/relevancy catch-up for the same loop |
| **OnRemove** | the duration effect ends | stopping the loop, cleanup |
### Rules
1. **`OnActive` and `WhileActive` must produce the same visual state.** A client
who was present and a client who arrived late must see the same thing. Putting
the "start" logic only in `OnActive` means late joiners see nothing.
2. **Everything `OnActive`/`WhileActive` spawns, `OnRemove` destroys.** Looping
cues are the main source of leaked VFX in GAS projects.
3. **Never use `Executed` for anything with a duration**, and never use the
duration forms for a one-shot — the recovery paths differ.
4. **Assume `OnRemove` may run without `OnActive` having run** on that client, and
the reverse. Write both defensively.
---
## 3. Static versus Actor notifies
| Notify type | Instanced | State | Use for |
|---|---|---|---|
| **Static** | no — a CDO handles the call | none | one-shot effects, sounds, decals |
| **Actor** | yes — an actor is spawned per instance | yes | looping effects, anything requiring cleanup |
### Rules
1. **Default to Static.** It allocates nothing and cannot leak.
2. **A Static notify must not store per-instance state.** It runs on the class
default object; a member written during one invocation is shared by every
subsequent one, including other players' invocations.
3. **Use an Actor notify only when there is something to tear down** — a looping
particle system, an attached component, a timeline.
4. **Actor notifies are actors**: they have relevancy, they cost replication
consideration if not marked otherwise, and they can outlive their owner if
removal is missed.
---
## 4. The three ways a cue gets addressed
```text
1. GameplayEffect GE lists cue tags; ASC fires them with the effect
2. Direct ASC call ExecuteGameplayCue / AddGameplayCue / RemoveGameplayCue
3. IGameplayCueInterface the actor handles cue tags itself
```
### Rules
1. **Prefer route 1 for anything tied to an effect's lifetime.** The effect and
its presentation then start and stop together by construction.
2. **Route 2 is for presentation with no effect behind it** — a UI-driven flash, a
client-local confirmation. It is manual, so its removal is manual too.
3. **Route 3 is for actor-specific handling** that no generic notify can express.
It couples the actor to the cue vocabulary; use sparingly.
4. **Do not mix routes for one cue tag.** Two owners of the same presentation
produces double effects that appear only when both paths trigger.
---
## 5. The network rule
**Cues never run on a dedicated server.** The presentation layer does not exist
there.
Consequences that must be designed for, not discovered:
1. **A cue may not carry gameplay side effects.** Damage, score, state changes,
spawning — none of it may live in a notify, because on a dedicated server that
code never executes. This is the single most important rule in this document.
2. **Cue parameters are a lossy channel.** They are packed for replication;
whatever your conversion helper drops is dropped silently on every client.
Verify the round trip for every field you rely on, especially tag containers.
3. **Cue delivery is unreliable.** Packet loss, relevancy changes and joining
mid-effect all produce missed cues. Design for a missed cue being invisible,
not desynchronising.
4. **Cue assets belong in the client asset bundle only.** Putting them in the
server bundle loads presentation content on a machine that will never use it.
### Test that the rule holds
Run the feature on a dedicated server with the presentation content deliberately
absent. Gameplay results must be byte-identical.
---
## 6. Registering cue paths from feature plugins
A feature plugin that ships cue assets must register its content directory with
the cue manager, or its cues are invisible.
The important structural point: **cue path registration must happen earlier than
feature activation.** The manager builds its tag-to-asset index during its object
library scan; a path added after that scan is not indexed until the next rescan.
This is earlier than any of the standard activation hooks.
The pattern that solves it:
> **The feature action is a pure data declaration; a lifecycle observer is the
> executor.**
The action object declares only the directory list and validates it in the
editor. A separate observer, registered with the feature policy, listens for the
earliest lifecycle phase and performs the registration for every action of that
type it finds.
### Rules
1. **Register on the earliest available phase**, not on activation.
2. **Batch the rescan.** Add all paths, then rebuild the index once — one rescan
per feature, not one per directory.
3. **Refresh the primary-asset registration when the path set changes**, on the
way in *and* on the way out. Skipping it on removal leaves the asset manager
describing directories that no longer exist.
4. **Implement removal and assert it.** Count the paths removed and compare
against the paths added; a mismatch means the registry is drifting across
activation cycles.
5. **Use the same manager instance on both sides of the lifecycle.** Registering
through your subclassed manager and unregistering through the engine base
class is a silent asymmetry — it compiles, runs, and leaves entries behind.
---
## 7. Loading and cost
Cue assets are discovered by scanning directories and are, by default, loaded on
demand — which means a hitch the first time each cue fires.
The mitigation is preloading, and it is where most complexity in a cue manager
lives: async library loading, a preload set, always-loaded tags, reference
tracking, and hooks for GC and map transitions.
### Rules
1. **Decide the load policy explicitly and verify it is reachable.** A load-mode
switch that short-circuits every branch renders the entire preload subsystem
dead code — the machinery exists, is maintained, is never executed, and any
diagnostic command reports zero because there is genuinely nothing there.
2. **A constant is not a knob.** A local `const bool` inside a namespace whose
name ends in `Cvars` is not a console variable and cannot be changed at
runtime. If a policy must be tunable, register it as an actual console
variable; otherwise do not imply that it is one.
3. **Preload the small set that must never hitch** — the cues on the critical
path, hit feedback above all — and let the rest load on demand.
4. **Put cue paths in the client bundle only** (see §5).
5. **Measure the cue set size before optimising it.** Discovery cost scales with
the number of scanned directories; load cost scales with the assets actually
referenced.
---
## 8. Teardown
Cues attach to an avatar. When the avatar changes — death, possession change,
respawn — cues that are still active belong to nothing.
The correct teardown sequence when unbinding an ability system from an avatar:
```text
1. verify the ASC's avatar is actually this actor
2. cancel abilities
3. clear ability input bindings
4. remove all gameplay cues
5. clear the avatar
```
### Rules
1. **Check avatar identity before tearing down.** A component that unbinds
unconditionally can rip the ASC away from an avatar it no longer owns.
2. **Remove all cues explicitly.** Do not rely on the removal of the effects that
added them; direct-call cues (route 2) have no effect behind them.
3. **Test the cycle twice.** Leaks and duplicates appear on the second respawn,
not the first.
---
## 9. The validation gap, and what to do about it
Nothing in the engine checks that:
- a cue tag referenced by an effect has a notify asset;
- a notify asset's tag is referenced by anything;
- a cue tag is spelled the way the notify expects.
All three failures present identically: no visual, no log, no error.
### Minimum guardrails
1. **Constrain cue tag properties** with `meta=(Categories="GameplayCue")` so
authoring uses a dropdown.
2. **Write a content validator** that resolves every cue tag in every effect
against the manager's index, and reports both directions of orphan.
3. **Log once, in development builds, when a cue fires with no handler.** This
converts the silent case into a searchable one.
4. **Keep cue tags in one namespace with one owner.** A cue tag placed in the
wrong namespace because "the notify happens to live there" is a rename waiting
to happen — and renames need redirects (see `ue-gameplay-tag-governance`).
---
## 10. Review checklist
- [ ] No gameplay logic inside any notify.
- [ ] Feature works identically with all cue assets absent.
- [ ] `OnActive` and `WhileActive` converge on the same visual state.
- [ ] Everything spawned in the duration forms is destroyed in `OnRemove`.
- [ ] Static notifies hold no per-instance state.
- [ ] Actor notifies exist only where there is something to tear down.
- [ ] One addressing route per cue tag.
- [ ] Cue assets are in the client bundle only.
- [ ] Cue parameter round-trip verified for every field used.
- [ ] Feature-plugin cue paths registered at the earliest lifecycle phase.
- [ ] Path removal implemented, asserted, and using the same manager instance.
- [ ] Load policy is reachable, and any "knob" is a real console variable.
- [ ] Teardown checks avatar identity and removes all cues.
- [ ] A validator reports orphan tags and orphan notifies.
---
## 11. Tests
### Behaviour
- one-shot cue fires once per execution, not per client-side prediction retry;
- looping cue starts on `OnActive`, is matched by `WhileActive` for a late
joiner, and stops on `OnRemove`;
- `OnRemove` without a preceding `OnActive` does not crash or leak;
- cue with no registered notify produces no error and no side effect.
### Network
- dedicated server + remote client: gameplay results identical with and without
cue content;
- client joining mid-effect sees the same state as a client present at the start;
- relevancy lost and regained during a looping cue converges to the correct state.
### Lifecycle
- death and respawn twice: no duplicated or orphaned cues;
- feature plugin activate, deactivate, activate: cue path count returns to its
original value;
- possession change mid-cue.
### Cost
- first-fire hitch measured for a non-preloaded cue;
- preloaded set size and residency reported by a diagnostic command that has been
verified to read a live code path.
---
## 12. When not to use a cue
Use something else when:
- the effect must be guaranteed — it is gameplay, not presentation;
- the receiver needs a return value or acknowledgement — direct call;
- the event drives UI state that must survive a missed message — replicated
state plus a local message (`ue-gameplay-messaging`);
- exactly one known actor reacts, in the same module — direct call or delegate.
A cue earns its cost when presentation is genuinely optional, genuinely
many-to-many, and genuinely absent on the server. Used for anything else, it is a
tag join with no validation and unreliable delivery — which is a description of a
bug that has not happened yet.
---
## Provenance
The failure modes and the worked analysis in `references/` come from a
line-by-line audit of Epic's Lyra Starter Game on Unreal Engine 5.6, read as
source rather than run. The cue subsystem there is unusually instructive because
much of its preload machinery is present, maintained, and unreachable in the
shipped configuration — a shape that reading the class list will never reveal.
Source addresses stay in the research archive that produced this skill; what ships
is the detection recipe. Each entry carries a stable identifier (`GC-01` and up)
that resolves back to the audited location, so any specific claim can be produced
on request.
## Evidence boundary
The measured material refers to one project on one engine version. It is evidence
of failure modes, not a guarantee about other versions. Re-run the detection
recipes against your own tree before acting on any specific claim.
@@ -0,0 +1,665 @@
# Failure modes: GameplayCues
Twenty ways a cue-based presentation layer fails without saying so.
The shared property of this list, stated once so it is not repeated twenty times:
**a cue defect is silent by construction.** The mechanism is a tag join with no
validation, delivering an event that is *allowed* to be missed, to a layer that
does not exist on the server. Every diagnostic has to be built deliberately,
because the engine will not volunteer one.
Each entry gives the mechanism, why it stays silent, why the obvious check misses
it, the symptom a human reports, a `Detect` recipe you can run against your own
tree, and the guardrail. Identifiers (`GC-01` and up) are stable and resolve back
to the audited source in the research archive.
Detection recipes use `rg` (ripgrep). They were run against the audited project
while this file was written; where a first-draft recipe gave a wrong answer, the
corrected form is the one printed here and the trap is called out.
---
### GC-01 - Gameplay logic inside a notify
**Mechanism.** Damage, scoring, state changes or spawning placed in a cue notify,
because the notify is where the designer was already working.
**Why it is silent.** In the editor and on a listen server the notify runs on the
same process that owns gameplay, so the write lands and the feature appears
correct. Nothing anywhere declares that a notify is presentation-only.
**Why the obvious check misses it.** Code review of the notify reads as reasonable
gameplay code, and it *is* reasonable — in the wrong file. The defect is not in the
statement, it is in the file the statement is in. No compiler, linter or test that
runs in-editor can see it.
**Symptom.** The feature works for everyone during development and simply does not
happen on a dedicated server. Reported as "the server build is broken".
**Detect.** Look for writes inside notify classes:
```bash
rg -n --type cpp -g '*Cue*' \
"(ApplyGameplayEffect|AddScore|->Server|SpawnActor|SetHealth|=\s*true;)" \
Source/
```
Stronger, and the one that actually settles it: run the feature on a dedicated
server with every cue asset removed. Gameplay results must be identical.
**Guardrail.** A notify may read state and produce audiovisual output, nothing
else. Review every notify for writes, and keep the dedicated-server-without-cues
run in the acceptance list for any feature that ships a cue.
---
### GC-02 - Cue tag with no notify asset
**Mechanism.** An effect references a cue tag; no notify is registered for it —
a typo, an unscanned directory, or a plugin that failed to register its path.
**Why it is silent.** An unhandled cue is a legal runtime state: the engine
delivers the event to a registry that has no entry, and returns. Missing
presentation is exactly what "presentation is optional" means, so there is nothing
for the engine to complain about.
**Why the obvious check misses it.** The tag exists, the effect compiles, the asset
cooks. Both halves of the join are individually valid; only their *relation* is
broken, and nothing in the toolchain owns that relation.
**Symptom.** Silence. No visual, no log, no error. The artist is told the effect
"doesn't work" and starts editing a notify that was never being called.
**Detect.** Compare the two sides of the join. Tags referenced by content versus
tags claimed by notifies:
```bash
rg -no "GameplayCue\.[A-Za-z0-9_.]+" Content/ Source/ | sort -u
```
Feed both sets into a diff. Then prove your tooling works by deliberately
misspelling one cue tag and confirming the check reports it — a validator nobody
has seen fail is not known to work.
**Guardrail.** Constrain cue tag properties with `meta=(Categories="GameplayCue")`
so authoring uses a dropdown; add a content validator that resolves every
referenced tag against the runtime index; log once per unhandled cue in
development builds.
---
### GC-03 - Notify asset that no tag references
**Mechanism.** The reverse orphan: a notify exists for a tag nobody fires,
usually left behind by a rename or a cancelled feature.
**Why it is silent.** An unreferenced notify is still scanned, indexed and
sometimes preloaded. It behaves exactly like a notify waiting for its first
trigger, which is a normal state.
**Why the obvious check misses it.** Reference-viewer style checks ask "what does
this asset reference?", not "who addresses this asset by string?" Nothing
references a notify by hard pointer — that is the entire point of the design.
**Symptom.** No symptom at all, which is why it accumulates. It surfaces as
unexplained package count and memory in the cue set.
**Detect.** Same recipe as GC-02, read in the other direction: tags claimed by
notify assets minus tags referenced by effects. Run the validator both ways in one
pass, and report both orphan directions separately.
**Guardrail.** One validator, two directions, in CI. Orphan notifies are deleted
in the same commit that finds them, or the list stops being read.
---
### GC-04 - Per-instance state on a Static notify
**Mechanism.** A Static notify runs on the class default object. A member written
during one invocation persists into every later invocation, including other
players'.
**Why it is silent.** Single-player and single-source testing never overlaps two
invocations, so the shared member always holds the value the current caller just
wrote. The bug requires concurrency to appear.
**Why the obvious check misses it.** The class looks like an ordinary object with
ordinary members. Nothing at the declaration site says "this is a singleton" — the
CDO behaviour comes from how the cue manager dispatches, which is in a different
file entirely.
**Symptom.** One player's cue reads another player's data. Effects that are correct
alone and wrong in multiplayer, intermittently.
**Detect.** Find mutable members on Static notify classes:
```bash
rg -n -A40 "class .*GameplayCueNotify_Static" Source/ \
| rg -n "^\s*(float|int32|bool|F[A-Z]\w+|T\w+<)[^()]*;"
```
Any non-const, non-config member on a Static notify is the defect.
**Guardrail.** Static notifies hold no mutable state. If state is needed, it is an
Actor notify — that is what the distinction is for.
---
### GC-05 - Looping cue that is never removed
**Mechanism.** `OnActive`/`WhileActive` spawns a persistent effect; `OnRemove`
does not destroy it, or never runs because the owning effect was removed by a path
that skipped cue removal.
**Why it is silent.** A spawned particle system is a legitimate long-lived object.
Nothing distinguishes "still playing because the effect is active" from "still
playing because nobody stopped it".
**Why the obvious check misses it.** The first death-respawn cycle usually looks
clean, because the leak needs a second cycle to become visible as duplication.
Most manual testing stops at one cycle.
**Symptom.** Accumulating VFX and audio across respawns; frame time degrading over
a match; a bug report that says "it gets worse the longer you play".
**Detect.** For each duration-form notify, check that the removal path destroys
what the activation path spawned:
```bash
rg -n "OnActive|WhileActive|OnRemove" Source/ -g '*Cue*' -A15 \
| rg -n "(SpawnSystem|SpawnEmitter|SpawnSound|Destroy|Deactivate)"
```
Then count active cue actors before and after two full death-respawn cycles. The
count must return to baseline, not merely stop growing.
**Guardrail.** Everything the duration forms spawn, `OnRemove` destroys. Write
`OnRemove` to be safe when `OnActive` never ran on that client.
---
### GC-06 - `OnActive` without `WhileActive`
**Mechanism.** Start-of-loop logic placed only in `OnActive`, which fires only for
clients present at the moment the effect began.
**Why it is silent.** For every client who was present, the behaviour is perfect.
The failure exists only in the experience of a client who was not, and that client
sees an absence — the hardest thing to notice.
**Why the obvious check misses it.** Both callbacks exist on the class, so a
structural review ticks the box. The difference between them is a delivery-timing
property of the engine, not a property of the code you are reading.
**Symptom.** Players who join, or who lose and regain relevancy, see nothing while
everyone else sees the effect. Reproduces only with a real client join, never in a
single-process test.
**Detect.** Find notifies that implement one callback and not the other:
```bash
rg -l "OnActive" Source/ -g '*Cue*' > /tmp/active.txt
rg -l "WhileActive" Source/ -g '*Cue*' > /tmp/while.txt
comm -23 <(sort /tmp/active.txt) <(sort /tmp/while.txt)
```
Anything listed implements the start and not the catch-up.
**Guardrail.** `OnActive` and `WhileActive` must converge on the same visual state.
Test by joining a match mid-effect, and by losing and regaining relevancy during a
loop.
---
### GC-07 - Two addressing routes for one tag
**Mechanism.** The same cue tag is fired both by a gameplay effect and by a direct
ability-system call, or additionally handled through the cue interface on the
actor.
**Why it is silent.** Each route is individually correct and each was added by
someone solving a real problem. The duplication is a property of the pair, which
no single author sees.
**Why the obvious check misses it.** Searching for the tag finds both sites, but
they look like they belong to different features — one in an effect asset, one in
C++. Nothing marks a cue tag as having an owner.
**Symptom.** Doubled effects, appearing only when both paths trigger, often only in
one game mode. Reported as "the explosion is twice as loud in Team Deathmatch".
**Detect.** For each cue tag, enumerate every firing route:
```bash
TAG="GameplayCue.Fire.Impact"
rg -n "$TAG" Source/ Content/ Config/
rg -n "(ExecuteGameplayCue|AddGameplayCue|RemoveGameplayCue)" Source/ -A2 | rg -n "$TAG"
```
More than one owner is the defect, even when the current behaviour looks right.
**Guardrail.** One owner per cue tag, documented at the tag declaration. Search
every route before adding a firing site.
---
### GC-08 - Manual cue added without a manual removal
**Mechanism.** A direct add call has no effect behind it, so no effect removal will
ever clean it up.
**Why it is silent.** The add succeeds and the presentation appears. Removal is a
separate call that nobody is forced to write, and the object model does not record
that a removal is owed.
**Why the obvious check misses it.** The code that adds is present and correct. The
defect is the absence of a second call on every exit path, and absence is what
review is worst at seeing.
**Symptom.** A looping cue that survives the state that created it — including
across death, possession change and disconnect.
**Detect.** Count adds against removes:
```bash
rg -c "AddGameplayCue" Source/
rg -c "RemoveGameplayCue" Source/
```
An imbalance is a lead, not a verdict — then check each add site for a paired
removal on *every* exit path, not just the happy one.
**Guardrail.** Direct-call cues need an explicit paired removal on every exit path.
Prefer attaching the cue to an effect's lifetime instead, so removal is structural
rather than remembered.
---
### GC-09 - Teardown without an avatar identity check
**Mechanism.** A component unbinds an ability system component unconditionally,
without checking that the ASC's current avatar is still this actor.
**Why it is silent.** The unbind succeeds. The damage is done to a *different*
actor's state — the one the ASC has already moved to — so the error surfaces far
from its cause.
**Why the obvious check misses it.** The teardown code is correct in isolation and
correct in the common case. It is wrong only in the ordering where the avatar
changed first, which single-process testing rarely produces.
**Symptom.** Abilities and cues on the *current* avatar are cancelled when an old
one is cleaned up. Looks like a spontaneous ability cancellation with no cause.
**Detect.** Find unbind paths and check for an identity guard above them:
```bash
rg -n -B8 "(ClearAbilityInput|CancelAbilities|RemoveAllGameplayCues|SetAvatarActor\(nullptr)" Source/ \
| rg -n "GetAvatarActor\(\)\s*==|IsValid\(.*Avatar"
```
An unbind path with no `GetAvatarActor()` comparison above it is the defect.
**Guardrail.** Verify the ASC's avatar is this actor before cancelling abilities,
clearing input and removing cues. The audited project does this correctly; the
guard is one comparison and it is worth copying verbatim.
---
### GC-10 - Cue assets in the server bundle
**Mechanism.** Cue content is assigned to an asset bundle that the dedicated
server loads.
**Why it is silent.** Loading extra assets is never an error. The server runs
correctly, just fatter, and nothing reports "you loaded a particle system you can
never play".
**Why the obvious check misses it.** Bundle assignment lives in asset manager
configuration, not next to the cue content. Reviewing the cue does not show you
which bundle it is in.
**Symptom.** Server memory spent on particles and audio. Discovered, if ever,
during a memory investigation months later.
**Detect.** Find where cue paths are registered and which bundle constant is used:
```bash
rg -n "AddGameplayCueNotifyPath|GameplayCueRefs|LoadState" Source/ -B3 -A3
```
The bundle name at that site should be the client bundle. Cross-check against
the asset manager's bundle definitions.
**Guardrail.** Cue paths belong to the client bundle only. Make the bundle constant
explicit at the registration site so review can see it without opening
configuration.
---
### GC-11 - Load policy that short-circuits the whole subsystem
**Mechanism.** A load-mode constant causes an early return at every switch site,
including the one that binds the subsystem's delegates.
**Why it is silent.** Every branch is a legal configuration. "Load everything
upfront" is a sensible mode; the fact that choosing it disables an entire preload
subsystem is an emergent property of three separate switch statements, none of
which says so.
**Why the obvious check misses it.** The preload machinery exists, is maintained,
and is full of correct code. Reading it tells you nothing about whether it runs.
Worse, the diagnostic command built for it reports **zero**, which reads as "there
is nothing to preload" rather than "this code never executes".
**Symptom.** A named feature that has no effect. Engineers investigate the preload
implementation for a bug that is not in it.
**Detect.** Find the mode constant, then find every switch on it, then check which
branch each falls into:
```bash
rg -n "static .*LoadMode\s*=" Source/
rg -n "switch\s*\(.*LoadMode" Source/ -A12
```
If the configured value hits an early `return` at a site that also performs
delegate binding, everything below that binding is unreachable. In the audited
project three switch sites short-circuit, and the third returns before its
delegate bindings.
**Guardrail.** Verify the load policy reaches its intended branch. Treat "the dump
command shows nothing" as a question about reachability, not about content.
---
### GC-12 - A constant presented as a runtime knob
**Mechanism.** A local `const bool` or a `static` variable placed in a namespace
named after console variables, with no console-variable registration.
**Why it is silent.** It compiles and behaves as the constant it is. The naming is
the only thing that lies, and naming is not checked by anything.
**Why the obvious check misses it.** The namespace name contains the word an
engineer greps for. Searching for the knob finds the declaration, which looks
exactly like the seven real registrations elsewhere in the project.
**Symptom.** Engineers try to change behaviour from the console and cannot. Time is
lost concluding that the console, the build, or the module is broken.
**Detect.** Compare pseudo-knobs against real registrations:
```bash
rg -n "^namespace \w*Cvar\w*" Source/ # namespaces that promise knobs
rg -c "FAutoConsoleVariableRef" Source/ # registrations that deliver them
```
Note the trap: searching for the literal `namespace Cvars` finds nothing, because
real names are prefixed (`FooManagerCvars`). The first draft of this recipe
returned zero hits and would have been read as "no such problem here". In the
audited project the corrected recipe finds one such namespace against eight real
registrations, and inside that namespace only a console *command* is registered —
the two variables beside it are plain constants.
**Guardrail.** If it must be tunable, register it as a real console variable.
Otherwise do not name its scope after one.
---
### GC-13 - A collection that is iterated but never filled
**Mechanism.** A local container is declared, immediately looped over, and never
populated — usually with a comment where the population belongs.
**Why it is silent.** A loop over an empty container is a no-op, which is
indistinguishable from a loop whose input happens to be empty right now.
**Why the obvious check misses it.** The loop body is real code, often including
error handling and logging, so the feature reads as implemented. There is even a
warning path for invalid entries — which can never fire, because there are no
entries.
**Symptom.** A named feature ("always loaded cues") that has no inputs and
therefore no effect, while appearing complete in review.
**Detect.** Find containers declared and iterated within a few lines, with no
insertion between:
```bash
rg -n -A6 "T(Array|Set)<\w+>\s+\w+;" Source/ | rg -n "for\s*\("
```
For each hit, search the same scope for `Add|Emplace|Append|Insert` on that name.
None means the loop is decorative.
**Guardrail.** A loop over an empty local is a review blocker. If the population is
future work, the loop is future work too.
---
### GC-14 - Feature-plugin cue path registered too late
**Mechanism.** Registration is performed on feature activation, after the manager
has already built its tag-to-asset index.
**Why it is silent.** Registration succeeds. The path is in the list. It is simply
not in the *index*, and no error distinguishes those two states.
**Why the obvious check misses it.** In the editor, incidental rescans — asset
saves, hot reloads, content browser operations — rebuild the index often enough
that the cues appear to work. The failure is specific to a cold packaged run.
**Symptom.** A plugin's cues are invisible in a packaged build and fine in the
editor. The classic "works on my machine" that is actually "works in my editor".
**Detect.** Find where cue paths are added and which lifecycle phase the call sits
in:
```bash
rg -n "AddGameplayCueNotifyPath" Source/ -B12 \
| rg -n "(OnGameFeatureRegistering|OnGameFeatureLoading|OnGameFeatureActivating|Activate)"
```
`Registering` is early enough; `Activating` is not. Also check the rescan flag: one
rescan per feature, not one per directory.
**Guardrail.** Register on the earliest lifecycle phase available, via a policy
observer rather than an activation hook. Batch the rescan: add all paths, rebuild
once. The audited project does exactly this, and its feature action deliberately
has no activation body at all.
---
### GC-15 - Asymmetric manager access across the lifecycle
**Mechanism.** Registration goes through a subclassed manager; removal goes through
the engine base class.
**Why it is silent.** Both calls compile and both succeed, because the subclass
inherits the base method. As long as the subclass adds no bookkeeping, the two
paths are equivalent — today.
**Why the obvious check misses it.** The two lines are in different functions,
often hundreds of lines apart, and both read as "get the manager, call the
method". The asymmetry is only visible when you put the two resolution expressions
side by side.
**Symptom.** Nothing, until the subclass gains state. Then removal silently leaves
entries behind and the registry drifts across activation cycles.
**Detect.** Put the add and remove sites next to each other:
```bash
rg -n "(AddGameplayCueNotifyPath|RemoveGameplayCueNotifyPath)" Source/ -B4 \
| rg -n "(GetGameplayCueManager|UAbilitySystemGlobals|Cast<)"
```
Different resolution expressions on the two sides is the defect.
**Guardrail.** Resolve the manager the same way on both sides, and assert the
removal count. The audited project asserts the count — that assertion is the
mitigating factor and the part worth copying.
---
### GC-16 - Registry refresh on the way in but not on the way out
**Mechanism.** The primary-asset registration is refreshed when paths are added and
not when they are removed.
**Why it is silent.** A stale registration describes directories that no longer
exist. Asset lookups against them fail the same way a genuinely empty directory
fails: nothing found, no error.
**Why the obvious check misses it.** The refresh call on the add side is visible
and correct. Its absence on the remove side is one missing line in a function that
otherwise does everything right, including asserting its own removal count.
**Symptom.** Divergence that accumulates across feature activation cycles. Path
counts do not return to baseline after activate, deactivate, activate.
**Detect.** Count refresh calls against path-set mutations:
```bash
rg -n "RefreshGameplayCuePrimaryAsset" Source/ -B15 \
| rg -n "(Registering|Unregistering|Add|Remove)"
```
A refresh on the registering path with no counterpart on unregistering is the
defect. Confirm behaviourally: activate, deactivate, activate a feature and
compare path counts.
**Guardrail.** Refresh on every path-set change, in both directions.
---
### GC-17 - Lossy payload conversion helper
**Mechanism.** A helper that converts between a message struct and cue parameters
drops fields.
**Why it is silent.** The conversion produces a valid, well-formed result. The
missing field arrives as its default value, which for a tag container is empty —
and empty is a legal value that means "no tags".
**Why the obvious check misses it.** The function has a name that promises a
conversion, a signature that mentions both types, and a body that assigns most
fields. Review confirms "yes, this converts". Only a field-by-field round trip
shows the hole.
**Symptom.** A field populated at the sender and empty at the receiver, on every
client, with no warning. Investigated as a replication bug for as long as it takes
to read the helper.
**Detect.** Read every conversion function field by field, and treat every
unfinished-work comment inside one as a declaration that the field is not carried:
```bash
rg -n -A20 "(ToCueParameters|FromCueParameters|VerbMessageTo|CueParametersTo)" Source/ \
| rg -n "(TODO|FIXME|\?\?\?)"
```
In the audited project this finds the same field dropped in both directions, each
marked with its own comment.
**Guardrail.** Verify the round trip field by field before relying on any field.
Treat any unfinished-work comment in a conversion function as "this field is not
carried".
---
### GC-18 - Concluding "unused" from absent C++ callers
**Mechanism.** A Blueprint-callable function's call sites can live in binary asset
graphs, which are not text-searchable.
**Why it is silent.** The search is clean. Zero hits is a definite-looking answer,
and definite-looking answers are the ones people act on.
**Why the obvious check misses it.** The obvious check *is* the problem: `rg`
searches text, and the callers are not text. The tool answers the question it was
asked — "where does this string appear in source?" — which is not the question the
engineer meant.
**Symptom.** Code deleted as dead breaks a Blueprint at runtime, discovered after
the fact, usually by a designer.
**Detect.** For any Blueprint-exposed symbol, treat source search as inconclusive
by construction:
```bash
rg -n "UFUNCTION\(.*Blueprint(Callable|ImplementableEvent|NativeEvent)" Source/ -A2
```
Anything on that list needs the editor's reference viewer before removal. An empty
`rg` result proves the pattern, not the absence.
**Guardrail.** "No C++ callers" is not evidence of disuse for a Blueprint-exposed
symbol. Record it as an open question, not as dead code — which is exactly what the
audit did with the helpers from GC-17.
---
### GC-19 - Cue tag parked in the wrong namespace
**Mechanism.** A tag is declared next to the notify asset that happens to implement
it, rather than in the namespace that owns the concept.
**Why it is silent.** The tag works. Its location is a naming decision, and naming
decisions have no runtime behaviour — until someone tries to fix one.
**Why the obvious check misses it.** Nothing validates that a tag's namespace
matches its meaning. The only record that the placement is wrong is, at best, a
comment written by the person who did it.
**Symptom.** Moving it later requires a redirect. Without redirects, every asset
referencing it silently loses the reference — the rename is not blocked, it is
merely destructive.
**Detect.** Read the tag declarations for confessions, then check whether the
project has any migration mechanism at all:
```bash
rg -n "DevComment=.*(needs to move|wrong|temporary|should be)" Config/
rg -c "GameplayTagRedirects" Config/
```
In the audited project the first search finds a tag whose own comment says it is
misplaced, and the second returns zero — the tag cannot be moved without breaking
references.
**Guardrail.** Cue tags follow the concept, not the asset location. Add the
redirect in the same commit as any move, and make sure a redirect mechanism exists
before you need it.
---
### GC-20 - Editor measurements treated as representative of cue loading cost
**Mechanism.** The editor loads both the client and the server asset bundles.
**Why it is silent.** The measurement completes and produces a number. Numbers
from a profiler carry an authority that their provenance does not.
**Why the obvious check misses it.** The condition that causes it is one clause in
one branch of the bundle-selection logic, in a different subsystem from the one
being measured. Nobody profiling cue residency reads the experience loader.
**Symptom.** Memory and load-time figures that describe no shipping configuration.
Cue residency in particular is overstated, and budgets built on it are wrong in the
safe direction until the day they are not.
**Detect.** Read the bundle-selection branch before trusting any in-editor asset
memory number:
```bash
rg -n "GIsEditor\s*\|\|" Source/ -A4
```
If the editor branch unions both bundle sets, editor measurements are usable for
direction of change only.
**Guardrail.** Use editor measurements for direction of change. Absolute cue
loading cost requires a packaged build, and any budget quoted without one carries
that caveat in writing.
@@ -0,0 +1,200 @@
# Patterns: a cue subsystem read end to end
A worked reading of one real GameplayCue subsystem, audited as source. It is here
because the cue layer has a property that makes reading a class list useless:
**large parts of it can be present, correct, maintained, and unreachable**, and
nothing in the structure says which parts.
Individual failures are in [failure-modes.md](failure-modes.md), one entry each,
with a recipe. This file is about the shapes — what the subsystem is for, where
its cost lives, and which of its parts were actually running.
Markers: **[measured]** — read in source; **[derived]** — conclusion from measured
facts; **[open]** — not settled by source reading, and left open.
---
## 1. The trade the mechanism makes
A cue replaces an asset reference with a string-like tag, and a registry resolves
the tag by scanning content directories.
What you buy is real: gameplay assets carry no presentation payload, presentation
ships in a separate plugin, and the whole layer is absent on a dedicated server.
What you pay is one specific thing, and it is worth naming precisely:
> **You have exchanged a reference the toolchain checks for a join the toolchain
> does not check.**
A hard pointer that dangles is a cook error. A tag with no notify is silence.
Both halves of the join stay individually valid — the tag exists, the asset
exists — and no tool owns the relation between them. Everything in this file
follows from that one exchange.
This is why cues are an application of tag governance with a loading subsystem
attached, rather than a feature in their own right. Every rule about string joins
applies here first, and the loading subsystem is where the surprises live.
---
## 2. Where the complexity actually is
Reading the audited manager, the code divides into three very unequal parts:
| Part | Size | Status in the shipped configuration |
|---|---|---|
| Dispatch: tag arrives, notify is invoked | small | running |
| Discovery: scan directories, build the tag-to-asset index | moderate | running |
| Preload: async library load, preload set, always-loaded tags, reference tracking, GC and map-transition hooks | **most of the file** | **unreachable** |
The third row is the finding, and it is a shape rather than a bug.
A load-mode constant is set to "load everything upfront" **[measured]**. That
value short-circuits three separate switch sites **[measured]**, and the third of
them returns before the statements that bind the subsystem's delegates
**[measured]**.
The consequence **[derived]**: tag-loaded, post-garbage-collect and post-load-map
handlers are never bound; the preload set, the always-loaded set and the reference
tracker are never populated; and the project's own diagnostic command reports
zero, forever.
**That last detail is the trap.** A diagnostic reporting zero reads as "there is
nothing to preload". It actually means "this counter is dead". Anyone
investigating cue memory starts from a number that is not measuring anything, and
the natural next step — reading the preload implementation for a bug — is a day
spent in correct code. Recipe: GC-11.
The transferable rule is not about cues at all:
> Before debugging a subsystem, establish that its code runs. A diagnostic that
> reports zero is a claim about reachability until proven otherwise.
---
## 3. Knobs that are not knobs
In the same file, a namespace named after console variables contains three
declarations **[measured]**. Exactly one — a console *command* — is registered
with the engine. The other two, including the load mode from §2, are plain
statics **[measured]**.
Project-wide the ratio is one such namespace against eight real console-variable
registrations **[measured]**.
Two things make this worth an entry rather than a footnote:
1. **The naming is the entire defect.** The code is correct; it is a constant and
behaves as one. What lies is the scope name, and nothing checks scope names.
2. **The obvious search fails.** Grepping for the literal `namespace Cvars` finds
nothing, because real names are prefixed with the owning type. The first draft
of the detection recipe for this returned zero hits and would have been read as
"we do not have this problem". The corrected recipe searches for any namespace
whose name *contains* the token. This is recorded in GC-12 with the trap
spelled out, because the trap generalises further than the finding does.
---
## 4. Feature-plugin registration: the one thing done right
Cue path registration from a feature plugin has a timing constraint that is easy
to get wrong and invisible when you do: the manager builds its index during its
object library scan, so a path added after that scan is not indexed until
something triggers a rescan. In the editor, incidental rescans hide it. In a cold
packaged run, nothing does. Recipe: GC-14.
The audited project solves it with a pattern worth copying verbatim
**[measured]**:
> **The feature action is a pure data declaration; a lifecycle observer is the
> executor.**
The action object declares the directory list and validates it in the editor — and
has *no activation body at all* **[measured]**. A separate observer registered
with the feature policy listens for the earliest lifecycle phase and performs the
registration for every action of that type it finds. Paths are added with the
rescan flag off, and a single index rebuild follows **[measured]**.
The empty activation body is the part people delete when adopting this, because an
action with no `Activate` looks unfinished. It is not: it is the point.
### And the asymmetry that survived inside it
The same function pair is also the audit's best example of a near-miss
**[measured]**:
| Direction | Manager resolution | Refresh call |
|---|---|---|
| Registering | project's own manager subclass | present |
| Unregistering | engine base class | **absent** |
Both calls compile and both work, because the subclass inherits the method. They
are equivalent exactly as long as the subclass adds no bookkeeping **[derived]**.
And the refresh performed on the way in has no counterpart on the way out, so the
asset manager keeps describing directories that have been removed **[derived]**.
Recipes: GC-15, GC-16.
**What redeems it, and what to actually copy:** the removal path counts what it
removed and asserts the count against what was added **[measured]**. In an audit
whose dominant finding across the whole project was "add works, remove is
incomplete", this was the one place where teardown was both implemented and
asserted. Copy the assertion; fix the asymmetry it sits next to.
---
## 5. The network rule, and why it is first
Cues never run on a dedicated server, because the presentation layer does not
exist there.
Every consequence of that is a design constraint rather than a caution:
- a notify may not carry gameplay side effects — on a dedicated server that code
never executes (GC-01);
- cue parameters are a lossy channel, and what a conversion helper drops is
dropped silently on every client. The audited helpers drop a context tag
container in **both** directions, each with its own unfinished-work comment
**[measured]** (GC-17);
- delivery is unreliable by design: packet loss, relevancy changes and mid-effect
joins all produce missed cues;
- cue assets belong in the client bundle only.
The acceptance test that covers all four at once is cheap and nobody runs it: **run
the feature on a dedicated server with every cue asset removed, and require
byte-identical gameplay results.**
---
## 6. What source reading could not settle
Two limits, stated because they bound the claims above:
1. **Blueprint call sites are invisible to text search.** The conversion helpers
in §5 have zero C++ callers and are Blueprint-callable **[measured]**. That is
recorded as **[open]** — an open question, not dead code. Deleting them on the
strength of an empty `rg` result is GC-18, committed by the person who wrote
the audit. (It was not.)
2. **Editor memory figures do not describe shipping.** The experience loader's
bundle selection unions client and server bundles when running in the editor
**[measured]**, so in-editor cue residency is overstated by whatever the server
set contains. Editor measurements are good for direction of change and nothing
else (GC-20).
---
## Provenance
Measured against Epic's Lyra Starter Game on Unreal Engine 5.6, read as source
rather than run, in a single workspace. Source addresses stay in the research
archive that produced this skill; each `GC-` 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. These are examples and failure
evidence, not guarantees about other engine versions or other samples. Several
claims are explicitly marked open and stay open. Re-run the recipes in
[failure-modes.md](failure-modes.md) against your own tree before acting on
anything here.