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,343 @@
---
name: ue-reference-project-adoption
description: >-
Adopt architecture from an Unreal Engine reference or sample project (a
first-party starter game, an engine template, an internal legacy title)
without inheriting its shortcuts: classify each borrowed element as defect,
showcase stub, deliberate sample-scope narrowing, architecture tax or
foreign-product legacy, then decide copy/close/skip per element. Use when
starting a project from a template, lifting a subsystem out of a sample,
reviewing code that was copied from one, or explaining why a setting copied
from the reference does nothing.
---
# UE reference project adoption
The invariant:
> A reference project shows you a **shape**, not a **contract**. Anything you copy
> without finding its consumer, its writer and its owner is a shape you now have to
> finish yourself.
Worked classification, tax analysis and vocabulary hygiene:
[patterns](references/patterns.md).
Nineteen detection recipes for silent failures:
[failure modes](references/failure-modes.md).
Read the failure modes before adopting or reviewing borrowed architecture. 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-architecture-guardrails`, `ue-modular-gameplay`,
`ue-multiplayer-authority`, `ue-gameplay-tag-governance`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. The one thing that makes this hard
Broken code announces itself. It crashes, it logs, it fails a test.
An unfinished mechanism announces nothing. It returns a neutral value forever, and
that neutral value is indistinguishable from "the feature is off right now".
So the ranking that matters during adoption is **not severity**. It is *probability
of copying it without noticing*. Those are close to inverted: the most dangerous
elements in a mature sample project are the ones that never misbehave.
### Severity is not copyability
| | Pattern is safe to copy | Pattern is unsafe to copy |
|---|---|---|
| **Fails loudly** | plain defect — fix the line, keep the design | — |
| **Never fails** | foreign legacy — rename and keep | **stubs and scope narrowings** |
| **Works as intended** | tax you can afford | tax sized for someone else |
Only the bottom-right cell costs a subsystem rewrite instead of a line edit.
---
## 2. Five categories, and the decision each one implies
Classify every element you intend to borrow. The category, not the symptom,
determines what you do.
### C1 — Defect
A mistake the reference's authors would fix if they noticed.
**Decision:** fix the line; keep the surrounding design. A defect is not evidence
against the pattern that contains it.
### C2 — Showcase stub
Declared, editable, sometimes even read — but with no writer, no consumer, or a
hardcoded neutral result. Usually marked with a TODO, often not.
**Decision:** either implement it before exposing it, or delete the field. Never
ship an editable property whose consumer is a literal constant. Designers will
spend days tuning it.
### C3 — Deliberate sample-scope narrowing
The reference intentionally did not build a subsystem, because a sample does not
need it. Nothing is broken. The reference is internally consistent.
**Decision:** this is the expensive one. Decide explicitly, at adoption time,
whether your product needs the missing subsystem — and budget it. The failure mode
is not "we copied a bug", it is "we shipped without noticing there was a hole".
### C4 — Architecture tax
Correct, working, and sized for the reference author's team, release cadence and
platform matrix.
**Decision:** adopt by *need*, not by *completeness*. Copying an entire modular
stack because it is there means paying for an answer to a question you do not have.
### C5 — Foreign-product legacy
Constants, names and comments carried over from a different title.
**Decision:** rename on entry. Harmless as code, corrosive as vocabulary — in a
year, half the team will believe the foreign term is an engine concept.
---
## 3. Four tests, run before you copy anything
Cheapest first. Most stubs die at test 3.1.
### 3.1 Find the writer, not the reader
For any tunable field, search for **assignment**, not usage.
```bash
rg -n "\bMyField\b" Source/ # declaration plus two reads: looks alive
rg -n "\bMyField\b\s*(=|\+=|-=)" Source/ # one hit, and it is the declaration
```
Watch the second line. A member declared as `float MyField = 0.f;` matches every
assignment pattern you can write, so the naive search answers "there is a writer"
in exactly the case where there is none. Subtract the declaration before you
conclude anything:
```bash
rg -n "\bMyField\b\s*(=|\+=|-=)" Source/ \
| rg -v "\b(bool|u?int\d+|float|double|F[A-Z]\w+|T\w+<)\s+MyField\b"
```
A field that is read and compared but never written is worse than an unused field:
reading the code convinces you it is wired up. This is the single highest-yield
check in this skill — and, as the two commands above show, the one whose first
draft most often lies to you.
### 3.2 Find the comparison, not the plumbing
For any field named `Priority`, `Order`, `Weight`, `Rank`, `SortKey`: search for its
use inside a sort, predicate or comparison. Being passed through six functions and
stored in a struct means nothing.
If there is no comparison, the effective ordering is *registration order*, which in
a plugin-based project means *feature activation order* — non-deterministic from
the designer's point of view.
### 3.3 Ask who fills this in
Any of these is a stub or a narrowing until proven otherwise:
- an empty container next to a TODO;
- `const bool bSomething = false;` with the real expression in a comment;
- `const bool bIsValid = true;` consumed by a validation branch;
- a parameter threaded through several call layers that is never branched on;
- a function that exists, is public, and has zero callers in the codebase.
The distinction between C2 and C3 is only whether the author ever intended to fill
it in. Both cost you the same.
### 3.4 Ask what the adversary does
For anything on a trust boundary, ask: *what stops the other machine from lying
about this fact?* If the answer is "nothing", you are looking at C3, and the cost
of closing it is yours to price.
### 3.5 The paid test
When static reading is ambiguous, change the setting and measure whether anything
moves. Expensive, and the only test that cannot be fooled. Use a written protocol
with a restore point, one knob at a time.
---
## 4. Sample-scope narrowings a product must close
These are the holes that reference projects most commonly leave, in rough order of
how much they cost to close later:
| Narrowing | Why a sample omits it | What it costs a product |
|---|---|---|
| No lag compensation / server rewind | needs a real server, real latency, real test rig | Cannot be retrofitted cheaply — it dictates how abilities, tracing and damage are structured |
| Replication graph present but disabled | tuning it requires production-scale player counts | Traffic and CPU limits discovered in beta |
| No tag/asset redirect discipline | a sample never renames anything after release | Every rename becomes a breaking change with no migration path |
| Authority checks missing on state writers | single-process PIE never exercises the boundary | Silent state divergence, cheat surface |
| Fail-open defaults on unknown input | sample never produces unknown input | Security and gameplay decisions default to "allowed" |
| Preload/streaming machinery bypassed by config | sample content fits in memory | Hitching that looks like an engine bug |
| No teardown for dynamic additions | sample never unloads a feature | Leaks and duplicate registrations on the second load |
None of these is a bug report against the reference. Each is a line item in your
adoption budget.
### The rule
> Every C3 narrowing you accept must be written down as an accepted risk with an
> owner, or scheduled. "We did not notice" is the only unacceptable outcome.
---
## 5. Architecture tax: adopt need, not completeness
A reference project's modularity is an answer to a specific question — usually
"how do several teams ship seasonal content into one binary without merge
conflicts". If you do not have that question, you pay the cost and receive nothing.
Before adopting a modular stack, state the question it answers for *you*:
- more than one team shipping into the same binary?
- content added after launch without a client patch?
- game modes swapped at runtime by data?
- platform-specific feature sets?
Zero yes answers means the stack is tax. One or two means adopt the layers that
answer them and stop there.
### The C++/Blueprint boundary is also tax
A reference project's C++/BP split reflects its author's staffing. A codebase where
C++ exposes overwhelmingly *calls* rather than *override points* assumes a strong
C++ team is always available to extend it. Copy that boundary without that team and
designers hit a wall on their first non-trivial request.
Measure both sides before copying the ratio: how many `BlueprintCallable` entry
points versus how many `BlueprintImplementableEvent` and `BlueprintNativeEvent`
extension points, and who on your team can add to the second column. Measured
counts from one reference project are in [patterns](references/patterns.md).
### Measure the whole mount surface, not the primary content root
In a plugin-based project, plugins mount their own content roots. Any census,
budget or memory metric computed over the primary game root alone is wrong by
however much the plugins hold — in one measured reference, by roughly a factor of
six. Enumerate all mount roots, not just the obvious one.
---
## 6. Foreign legacy hygiene
On entry, rename:
- constants and type names carrying another product's brand;
- asset type identifiers whose names describe another game's content;
- comments copied from a different class;
- tags whose comment admits they are in the wrong namespace.
Do this at import time. After the first sprint it becomes archaeology, and the
foreign vocabulary starts appearing in design documents.
---
## 7. Fork and drift discipline
If you copy code rather than depend on a plugin:
1. Record the reference version and commit at import time, in a file next to the
copy.
2. Keep imported code in a directory that is obviously imported.
3. Record every intentional divergence with a one-line reason.
4. Re-diff against the upstream on each engine upgrade; an unrecorded divergence is
indistinguishable from an upstream fix you are about to lose.
Without this, engine upgrades turn every borrowed subsystem into a manual merge
with no ground truth.
---
## 8. Adoption procedure
For each subsystem you intend to borrow:
1. **Name the question** it answers for your product. If you cannot, stop.
2. **Read the consumer, not the declaration.** Confirm each field has a writer and
each ordering field has a comparison.
3. **List the trust boundaries** it crosses and what validates each one.
4. **Classify every surprising element** as C1–C5.
5. **Decide per category:** fix / implement or delete / budget and schedule / trim
to need / rename.
6. **Write down accepted C3 risks** with an owner.
7. **Record the import version** and directory.
8. **Add a test that would fail** if the narrowing you accepted became a bug.
---
## 9. Review checklist
- [ ] Every borrowed tunable field has a verified writer.
- [ ] Every ordering field participates in an actual comparison.
- [ ] No editable property is consumed by a hardcoded literal.
- [ ] Every parameter threaded through layers is branched on somewhere.
- [ ] Trust boundaries are enumerated; each has a named validator or an accepted
risk.
- [ ] Missing subsystems (C3) are written down, not discovered later.
- [ ] Modular layers adopted map to a stated product need.
- [ ] C++/BP boundary matches the team that has to live with it.
- [ ] Metrics computed over all mount roots.
- [ ] Foreign-product vocabulary renamed at import.
- [ ] Import version and divergences recorded.
- [ ] Teardown exists for every dynamic addition adopted.
---
## 10. When copying wholesale is right
Adopt as-is, without this ceremony, when **all** of the following hold:
- the element is self-contained and has no trust boundary;
- you can name its consumer within one file;
- it has no editable tuning surface;
- replacing it later is a local edit, not a migration.
Small utilities, math helpers, container types and single-purpose components
qualify. Subsystems, lifecycle machinery and anything that touches replication,
persistence or the tag registry do not.
---
## 11. What this skill does not say
It does not say reference projects are bad guidance. A mature sample is the best
available evidence for *composition* — how definition and instance separate, how
readiness becomes an explicit chain, how presentation decouples from gameplay.
It says: the sample is evidence for the shape and silent about the finish. The
references exist so the silence is enumerated instead of discovered in production.
---
## Provenance
The classification, the four tests and every entry under `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. Source addresses stay in the research archive that produced this
skill; what ships is the detection recipe, because an address in someone else's
tree is not something you can act on and a recipe is.
Each entry carries a stable identifier (`RA-01`, `RA-02`, …) that resolves back to
the audited location in that archive. If you need the original address to settle a
dispute, it exists and can be produced.
## Evidence boundary
The measured material refers to one specific reference project on one engine
version in one workspace. It is a worked example of the classification, not a
defect list to carry into other engine versions or other samples. Re-run the four
tests against your own reference before trusting any specific claim.
@@ -0,0 +1,739 @@
# Failure modes when adopting a reference project
Nineteen ways a borrowed subsystem stays quiet while doing nothing.
They share one property: **each is discovered late by construction.** Every entry
here compiles, passes review, produces no warning and no log line. What is absent
is a writer, a comparison, a validator or an owner — and absence does not raise.
Each entry gives you a `Detect` you can run against your own tree today. The
recipes are deliberately crude: a grep that returns nothing where reads exist is
worth more than a subtle static analysis nobody runs.
`RA-nn` identifiers are stable and resolve to the audited location in the research
archive behind this skill.
Categories: **C1** defect · **C2** showcase stub · **C3** deliberate sample-scope
narrowing. Architecture tax (C4) and foreign vocabulary (C5) are not failure modes
and live in [patterns](patterns.md).
---
## C1 — Defects
Fix the line, keep the design. A defect is not evidence against the pattern that
contains it.
### RA-01 - Tag picker filtered to a namespace that does not exist
**Mechanism.** An editable gameplay-tag property carries a
`meta = (Categories = "Some.Root")` filter, and no tag in the project is declared
under that root. The real tags live under a different prefix.
**Why it is silent.** The filter's only job is to narrow an editor dropdown. A
filter that matches nothing produces an empty dropdown, which is visually identical
to "no tags of this kind have been authored yet". Nothing compiles differently,
nothing logs, and the property simply stays unset forever.
**Why the obvious check misses it.** The filter string is syntactically valid and
the property is genuinely declared, read and used — every "is this wired up?" search
answers yes. Nobody searches for the *filter value* as a tag prefix, because it
reads like a category label rather than a key that has to resolve. In the measured
reference this was one of only fourteen places in the entire project where tag input
was constrained at all, so the one guardrail present was also the one misconfigured.
**Symptom.** A designer opens the dropdown, sees nothing, concludes the feature is
unimplemented, and leaves the field empty. The system then behaves as if the
designer chose "none".
**Detect.** Extract every filter value and confirm each resolves to declared tags:
```bash
rg -o 'Categories\s*=\s*"([^"]*)"' -r '$1' Source/ Plugins/ | sort -u
# then, per root R:
rg -c "\"R\." Config/*.ini Source/
```
A root with zero declarations is a dead filter.
**Guardrail.** A `Categories` filter is a guardrail only if something proves it
resolves. Add a startup or CI assertion that every filter value matches at least one
declared tag; an empty picker must be an error, not a shrug.
---
### RA-02 - A misspelling that works because every side repeats it
**Mechanism.** A tag or string key is misspelled in its declaration and in every
consumer. The string is the join between config, code and platform overrides, so
identical errors on both sides join correctly.
**Why it is silent.** Correctness for a string join means *all sides agree*, not
*the string is a word*. Six identical misspellings across four files function
exactly as six correct ones would. There is no side that could disagree.
**Why the obvious check misses it.** Searching for the correct spelling returns
nothing, and an empty result reads as "this feature is not implemented here" rather
than "it is spelled differently". The compiler never sees the literal as a name, and
spell-checkers do not run over identifiers. The bug only becomes visible at the
moment someone *fixes* it at one site.
**Symptom.** Nothing at all — until a cleanup pass corrects the spelling in one
file. The join then breaks with no error, and the regression is attributed to
whatever else shipped that week.
**Detect.** Find tag literals repeated as raw strings instead of referenced through
one constant:
```bash
rg -oN '"[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*){2,}"' Source/ Config/ \
| sort | uniq -c | sort -rn | head -40
```
Any literal appearing more than once is a join with no single source of truth.
**Guardrail.** One declaration site per tag; every other site references the
constant. Then a rename is a compile error instead of a silent disconnection.
---
### RA-03 - A loop over a container that has no producer
**Mechanism.** A local container is declared next to a TODO explaining how it will
one day be filled, then iterated immediately. Nothing ever fills it.
**Why it is silent.** Zero iterations is a legal, successful outcome. The function
returns normally and reports success, because doing nothing to an empty set is
indistinguishable from doing the work correctly.
**Why the obvious check misses it.** The code reads as a complete mechanism:
declaration, loop, and a body that does real work. Review attention goes to whether
the body is correct. The missing piece is one level up — the container has a
consumer and no producer, and reviewers check consumers.
**Symptom.** A documented behaviour ("these entries are always loaded") reports zero
at runtime, and is diagnosed as a content or configuration problem for days.
**Detect.** For every container that is iterated, look for a writer:
```bash
rg -n "for\s*\(.*:\s*(\w+)\)" -r '$1' Source/ | sort -u > /tmp/iterated
# per name N:
rg -n "\bN\b\s*\.\s*(Add|Append|Emplace|Push|Insert|Reserve)" Source/
```
Reads without writes mean the loop is decoration.
**Guardrail.** Do not ship a loop over a container that has no producer in the same
translation unit or an obvious injection point. If the producer is future work, the
loop is future work too.
---
### RA-04 - Progress arithmetic with inverted operands
**Mechanism.** A startup progress fraction computes each sub-step's contribution
with the operands the wrong way round, so the reported value does not advance while
a single long step runs.
**Why it is silent.** The result stays inside the valid range and still increases
across step boundaries. It is a wrong number, not an invalid one, so no assertion
and no clamp fires.
**Why the obvious check misses it.** On a developer machine with a warm cache the
whole sequence finishes in under a second, and nobody watches one step long enough
to see it stall. The code is exercised on every launch and observed on none.
**Symptom.** On a cold shader cache or a slow disk the bar freezes for a long time
and players report a hang. The engineering response is to look for a deadlock,
because the bar is trusted.
**Detect.** Two options. Force the slow path with whatever artificial-delay cvar the
loading system provides and watch whether the value moves within a step. Or unit-test
the progress function directly: feed it a fixed step count and assert the value is
strictly increasing at every sub-step, not just at step boundaries.
**Guardrail.** Progress arithmetic gets a test. It is the canonical example of code
that runs constantly and is observed only under conditions developers do not have.
---
### RA-05 - Listener removed using the wrong channel key
**Mechanism.** A tag-addressed pub/sub subsystem walks parent tags when
broadcasting. On finding an expired listener it removes the handle using the
*original broadcast channel* rather than the ancestor tag currently being visited.
Handle identifiers are unique only within a channel.
**Why it is silent.** Both outcomes are legal operations on a map. Either the key is
absent and removal is a no-op — the stale listener survives in the parent bucket — or
a different listener happens to hold the same channel-local id and is removed from
the wrong bucket. Neither path raises.
**Why the obvious check misses it.** The removal call is present, correctly typed,
and looks right. The defect is in *which key* is passed, and both candidate keys are
in scope, same-typed, and similarly named. Type checking cannot separate them, and
review reads the statement as "remove the expired listener".
**Symptom.** A listener that was unregistered keeps receiving messages, or an
unrelated listener silently stops receiving them. The failure surfaces in whichever
system owned the collateral listener, arbitrarily far from the subsystem at fault.
**Detect.** Inside any parent-tag or hierarchy traversal, confirm every mutation
keys on the loop cursor rather than the function parameter:
```bash
rg -n -B12 "(Unregister|Remove\w*Listener|RemoveAt)" Source/ \
| rg -n "for\s*\(|ParentTag|Ancestor"
```
Then read each hit and check the key.
**Guardrail.** Make listener identity an explicit pair type — channel plus id — so
passing the wrong channel fails to compile instead of silently mis-keying.
---
## C2 — Showcase stubs
The defining property: **the declaration and the consumer both exist; the writer
does not** — or the consumer is a literal. Neither a compiler warning nor an
"unused symbol" search finds these.
### RA-06 - Editable tunable with a live reader and no writer
**Mechanism.** A designer-facing cooldown is compared against a "last event"
timestamp field. The timestamp is declared and read, and is never assigned anywhere
in the codebase.
**Why it is silent.** Time elapsed since an unset timestamp is time since world
start, which always exceeds any sane threshold. The comparison is permanently true,
so the gate the setting was meant to impose never rejects anything. Permanently
allowing is a valid runtime state, so nothing errors.
**Why the obvious check misses it.** The field is read twice and participates in a
comparison, so every "is this used?" heuristic — grep, IDE find-usages, unused-symbol
warnings — answers yes. What is missing is the **writer**, not the reader, and no
default tool asks that question. This is the highest-yield check in the whole skill
precisely because it inverts the usual direction of the search.
**Symptom.** A designer tunes the value for days, reports it has no effect, and is
told to check their data. The setting is then either abandoned or "fixed" by
changing something else that happens to correlate.
**Detect.** For every `EditAnywhere` or `EditDefaultsOnly` numeric property and for
every timestamp it is compared against, search for an assignment rather than a
mention:
```bash
rg -n "\bLastFireTime\b" Source/ # reads: several
rg -n "\bLastFireTime\b\s*(=|\+=|-=)" Source/ # one hit - and it is the declaration
```
The second search does **not** come back empty, and that is the trap. A member
declared with an initializer (`double LastFireTime = 0.0;`) matches every
assignment pattern you can write, so the naive search reports a writer that does
not exist. Subtract the declaration before concluding:
```bash
rg -n "\bLastFireTime\b\s*(=|\+=|-=)" Source/ \
| rg -v "\b(bool|u?int\d+|float|double|F[A-Z]\w+|T\w+<)\s+LastFireTime\b"
```
Empty after that subtraction, with live reads elsewhere, is a stub. Verified on
the reference: the unsubtracted search returns one line, the subtracted search
returns none.
**Guardrail.** Never ship an editor-exposed property whose value has no writer.
Every tunable needs one owning assignment site and one test that moves the value and
asserts an observable delta.
---
### RA-07 - Ordering field that is stored, copied and never compared
**Mechanism.** A `Priority` integer is accepted by eight registration overloads,
stored on the entry, copied into the request struct, and never appears in a sort, a
predicate or a comparison anywhere.
**Why it is silent.** The field has a defined value at all times and travels
correctly through the entire API. Ordering still happens — it is just registration
order. A plausible order is produced on every run, so nothing looks wrong.
**Why the obvious check misses it.** The value is written, read, copied and passed
across a large public surface. Usage counts are high. Any review that asks "is
`Priority` used?" finds a dozen sites and stops. The right question is narrower: does
it appear inside a `Sort`, `<`, `>` or predicate? That question is not one anyone
thinks to ask about a field that is obviously plumbed.
**Symptom.** Widget or handler order changes between runs and between machines. In a
plugin-based project, registration order is feature activation order, so the symptom
reads as a race condition and gets chased in the wrong subsystem.
**Detect.** Search for the comparison, not the plumbing:
```bash
F='(Priority|Order|Weight|Rank|SortKey)'
rg -n "\b$F\b" Source/ Plugins/ | wc -l # plumbing: many
rg -n "(Sort|StableSort|Algo::)" Source/ Plugins/ | rg "\b$F\b" # ordering: ?
rg -n "\b$F\s*[<>]=?\s*\w|\w\s*[<>]=?\s*\w*$F\b" Source/ Plugins/ \
| rg -v -- "->" # comparisons: ?
```
The `-v -- "->"` matters. Without it, every `Entry->Priority = X` is counted as a
comparison because the arrow contains `>`, and a field that is only ever assigned
looks like a field that is ordered. On the measured reference the naive form
reported two "comparisons"; both were assignments.
A large first number with empty second and third lines is an ordering field that
does not order.
**Guardrail.** An ordering field ships with the comparator that consumes it, in the
same change. If ordering is not implemented yet, do not expose the field.
---
### RA-08 - Tag-driven visibility with the check hardcoded off
**Mechanism.** A widget base class exposes an editable tag container for
"hide when these tags are present". Both consumer sites read
`const bool bHasHiddenTags = false;` with the real expression parked in a comment
next to a TODO. The listener registration that would supply the tags is also a
comment.
**Why it is silent.** `false` is a legitimate value: it means "no hiding tags are
active right now". The widget is visible, which is a normal state, so no test and no
reviewer can tell the difference between "correctly not hidden" and "never able to
hide".
**Why the obvious check misses it.** The TODO admits *one* missing piece and thereby
misdirects. The obvious fix — implement `bHasHiddenTags` — does not restore the
intended behaviour, because a nearby visibility setter overwrites the
designer-authored shown and hidden visibility values on its first call, which arrives
during construction. A stub can be deeper than its own TODO says it is, and a reviewer
who trusts the TODO stops one layer too early.
**Symptom.** A designer fills the tag container, observes nothing, and the class is
recorded in team lore as "the tag hiding does not work" without anyone establishing
why.
**Detect.** Find neutral literals feeding decision branches:
```bash
rg -n "const bool b\w+ = (false|true);\s*//" Source/ Plugins/
```
For each hit, check whether the property that should feed it is `EditAnywhere`. Then
check that nothing else overwrites the same state before the branch is reached — the
second step is the one people skip.
**Guardrail.** An editable property whose consumer is a literal constant must not
ship. When you do implement one, re-derive the whole path rather than the single line
the TODO names.
---
### RA-09 - Parameters threaded through for a subsystem that was never built
**Mechanism.** A `bIsSimulated` flag appears in three method signatures and is
passed through six call sites. Its sole caller passes a literal `false`. A companion
"replace this hit" method exists and is never called; the boolean recording its result
is only ever read.
**Why it is silent.** A flag that is always `false` produces one consistent code
path, which is the path everything is tested on. A method with no callers has no
behaviour to be wrong. Both are inert rather than broken.
**Why the obvious check misses it.** The threading through multiple layers is exactly
what a real, wired-up feature looks like — that shape is itself the disguise. Usage
searches return many hits. Only two narrower questions expose it: is the parameter
ever branched on, and does the sole call site pass a variable or a literal?
**Symptom.** An engineer reads the signatures, concludes server-side hit rewind
exists, and budgets zero for it. The gap is discovered when the game meets real
latency, at which point the structure of abilities, tracing and damage is already
committed.
**Detect.** Find parameters that are carried but never branched on:
```bash
rg -n "\bbIsSimulated\b" Source/ # many hits: plumbed
rg -n "if\s*\(\s*!?bIsSimulated" Source/ # none: never decides anything
rg -n "\w+\(.*\bfalse\b.*\)" Source/ | rg "bIsSimulated|Simulated"
```
Also list public functions with zero callers: `rg -n "FunctionName"` returning only
the declaration and definition.
**Guardrail.** Do not ship attachment points for a subsystem that does not exist.
An unused parameter is a claim about the architecture; if the claim is false, delete
the parameter or write the subsystem.
---
### RA-10 - Field silently dropped in both directions of a conversion
**Mechanism.** Helper functions convert between a gameplay message and cue
parameters. A context tag container present on both sides is not copied in either
direction. Both sites carry a TODO.
**Why it is silent.** The conversion succeeds and produces a fully valid object. The
dropped field simply arrives empty, and empty is the same value a caller who did not
set it would produce. There is no partial-failure signal because nothing failed.
**Why the obvious check misses it.** Round-tripping is the natural test, and a
round trip through both directions loses the same field consistently — so a
comparison of "before" against "after" on the fields anyone thought to compare
passes. Structural equality is the test that would catch it, and structural equality
is what nobody writes for conversion helpers.
**Symptom.** Effects lose their contextual tags when routed through the conversion,
so downstream selection by context silently picks the default variant.
**Detect.** For every conversion helper, compare field counts on both sides:
```bash
rg -n "^\s*(FGameplayTagContainer|F\w+|TArray<\w+>)\s+\w+;" Source/**/Struct.h
rg -n -A30 "ToOtherForm|FromOtherForm|Convert\w+" Source/ | rg "\bField\b"
```
Any field declared on both types and mentioned in neither direction is dropped.
**Guardrail.** Conversion helpers get a structural round-trip test that enumerates
fields by reflection rather than by hand. A hand-written comparison tests the fields
the author remembered, which is the same set they remembered to copy.
---
### RA-11 - Replicated structure with no callers and an empty removal hook
**Mechanism.** A replicated fast-array serializer type is fully declared, with the
removal callback implemented as an empty body. Nothing in the codebase constructs or
uses it.
**Why it is silent.** Unused replicated types cost nothing at runtime and generate no
warning. The empty callback is a valid override — many are legitimately empty.
**Why the obvious check misses it.** The type is complete and idiomatic, so it reads
as infrastructure that some subsystem depends on. Deleting it feels risky, so it
survives every cleanup. Meanwhile a reader treats its existence as evidence that
replicated messaging is solved.
**Symptom.** A team builds on top of it, assuming it works, and discovers on the
first multi-client test that no path ever populated it.
**Detect.** Find declared-but-unconstructed types:
```bash
rg -ln "struct F\w+ : public FFastArraySerializer" Source/ Plugins/
# per type T:
rg -n "\bT\b" Source/ Plugins/ | rg -v "\.h:|struct|USTRUCT|template"
```
Hits only in the header mean the type has no user.
**Guardrail.** Replication infrastructure ships with at least one caller and one
test that observes a replicated change. Untested replication is not infrastructure,
it is a plan.
---
## C3 — Deliberate sample-scope narrowings
Nothing here is broken. The reference is internally consistent with every one of
these. They become expensive only when the sample becomes the base of a product.
### RA-12 - No lag compensation, and the whole hit chain follows from it
**Mechanism.** The project contains no custom saved-move, server-move or compressed
flags implementation, and therefore no historical positions on the server. Local
targeting early-returns when the pawn is not locally controlled, so the server never
traces. A `bIsTargetDataValid` local is initialised to `true` and consumed as if it
were a validation result. Damage falloff is computed from the client's supplied trace
start, and the material multiplier from the client's supplied physical material.
**Why it is silent.** Every step is the only available behaviour given the missing
subsystem. The server cannot validate positions it never recorded, so trusting the
client is not a slip — it is the sole option. Correct-looking code all the way down,
with the gap one level below the code.
**Why the obvious check misses it.** Reviewing the damage path finds an authority
check and a validity boolean, which together look like a validation layer. The
boolean is a literal and the authority check answers a different question ("may this
instigator damage this target?") than the one that matters ("did this shot happen?").
Reading for the presence of checks finds checks; reading for what the adversary can
assert finds nothing stopping them.
**Symptom.** In production, clients report impossible hits and the diagnosis becomes
"someone forgot a validation call" — which sends engineers to patch individual sites
instead of pricing the missing subsystem.
**Detect.** Ask what the adversary supplies, then trace each such value:
```bash
rg -n "class \w+ : public FSavedMove_Character|ServerMove|CompressedFlags" Source/
rg -n "const bool b\w*Valid\w* = true" Source/
rg -n "TargetData|HitResult" Source/ | rg -i "damage|falloff|physmat"
```
No saved-move type plus client-supplied geometry in the damage math means full
client trust.
**Guardrail.** Choose a hit-registration model *before* borrowing a weapon stack:
server-authoritative with rewind, client-claim with plausibility bounds, or explicit
full trust. Write the choice down. Each has a different cost and a different cheat
surface, and the choice dictates structure you cannot cheaply change later.
---
### RA-13 - Replication graph implemented, configured, and shipped disabled
**Mechanism.** A replication graph implementation and around a dozen tuning cvars
exist. Project config disables it and the class routing table is essentially empty,
with the few entries present marked not-routed.
**Why it is silent.** The default replication path works fine at sample scale.
Disabled infrastructure produces no error and no measurable difference until player
counts and actor counts grow.
**Why the obvious check misses it.** The presence of the implementation, the cvars
and the config section reads as "this is configured". Nobody diffs the routing table
against the actor classes that actually exist, because a populated-looking config
section satisfies the eye.
**Symptom.** Bandwidth and server CPU limits are discovered in beta, at the point
where the actor class layout is fixed and re-routing is a large change.
**Detect.** Compare routing entries against replicated classes:
```bash
rg -n "bDisableReplicationGraph|ClassNodeMapping" Config/
rg -c "GetLifetimeReplicatedProps" Source/ Plugins/
```
A handful of routing entries against dozens of replicated classes means the graph
has never carried traffic.
**Guardrail.** Copying a disabled subsystem's configuration copies untested
configuration. Either enable it and measure under load, or delete the config and
record that you owe the work.
---
### RA-14 - Preload machinery made unreachable by a load-mode constant
**Mechanism.** A cue manager supports delayed loading with garbage-collect and
map-load hooks. A load-mode constant is set to "load upfront", which short-circuits
every switch site — including the one that installs the three delegates.
**Why it is silent.** Loading everything upfront is correct behaviour. The
machinery is not broken; it is bypassed. Memory is higher and nothing reports it,
because nothing is measuring against a budget.
**Why the obvious check misses it.** Searching for the delegates finds them bound in
source, so the wiring appears present. The early return that makes the binding
unreachable is in a different function, guarded by a constant that reads like a
development convenience. The project's own diagnostic command reports zeros, which is
then read as "no cues are preloaded yet" rather than "this counter is dead".
**Symptom.** A team diagnosing memory or hitching concludes the engine's preload
system is broken and descends into engine code, when the sample simply opted out.
**Detect.** Find switch sites gated by a constant, and confirm delegate binding is
reachable:
```bash
rg -n "LoadMode|ELoadMode|EEditorLoadMode" Source/ Plugins/
rg -n -B6 "AddUObject|AddRaw|BindUObject" Source/ | rg "return;|LoadUpfront"
```
**Guardrail.** Distinguish "the mechanism is broken" from "the sample configured it
off" before you spend a day in engine code. Record which subsystems the reference
bypasses; that list is part of what you are adopting.
---
### RA-15 - Public virtual state writers with no authority check
**Mechanism.** Death-state transitions are written by two public virtual methods on
a health component, neither of which checks for network authority.
**Why it is silent.** Single-process play-in-editor never separates authority from
simulation, so the code path is exercised constantly and always on the authority. The
missing check has no observable consequence in the environment where the sample runs.
**Why the obvious check misses it.** Authority checks are usually reviewed at the
RPC boundary, and these methods are not RPCs — they are ordinary virtual functions
that a caller in the right context invokes correctly. Being public and virtual is
what makes it dangerous: the class invites overriding and calling from anywhere, and
the invitation carries no precondition.
**Symptom.** A client-side subclass or a Blueprint calls the method during
prediction, the client's death state diverges from the server's, and the resulting
desync is chased as a replication bug.
**Detect.** List state writers and check each for an authority guard:
```bash
rg -n "void (Start|Finish|Set)\w*(Death|State|Team)\w*\(" Source/
rg -n -A6 "void (Start|Finish|Set)\w*(Death|State|Team)\w*\(" Source/ | rg -c "HasAuthority"
```
A count below the number of writers names the unguarded ones.
**Guardrail.** Every writer of replicated state either checks authority or is
private with a single guarded caller. Public plus virtual plus unguarded is an
invitation.
---
### RA-16 - Faction query fails open on invalid input
**Mechanism.** A team comparison returns an "invalid argument" result for unknown
membership, and the damage path treats anything that is not an explicit "same team"
as damageable. The site carries a TODO calling itself temporary.
**Why it is silent.** Unknown membership does not occur in a sample where every pawn
is assigned a team at spawn. The fail-open branch is never taken, so it is never
observed to be wrong.
**Why the obvious check misses it.** The function returns a three-valued result,
which reads as careful design. The defect is in the *consumer's* collapse of three
values into two, in a different file. Reviewing the query finds correct code;
reviewing the damage path finds a plausible boolean.
**Symptom.** Neutral, spectating or mid-transition actors take or deal damage. The
bug appears only in modes the sample does not have, which is to say, in yours.
**Detect.** Find three-valued results collapsed to booleans:
```bash
rg -n "Invalid|Unknown|Indeterminate" Source/ | rg -i "team|faction|relation"
rg -n -B4 -A4 "CanCauseDamage|CanDamage|IsHostile" Source/
```
Read the branch: does the invalid case land with "allowed" or "denied"?
**Guardrail.** Unknown must be denied, never allowed, on any decision that costs
something. If denial is not acceptable, the unknown state itself is the bug.
---
### RA-17 - No tag or asset redirects declared at all
**Mechanism.** Config declares zero gameplay tag redirects. Every tag name is a hard
string with no migration path.
**Why it is silent.** A project that has never renamed a tag needs no redirects. The
absence is invisible right up to the first rename, and samples do not rename after
release.
**Why the obvious check misses it.** This is an absence with no consumer to inspect
— there is no line of code to review. It is only visible if you go looking for a
capability you have not needed yet, which is exactly the thing nobody does during
adoption.
**Symptom.** The first tag rename after content exists breaks every asset that
referenced it. Since references live in binary assets, the breakage is silent data
loss rather than a compile error, discovered per-asset over weeks.
**Detect.**
```bash
rg -c "GameplayTagRedirects|\+ActiveGameRedirects|ClassRedirects" Config/
```
Zero, in a project that has shipped content, means renames have not yet happened —
not that they are safe.
**Guardrail.** Establish redirect discipline before the first rename, not after.
Add a CI check that a removed tag name has a corresponding redirect entry.
---
### RA-18 - Server RPC declared without validation
**Mechanism.** A quick-bar style component exposes a `Server, Reliable,
BlueprintCallable` function with no `WithValidation`, accepting an index from the
client.
**Why it is silent.** Well-behaved clients send valid indices, and the sample only
ever has well-behaved clients. Out-of-range access either clamps harmlessly or hits a
path no test covers.
**Why the obvious check misses it.** Nearby functions are marked
`BlueprintAuthorityOnly`, which reads as an access restriction and satisfies a
reviewer scanning for guards. That specifier constrains Blueprint execution context
only — it does not validate parameters, and it does not constrain C++ callers at all.
A guard that answers a different question is worse than no guard, because it stops
the search.
**Symptom.** A modified client sends an arbitrary index. Depending on the container,
this is a crash, a read of unrelated memory, or equipping something the player never
earned.
**Detect.**
```bash
rg -n "UFUNCTION\([^)]*\bServer\b[^)]*\)" Source/ Plugins/ | rg -v "WithValidation"
```
Every hit takes client-controlled input on trust.
**Guardrail.** Every server RPC validates its parameters, including range and
ownership, and `BlueprintAuthorityOnly` is never counted as validation.
---
### RA-19 - Readiness gate admits simulated proxies with no controller
**Mechanism.** An initialisation-state transition treats "has a controller" as a
precondition, but admits simulated proxies that have none, so readiness is reached
on different grounds depending on net role.
**Why it is silent.** Both branches produce "ready", and ready is the state everything
downstream waits for. No consumer asks *why* readiness was granted.
**Why the obvious check misses it.** The gate is short and reads as a careful
role-aware special case — which is what it is. The problem is that "ready" then means
two different things, and the divergence lives in every consumer rather than in the
gate. Reviewing the gate finds nothing wrong.
**Symptom.** Systems that assume a controller exists after readiness crash or
no-op on simulated proxies. The failure appears only with a remote client, so it
survives all local testing.
**Detect.** Find role-dependent readiness and enumerate its consumers:
```bash
rg -n -B4 -A10 "CanChangeInitState|HasReachedInitState" Source/ | rg "IsLocallyControlled|GetController|ROLE_SimulatedProxy"
rg -n "HasReachedInitState|GameplayReady" Source/ | wc -l
```
**Guardrail.** One readiness state means one set of guarantees. If proxies reach it
by a different route, they need a different state name, so consumers must choose.
---
## How to use this list
Do not read it as a defect report about someone else's project. Read it as a list of
**question shapes**:
1. Which of my editable properties has no writer?
2. Which of my ordering fields never reaches a comparison?
3. Which of my decision branches reads a literal?
4. Which of my parameters is threaded but never branched on?
5. Which of my unknown-input paths fails open?
6. Which of my public state writers has no authority check?
7. Which of my capabilities exists only as configuration that is switched off?
Each has a one-line `Detect` above. Run all seven against your own tree before you
run any of them against the reference.
## Evidence boundary
Every entry was read in source in one reference project on one engine version. They
are worked examples of the classification, not a defect list to carry into other
engine versions or other samples. An entry that does not reproduce under its own
`Detect` in your tree does not apply to you.
@@ -0,0 +1,294 @@
# Adoption patterns: a worked classification
This is the C1-C5 classification from `SKILL.md` applied end to end against one
real reference project, plus the tax analysis and vocabulary hygiene that follow
from it.
Individual silent failures are not repeated here. They live in
[failure modes](failure-modes.md), one entry each, with a detection recipe you
can run against your own tree. This file is about *how the categories behave* and
what each one costs, so that when you meet a new element you can place it.
---
## 1. What each category feels like from the inside
The categories are not severity levels. They are answers to a different question:
**what does the author of the reference believe about this element?**
| | Author's belief | Your evidence | Your move |
|---|---|---|---|
| **C1 Defect** | "this works" | it demonstrably does not | fix the line, keep the design |
| **C2 Showcase stub** | "this is not finished" | reads as finished | implement or delete the field |
| **C3 Scope narrowing** | "a sample does not need this" | nothing is wrong at all | budget it or accept the risk in writing |
| **C4 Architecture tax** | "this is worth it for us" | correct, working, expensive | adopt by need, not completeness |
| **C5 Foreign legacy** | nobody believes anything | a name from another product | rename on entry |
The dangerous boundary is C2 against C3, because from the outside they look
identical: a mechanism that produces a neutral result forever. The distinction is
whether the author intended to come back. It changes nothing about your cost, and
everything about how you talk to your team: a C2 is a hole in the reference, a C3
is a hole in your understanding of what you bought.
---
## 2. C1 - Defects
A defect in a reference project is the cheapest category and the least
interesting. Fix the line, keep the surrounding design, and do not let it
discredit the pattern that contains it.
Three shapes recur often enough to be worth naming:
**The filter that filters nothing.** A field constrains designer input to a
namespace, and the namespace does not exist in this project. The picker comes up
empty, which reads as "no matching entries yet" rather than "this constraint is
misconfigured". Where such filters are rare in a codebase, each one is load-bearing
and worth checking individually.
**The symmetric typo.** A misspelled identifier used as a string join across
config, code and platform overrides works perfectly, because every side misspells
it identically. It is only discovered by someone fixing the spelling in one place.
The lesson is not "check spelling"; it is that when a string literal is the join,
correctness means *all sides agree*, and the way to get that is one declaration
site referenced everywhere else.
**The loop over an empty container.** A local collection is declared next to a
TODO, never populated, and then iterated. The loop reads as a mechanism. It is a
placeholder for one.
None of these survives a careful read of the consumer. They survive a read of the
declaration, which is why they get copied.
---
## 3. C2 - Showcase stubs
The defining property is precise, and worth memorizing:
> The declaration exists. The consumer exists. The **writer** does not - or the
> consumer is a literal.
Neither a compiler warning nor an "unused symbol" search finds these, because the
symbol is used. Reading the code convinces you it is wired up. This is why the
highest-yield check in the whole skill is searching for assignment rather than
mention.
Sub-shapes, each with an entry in the failure-mode index:
- a tunable compared against a timestamp that is never assigned (RA-06);
- an ordering field stored, copied through the entire API surface, and never
compared (RA-07);
- an editable tag container whose consumer is a hardcoded `false` (RA-08);
- a parameter threaded through several call layers, whose only call site passes a
literal, waiting for a subsystem that was never written (RA-09);
- a field dropped silently in both directions of a conversion helper (RA-10);
- a replicated structure with no callers at all (RA-11).
### The trap inside the trap
A stub can be deeper than its TODO admits. In one measured case, implementing the
obvious missing condition would still not restore the intended behaviour, because
a second method on the same class overwrites the designer-authored values on its
first call. The TODO points at one line; the repair is two.
Treat a TODO as evidence that the author knew about *something*, not as a
specification of what is missing. Read the whole class before pricing the fix.
### What to do
Either implement it before exposing it, or delete the field. There is no third
option that is honest. An editable property whose consumer is a literal constant
is a trap for designers, who will tune it for days and then report that the build
is broken.
---
## 4. C3 - Deliberate sample-scope narrowings
Nothing here is broken. The reference is internally consistent with every one of
these. They become expensive only when the sample becomes the base of a product.
This is the category that costs a subsystem rather than a line, and the one that
adoption discussions habitually skip, because there is nothing to point at.
### The largest one: no lag compensation
When a reference project has no server-side rewind - no historical position
storage, no custom saved-move pipeline - everything downstream follows
mechanically:
- the server does not trace, because tracing is gated on being locally
controlled, and the server controls nobody;
- the client's target data is consumed as authoritative, because there is nothing
to compare it against;
- damage falloff is computed from the client's own reported trace origin;
- surface-material multipliers are taken from the client's reported hit.
Read as a defect list, this is four bugs. Read correctly, it is one design
decision with four consequences. "The reference forgot to validate hits" is the
wrong diagnosis, and it leads to four local patches that do not add up to server
authority.
**Adoption decision:** choose a hit-registration model *before* borrowing a weapon
stack. Server-authoritative with rewind, client-claim with plausibility checks, or
full trust. A sample usually implements the third. Each has a different cost and a
different cheat surface, and the choice dictates how abilities, tracing and damage
are structured - which is why it cannot be retrofitted cheaply.
### Configuration that ships disabled
A subsystem can be present, implemented, documented by its own console variables,
and switched off in shipped configuration with an essentially empty routing table.
The implementation being there tells you nothing about whether it was ever
exercised. Copying that configuration copies untested settings, and the settings
are the part that needs production-scale traffic to tune.
### Machinery made unreachable by a mode switch
A load-mode enum set to "load everything upfront" can short-circuit every branch
that would install a delay-load subsystem's delegates. The inherited machinery is
functional; the sample bypasses it. The diagnostic tools built for that subsystem
then report zeros forever, which reads as "nothing to report" rather than "this
code never runs".
Important qualification: this is a *configuration choice of the sample*, not an
engine defect. Diagnosing it as a broken engine feature sends you into engine
source for no reason. Check the mode before you check the mechanism.
### The rest, in one table
| Narrowing | What it costs a product |
|---|---|
| State transitions on a health or death component written without an authority check, on public virtual methods | Death becomes a state with no owner; every caller is an authority |
| Team or faction lookup that fails open on an invalid argument | Unknown membership resolves to "damage allowed" |
| No tag or asset redirects configured anywhere | Every rename is a breaking change with no migration path |
| A reliable server RPC declared without validation | Unvalidated input from the network; a Blueprint-authority marker does not constrain C++ callers |
| An init-state gate that admits simulated proxies with no controller | "Ready" means something different per net role |
Each row is a line item in your adoption budget, not a bug report against the
reference.
### The rule
> Every C3 narrowing you accept must be written down as an accepted risk with an
> owner, or scheduled. "We did not notice" is the only unacceptable outcome.
---
## 5. C4 - Architecture tax
Correct, working, and sized for the reference author's team, release cadence and
platform matrix. The question is never "is this good architecture". It is "does my
product have the problem this solves".
### Measured shape of one reference project
| Element | Measured | When it stops paying |
|---|---|---|
| Definition-to-instance stack: experience selects feature plugins, which contribute action sets, which grant abilities and data | 5 gameplay-feature plugins out of 81 plugins total | One game mode and no post-launch content: you buy distributed control flow and receive no modularity in return |
| C++ / Blueprint boundary | 493 Blueprints scanned, median 5 nodes each; 1256 `BlueprintCallable` entry points against 45 `BlueprintImplementableEvent` plus `BlueprintNativeEvent` override points | The 28:1 ratio means C++ exposes *calls*, not *extension points*. That assumes a permanently available C++ team. Without one, designers stall on their first non-trivial request |
| Plugin-mounted content roots | primary game root holds 2 838 assets; all 105 mount roots hold 17 256; project-owned content is 3 876 | Any census, budget or memory number computed over the primary root alone understates by roughly six times |
The second row is the one people copy without noticing they copied it. A C++ to
Blueprint ratio is a staffing decision wearing an architecture costume. Measure
both columns before adopting the boundary, and name the person who will add to the
second one.
The third row is a measurement discipline, not an architecture choice, and it
generalizes: in any plugin-based project, enumerate all mount roots. Tooling that
defaults to the primary game root will quietly report a fraction of reality.
### How to decide
State the question the modular stack answers *for you*:
- more than one team shipping into the same binary?
- content added after launch without a client patch?
- game modes swapped at runtime by data?
- platform-specific feature sets?
Zero yes answers means the whole stack is tax. One or two means adopt the layers
that answer them and stop there. Adopting for completeness is paying for an answer
to a question you do not have.
---
## 6. C5 - Foreign-product legacy
Three recurring shapes:
- global constants and type identifiers carrying another title's brand, usually
because the subsystem was lifted from that title;
- a tag whose own descriptive comment admits it is in the wrong namespace and
needs to move;
- a class comment copy-pasted from a different class, describing something the
class is not.
Harmless as code. Corrosive as vocabulary. Within a year nobody remembers why a
primary asset type is named after another game's content, and part of the team
assumes it is an engine term. The comment case is worse than the constant case: a
wrong name is eventually questioned, a wrong explanation is believed.
Rename at import time. After the first sprint it becomes archaeology.
---
## 7. The counterexample worth copying
A register-and-unregister pair, in which the unregister path removes exactly the
paths that registration added and then asserts on the removal count:
```cpp
// on unregister
const int32 NumRemoved = Registry.RemovePathsAddedBy(Feature);
ensure(NumRemoved == PathsAddedBy(Feature).Num());
```
In an audit whose dominant finding was "add works, remove is incomplete", this was
the one place where teardown was both implemented and asserted. Copy the shape:
paired add/remove, plus an assertion that the counts match. The assertion is what
turns a silent leak into a test failure.
Note the asymmetry that remained even there: registration went through the
project's own manager subclass while unregistration reached for the engine base
class, and a refresh call made on the way in had no counterpart on the way out.
Even the good example is worth reading twice - copy the shape, fix the asymmetry.
---
## 8. What source reading cannot settle
Three limits are worth stating, because they bound every claim in this skill:
1. **Blueprint call sites are invisible to source search.** A function with zero
C++ callers may be called from a Blueprint graph. Absence of C++ callers is not
absence of callers; settling it requires opening the graphs.
2. **Data tables and data assets are binary.** How many entries a table
contributes to a registry cannot be read from text. It requires the editor.
3. **An empty search result proves the pattern, not the absence.** Widen the
pattern, check the path, and check what your search tool excludes by default
before concluding that something does not exist.
The third has caught real errors in this material more than once. Treat it as a
standing rule rather than a caveat.
---
## Provenance
The classification and every measured number above come from a line-by-line audit
of Epic's Lyra Starter Game on Unreal Engine 5.6, read as source rather than run,
during a research project whose product was this skill set.
Source addresses stay in that archive. What ships is the recipe, because an
address in a tree you do not have is not actionable and a recipe is. Entry
identifiers (`RA-01` and up) in [failure modes](failure-modes.md) resolve back to
the audited locations, so any specific claim can be produced on request.
## Evidence boundary
One reference project, one engine version, one workspace. This is a worked example
of the classification, not a defect list to carry into other engine versions or
other samples. Re-run the tests from `SKILL.md` against your own reference before
trusting any specific claim here.