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:
2026-09-05 23:48:55 +07:00
parent 80ea7b03a9
commit ecd87ac96d
67 changed files with 21630 additions and 0 deletions
@@ -0,0 +1,348 @@
# Failure modes: game settings and performance
Nine ways a settings layer stores a value the player chose and does nothing with
it.
The shared property is unusual and worth stating carefully: **in this area the
framework is usually right and the wiring is usually missing.** Every finding
below sits at a seam — between a model and a property path, between a stored
value and the code that applies it, between a complete pipeline and the caller it
never got. None of them is a design flaw in the mechanism they belong to.
That matters for adoption, which is what this skill is for: the correct response
to this list is *copy the framework, audit the wiring*, not *write your own*.
Recipes use `rg` from a project source root and were executed against the audited
project while this file was written.
---
## Binding
### GS-01 - A property path the compiler does not check
**Mechanism.** Settings bind to storage through a stringified path to a getter
and setter, resolved by reflection at runtime. A macro checks that the function
*name* exists; nothing checks the return type, the parameter count or constness.
**Why it is silent.** A mismatched path fails at initialization with an
assertion — and assertions are compiled out of shipping builds. In shipping, a
broken binding is a setting that reads a default and writes nowhere.
**Why the obvious check misses it.** The macro's name contains "checked", and it
genuinely does check something. Review sees a compile-time-looking construct and
stops. The remaining gap is invisible until the types diverge, which happens
during a refactor rather than at authoring time.
**Symptom.** In development, an assertion when the settings screen opens — often
dismissed as unrelated. In shipping, a setting that silently does nothing, which
is indistinguishable from a setting that is not implemented.
**Detect.** Find the path-building macros and confirm the failure mode of the
resolver, then check whether anything validates paths outside the editor:
```bash
rg -n "GET_FUNCTION_NAME_STRING_CHECKED|FCachedPropertyPath|PropertyPathHelpers" \
--glob "*.h" --glob "*.cpp" .
rg -n -B4 "ensure|checkf" --glob "*.cpp" . | rg -i "getter|setter|propertypath"
rg -n "UE_BUILD_SHIPPING" --glob "*.cpp" . | rg -i "ensure"
```
**Guardrail.** Validate every path during registry initialization, report the
**setting's developer name** rather than a reflection error, and run that
validation in an automated test so it fails a build rather than a play session.
---
### GS-02 - Uniqueness enforced on the identifier, nothing enforced on the pair
**Mechanism.** Settings carry a stable developer name, and the registry checks it
is unique. Nothing checks that the name still matches the property path it binds.
**Why it is silent.** Both halves are individually valid: a unique name and a
resolvable path. A setting named for one property and bound to another works
perfectly and is wrong.
**Why the obvious check misses it.** The uniqueness check exists and passes,
which answers "are the identifiers sane?". Copy-paste between two settings
changes the display text and leaves the path — and the resulting setting looks
correct in every view except behaviour.
**Symptom.** Two settings that move together, or one that changes something the
player did not select. Reported as "the wrong option is wired up", investigated
in the widget layer.
**Detect.** Extract name/path pairs and eyeball the mismatches:
```bash
rg -n -B2 -A6 "SetDevName\(" --glob "*.cpp" . \
| rg "SetDevName|SetDynamicGetter|SetDynamicSetter"
```
Read the triples. A developer name and a getter that share no token is the
finding — crude, and it catches the copy-paste error that produces this.
**Guardrail.** Assert the correspondence in a test that walks the registry, or
generate the name from the path. A convention nothing enforces is a convention
that has already been broken somewhere.
---
## Ownership
### GS-03 - A per-player accessor that returns a global singleton
**Mechanism.** The local player exposes an accessor for its settings; the
implementation returns a process-wide singleton.
**Why it is silent.** With one local player it is correct in every observable
way. The abstraction is only wrong when a second player exists, which is a
configuration most testing never enters.
**Why the obvious check misses it.** Every call site reads correctly — a player
asking for its settings. The defect is one line inside an accessor that nobody
reads twice, because accessors are the least interesting code in a file.
**Symptom.** In split-screen, the second player edits the first player's
settings. Usually discovered not as a bug but as a growing population of "only
the primary player may change this" conditions, added one at a time by people
working around a cause nobody named.
**Detect.** Read the body of every per-player settings accessor:
```bash
rg -n -A4 "::Get\w*Settings\(\) const|::Get\w*Settings\(\)" --glob "*.cpp" . \
| rg -i "::Get\(\)|GEngine->|StaticClass"
rg -n "PlayingAsPrimaryPlayer|IsPrimaryPlayer" --glob "*.cpp" . | wc -l
```
The second count is the corroborating signal: a large number of primary-player
conditions in a project that claims per-player settings means the claim is not
true. In the audited project the accessor returns a global object, and the
conditions are numerous.
**Guardrail.** Decide per value whether it is machine-scoped or player-scoped,
and let the accessor's return type say which. If a value must be global, name the
accessor globally — the honest name prevents the workarounds.
---
### GS-04 - Player preferences stored in machine-scoped storage
**Mechanism.** A value that is clearly a personal preference — volume, for
instance — lives in the machine-local store because that is where the subsystem
applying it happens to read from.
**Why it is silent.** It works for one player on one machine, which is the
overwhelmingly common case. The value persists, applies, and survives restart.
**Why the obvious check misses it.** The placement follows the *implementation*
rather than the *semantics*, and the implementation reason is real: the applying
code is machine-global. A reviewer asking "does this work?" gets yes.
**Symptom.** Preferences that do not follow the player to another machine, and
two local players who cannot have different values. Discovered when cloud saves
or split-screen are added, long after the placement was set.
**Detect.** Classify the fields of both stores against the ownership test:
```bash
rg -n "UPROPERTY\(Config\)" -A2 --glob "*.h" <local-settings>.h | rg "\w+ \w+;"
rg -n "UPROPERTY\(\)" -A2 --glob "*.h" <shared-settings>.h | rg "\w+ \w+;"
```
For each field ask the four ownership questions from the skill. In the audited
project the audio volumes sit in machine-local storage, and the registry that
builds the audio screen reaches into both stores from adjacent lines.
**Guardrail.** Place by semantics, then solve the application problem. If the
applying subsystem is global, the setting can still be player-scoped with the
primary player's value applied — that is a deliberate policy rather than an
accident of storage.
---
## Application
### GS-05 - A global apply invoked from a change notification
**Mechanism.** Changing a setting immediately calls the heavyweight global apply
path from inside the change handler, rather than staging the value until the
player presses apply.
**Why it is silent.** The result is correct and even feels responsive — the
player sees the change instantly. Cancel still restores the value, so the
transaction is not broken, only leaky.
**Why the obvious check misses it.** It is three lines in an edit condition,
placed where a reviewer expects a condition rather than an action. The cost only
appears with a setting a player can scrub — a slider makes it one global reapply
per tick.
**Symptom.** Stutter while adjusting a quality setting; a visible flash on values
the player then cancels; and, for expensive channels, a hitch per change.
**Detect.** Find application calls inside notification paths, and check for the
tell:
```bash
rg -n -B6 "ApplyScalabilitySettings|ApplySettings|SetQualityLevels" --glob "*.cpp" . \
| rg "SettingChanged|OnSettingChanged|NotifySettingChanged"
rg -n -i "//\s*TODO.*(for now|immediately)" --glob "*.cpp" .
```
The second command is the high-yield one: in the audited project the immediate
apply carries a comment saying "for now", which converts a judgement call into a
confirmed finding.
**Guardrail.** Stage by default; preview deliberately and cheaply; apply on
apply. Treat "for now" on a global apply path as a scheduled item, not a note.
---
### GS-06 - A stored preference with no application code
**Mechanism.** A value is exposed, edited, persisted and loaded. The function
that would apply it has an empty body.
**Why it is silent.** Every visible part of the round trip works: the slider
moves, the value saves, it comes back after restart. Only the effect is missing,
and the effect is subjective — sensitivity, in the audited case.
**Why the obvious check misses it.** The apply function exists and is called. A
review of the settings layer finds a complete implementation; the emptiness is in
a body that the caller has no reason to open. And because the value *is* readable,
game code may consume it directly elsewhere, making the empty function neither
wrong nor sufficient.
**Symptom.** A player changes a preference, the UI confirms it, and nothing feels
different. Support cannot reproduce it because the value really is stored.
**Detect.** Find apply functions with empty bodies:
```bash
rg -n -A3 "void \w+::Apply\w+\(\)" --glob "*.cpp" . | rg -B2 "^\s*\}"
```
Then, for each stored preference, find its consumer:
```bash
V='MouseSensitivityX'
rg -n "\b$V\b" --glob "*.cpp" --glob "*.h" . | rg -v "UPROPERTY|Set$V|Get$V"
```
No consumer outside the accessors means the value goes nowhere.
**Guardrail.** A setting ships with its consumer, in the same change. If
application is intentionally the game's responsibility rather than the
framework's, delete the empty function — an empty function is a claim.
---
## Wiring
### GS-07 - A composition part that is read six times and never assigned
**Mechanism.** A device-profile name is composed from parts — base platform, a
mode suffix, a user choice — with fallback through progressively shorter
combinations. One part is declared as a local, read repeatedly, and never
written.
**Why it is silent.** The fallback is designed to handle a missing part, and it
does. Every path produces a valid profile name; the composition simply never
takes the branches that use the absent part.
**Why the obvious check misses it.** The variable appears in six places,
including a log line that prints it. Any "is this used?" search answers yes
loudly. The absent half is the **writer**, and no default tool asks that
question — this is the canonical shape from `ue-reference-project-adoption`,
found here in a subsystem where the consequence is a whole feature that does not
exist.
**Symptom.** Documentation and code structure both indicate that a game mode can
select its own device profile. It cannot. A team planning per-mode performance
profiles discovers this after designing around it.
**Detect.** For any composed name, count reads against writes:
```bash
V='ExperienceSuffix'
rg -n "\b$V\b" --glob "*.cpp" .
rg -n "\b$V\b\s*=[^=]" --glob "*.cpp" .
```
Reads with no assignment is the finding. In the audited project the first command
returns six lines — a declaration, four reads and a log — and the second returns
nothing but the declaration itself, which the authors annotated with a comment
saying nothing sets it.
**Guardrail.** Do not ship a composition part with no producer. If it is
scaffolding for a planned feature, say so where a reader of the *composition*
will see it, not only at the declaration.
---
### GS-08 - A complete pipeline with no caller
**Mechanism.** Automatic quality detection is fully implemented: a capability
check, a benchmark, threshold mapping, application and save. The function that
decides whether to run it at startup has no caller anywhere.
**Why it is silent.** The manual path works. A player who presses the button gets
correct auto-detection, so the feature demonstrably functions — just never on its
own.
**Why the obvious check misses it.** Every component is present and testable, and
unit tests of the threshold mapping pass. "Do we support automatic quality?"
answers yes from any angle except the one that matters: who starts it.
**Symptom.** First-run experience uses default quality on every machine. Nobody
notices, because developers' machines default acceptably and the button exists.
**Detect.** For the decision function, distinguish declaration from call:
```bash
F='ShouldRunAutoBenchmarkAtStartup'
rg -n "\b$F\b" --glob "*.h" --glob "*.cpp" .
```
Exactly two hits — the declaration and the definition — is the finding. Extend to
content if the project can call functions from assets, and record the result as
open if you cannot inspect them.
**Guardrail.** Every entry-point function gets a **call-site test**, not only a
unit test of what it computes. The mapping was never the part at risk.
---
### GS-09 - A size guard whose count nobody knows
**Mechanism.** Code enumerates every member of an engine struct — copying,
clamping, maximising quality levels field by field — and protects itself with a
compile-time assertion on the struct's size.
**Why it is silent.** It is correct, and it is good practice: after an engine
upgrade adds a channel, the build breaks instead of silently ignoring it.
**Why the obvious check misses it.** Nothing is wrong here. The hazard is
procedural: the guard fires during an upgrade, under time pressure, and the
person who bumps the number needs to know **how many functions** must be reviewed.
That number is not written anywhere near the assertion.
**Symptom.** An engine upgrade where the size constant is updated and one of the
five enumerating functions is not, silently dropping a quality channel — exactly
the failure the guard was written to prevent.
**Detect.** Count the guards and the functions they protect before upgrading:
```bash
rg -n "static_assert\(sizeof\(" --glob "*.cpp" --glob "*.h" .
rg -c "static_assert\(sizeof\(Scalability::FQualityLevels\)" --glob "*.cpp" .
```
In the audited project this returns **five** sites for one struct. Five is the
number an upgrader needs and would otherwise have to discover by grepping mid-fix.
**Guardrail.** Write the count and the list of functions into the assertion
message. The guard is only as good as the instruction it gives the person it
stops.
@@ -0,0 +1,229 @@
# Patterns: a measured settings and performance layer
A worked example of `SKILL.md` against one reference product. The conclusion is
unusual enough to state at the top, because it changes what you should do with
the rest of the file:
> **The framework is the best thing in the audited project. The wiring around it
> is where every defect is.**
Roughly 5700 lines of settings framework contain no reference to the game that
ships it, and no finding in this file is about its design. The nine failure modes
are all in the 2100 lines of project-specific backend and registry that bind it —
paths that the compiler cannot check, a pipeline with no caller, a composition
part with no writer. That distinction is the adoption advice: **take the
framework, audit the wiring**.
Markers: **[measured]** — read in source; **[derived]** — conclusion from
measured facts; **[open]** — not answerable from source.
---
## 1. What the framework gives you, concretely
| Capability **[measured]** | Why it is hard to rebuild |
|---|---|
| Setting model fully separated from widgets | The separation is what makes search, grouping and testing possible at all |
| Widget resolved by walking the model's **superclass chain** | Map one base class, cover every derived setting, keep specific overrides |
| Apply / cancel / reset as a real transaction over a dirty set | Correct restore of *runtime* state, not only stored values |
| Edit conditions with five outcomes, not a boolean | disabled-with-player-reason, hidden-with-developer-reason, single option disabled, killed |
| Cascading dependencies between settings | Changing one setting re-evaluates the others without hand-written wiring |
| Full-text search over rich text reduced to plain text | Falls out of the model separation |
| Asynchronous readiness before the screen renders | Audio device lists and cloud values arrive late; the screen waits |
| A debug mode that shows developer names and classes in the UI | Makes every other finding in this file findable |
**[derived]** The five-outcome edit condition is the piece most worth
internalising even if you write your own framework. Hand-built settings screens
almost universally have a boolean, and the two missing things — *tell the player
why it is disabled* and *tell the developer why it is hidden* — are exactly the
two questions asked later at cost.
---
## 2. The trade the framework makes, and where it lands
Bindings are **string paths resolved by reflection** — a getter and setter path
rooted at the local player **[measured]**. That removes a class per setting and
removes the compiler from the relationship.
The failure mode is precise: the stringification macro checks that a function
*name* exists, not its type, arity or constness; mismatches surface as an
assertion at registry initialization; **assertions are compiled out of shipping**
**[measured]**.
**[derived]** So the worst case is a shipping build where a setting reads a
default and writes nowhere, with no diagnostic anywhere. That is not an argument
against the design — it is an argument for one automated test that walks the
registry and resolves every path. Recipes: GS-01, GS-02.
---
## 3. The ownership split, and the leak inside it
The design is a clean two-store split **[measured]**:
| | Machine-local | Player-shared |
|---|---|---|
| Base | engine user settings, ini | per-player save game, cloud-syncable |
| Holds | resolution, quality, frame limits, device profile, benchmark result | colour-blind mode, sensitivities, subtitles, language, haptics |
The authors state the rationale in a comment: shared settings are safe to put in
the cloud and are stored per player, so controller preferences belong there
rather than in local settings where every user would receive them **[measured]**.
Then the accessor breaks it. The per-player getter for local settings returns a
**process-global singleton** **[measured]**.
**[derived]** "This player's local settings" is a fiction; there is one object per
process. The corroborating evidence is structural rather than a second defect:
the registry is dense with "only while playing as the primary player" conditions,
which is the architecture apologising for the accessor. Recipe: GS-03.
A second, milder leak in the same area: audio volumes live in machine-local
storage **[measured]** — a pure player preference placed by where the applying
subsystem reads, not by what the value means. The audio registry reaches into
both stores from adjacent lines. Recipe: GS-04.
**[derived]** Both are worth seeing together, because they are the same mistake at
two scales: **storage placed by implementation convenience rather than by
semantics**, then papered over at the call sites.
---
## 4. Three findings that are the same shape
This is the most transferable observation in the file. Three unrelated features,
one structure:
| Feature | Present **[measured]** | Missing **[measured]** |
|---|---|---|
| Automatic quality detection at first launch | capability check, benchmark, threshold mapping, apply, save | **any caller** of the decision function |
| Per-game-mode device profiles | name composition, fallback chain, four candidate forms, logging | **any writer** of the mode suffix |
| Input sensitivity application | storage, dirty tracking, save, load, an apply function | **a body** in the apply function |
**[derived]** Each is complete except for one link, and in each case the missing
link is invisible to every check that the rest of the feature passes. The
decision function has two occurrences — its declaration and its definition. The
suffix has six occurrences, of which one is a log line printing it and none is an
assignment. The apply function is called correctly from the right place and
contains nothing.
The suffix case deserves emphasis because it is the canonical shape from
`ue-reference-project-adoption` appearing in a subsystem where the consequence is
architectural: **a variable read four times and written zero times**, with the
authors' own comment noting that nothing sets it. Any search asking "is this
used?" answers yes, loudly. Recipes: GS-06, GS-07, GS-08.
---
## 5. Where the transaction leaks
Video settings apply **immediately from inside a change notification**, not on
apply, and the code says so with a comment reading "for now" **[measured]**.
**[derived]** Cancel still restores the value, so the transaction is not broken —
it is leaky in a way the player sees: a preview they did not ask for, on a global
apply path, potentially once per slider tick.
The comment is what turns this from a judgement call into a finding. **A "for
now" on a global apply path is a scheduled item.** Recipe: GS-05.
---
## 6. The upgrade guard, and the number it does not tell you
Manual enumeration of an engine struct's members — copying, clamping and
maximising quality levels field by field — is protected by a compile-time
assertion on the struct's size, at **five separate sites** for one struct
**[measured]**.
**[derived]** This is good practice and it works: after an engine upgrade adds a
quality channel, the build breaks rather than silently ignoring it. The gap is
procedural. The person who hits the assertion during an upgrade needs to know
*how many functions* must be reviewed, and that number is nowhere near the
message. Recipe: GS-09.
**Put the count in the assertion text.** A guard is only as useful as the
instruction it gives the person it stops.
---
## 7. The reapplication point, and why it is where it is
Global scalability is reapplied at the **last line** of the mode-load completion,
after the load state is set and after all three waves of load notification
**[measured]**. The same function serves the other trigger that changes the same
state — a hotfixed device profile **[measured]**.
Four reasons, all measured, and together they are a small lesson in ordering:
1. feature plugins activated by the mode can change console variables and
frontend policy, so effective values are unknown until they have all run;
2. the device profile itself may change, and the reapplication reselects it;
3. scalability is process-global, not per world — the authors note this in a
comment explaining why they do not track it per world in multi-instance
editor play;
4. it is guarded off for servers, where render settings are meaningless.
**[derived]** Two different causes — mode loaded, profile hotfixed — reduced to
one idempotent reaction at one named point. That arrangement is worth copying
directly, and it is the opposite of letting each feature reapply what it thinks
it changed.
---
## 8. Performance statistics: one collector, and the honest limits
Statistics come from the engine's own frame-data consumer interface rather than
from hand-rolled timers **[measured]**, feeding a ring buffer for graphing.
Eighteen statistics, with a compile-time count assertion in three places
**[measured]**.
**[derived]** The design point worth copying is that presentation and collection
are separate: widgets read a cache, and no widget collects. The design point worth
questioning before copying is that a buffer size is meaningless without a
documented sample cadence — a hundred-odd samples describes a window only once you
know the interval.
Two hard couplings to be aware of before lifting the subsystem: one statistic
reads a game-specific state class, and latency statistics are gated on a specific
GPU vendor because the project integrates one vendor's plugin **[measured]**.
Both are small and both are visible; neither is a reason to rewrite the rest.
---
## 9. What to copy, in order
1. **The framework itself**, if the UI dependency is acceptable. Five-outcome edit
conditions, superclass-chain widget resolution, the change tracker, async
readiness.
2. **The reapplication point** (§7) — one idempotent function, two causes.
3. **The two-store split** (§3) — but decide placement by semantics and check
your accessor's body before trusting the word "local".
4. **The size-guard discipline** (§6), improved by writing the count into the
message.
5. **Collector/presenter separation** for statistics (§8).
Audit before shipping: every property path resolves (GS-01, GS-02); the accessor
is not a singleton (GS-03); every apply function has a body (GS-06); every
composition part has a writer (GS-07); every pipeline has a caller (GS-08).
**[derived]** That list is short, mechanical, and would have caught every defect
in this file. None of it requires understanding the framework — which is the
point of shipping recipes rather than conclusions.
---
## Provenance
Measured against Epic's Lyra Starter Game on Unreal Engine 5.6 and the settings
plugin shipped with it: roughly 5700 lines of framework, 2100 of project settings
backends, 2000 of registry, and 800 of performance code, read as source in a
single workspace. Source addresses stay in the research archive that produced
this skill; each `GS-` identifier resolves back to the audited location there.
## Evidence boundary
Widget assets are binary and were not read, so nothing here describes the
presentation layer beyond its base classes. Configuration values quoted describe
that project on that day. Re-run the recipes against your own tree.