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,321 @@
---
name: ue-game-settings-architecture
description: >-
Design or review game settings and performance architecture in Unreal Engine:
setting model trees separated from widgets, reflection-backed property paths,
declarative edit conditions, apply and cancel as a transaction, machine-local
versus player-shared persistence, scalability and console variables and device
profiles, hardware benchmark wiring, and sampled performance statistics. Use
when adding video, audio, input or accessibility settings, cloud saves,
quality presets, frame-rate modes, device tuning or performance overlays.
---
# UE game settings architecture
The invariant:
> A setting is a **model** with persistence, validation and application
> semantics. A widget is one view of that model.
Measured patterns from a reference product: [patterns](references/patterns.md).
Detection recipes: [failure modes](references/failure-modes.md).
Settings look like the least architectural part of a game and are one of the
most: they span a UI framework, a reflection layer, two persistence stores, the
console-variable system, device profiles and the engine's scalability tables.
Every one of those boundaries fails silently.
Related skills: `ue-data-driven-architecture`, `ue-ui-architecture`,
`ue-input-architecture`, `ue-modular-gameplay`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. Separate model, registry and presentation
```text
registry (per local player)
└─ collections and pages
└─ setting models
├─ data source: getter and setter
├─ display text and description
├─ edit conditions
├─ options and validation
└─ apply / store-initial / restore lifecycle
visual data asset
└─ setting model class → entry widget class
```
The screen renders a tree of models through a class-to-widget map; it does not
hand-build a widget per option. The payoff: one model renders in several screens,
search and grouping operate on models, edit conditions are testable without any
UI, and settings code depends on no concrete widget class.
Resolve the widget by walking the model's **superclass chain**, so mapping one
base class covers every derived setting and a specific override remains possible.
---
## 2. Reflection-backed data sources, and their one cost
A setting stores a **path** to a getter and setter rooted at the local player,
and resolves it through reflection:
```text
local player → GetLocalSettings() → GetShadowQuality / SetShadowQuality
```
This removes a class per setting. It also removes the compiler from the
relationship.
### The cost, stated precisely
A macro that stringifies a function name checks that the **name** exists. It does
not check the return type, the parameter count, or constness. Those failures
surface as a runtime assertion during registry initialization — **and assertions
are compiled out of shipping**, so in a shipping build a broken path is a setting
that silently does nothing.
Three consequences worth designing around:
1. validate every path during registry initialization and **fail loudly with the
setting's developer name**, not with a reflection error;
2. run that validation in an automated test, not only when a developer opens the
screen;
3. prefer explicit binding where conversion is complex, where several values
change atomically, or where the setter has side effects worth reading.
Recipe: GS-01.
---
## 3. Edit conditions are declarative dependencies
Availability depends on platform traits, primary-player status, other settings,
capabilities, privileges, input device and build configuration. Express it in the
model, never in widget visibility.
Return more than a boolean:
```text
editable
disabled (reason shown to the player)
hidden (reason recorded for the developer)
option disabled (one entry removed from a list)
killed (hidden, not resettable, not reported)
```
A condition also declares which settings it depends on, so that changing one
setting re-evaluates the others.
**The two-reason split is the part worth copying.** A disabled setting shows the
player why; a hidden one records why for whoever later asks where it went. Most
hand-built settings screens have neither.
---
## 4. Persistence: machine-local versus player-shared
| | Machine-local | Player-shared |
|---|---|---|
| Holds | resolution, quality, frame rate, device profile, benchmark result | accessibility, sensitivities, subtitles, language, bindings |
| Backed by | engine user settings, local ini | per-player save game, cloud-syncable |
| Scope | one per machine | one per player |
### The ownership test
1. Should this follow the user to another machine?
2. Can two local players need different values at once?
3. Does it change global renderer or audio device state?
4. Can a secondary local player safely apply it?
### The leak to watch for
A per-player accessor that returns a **process-global singleton** is the standard
way this abstraction breaks. It compiles, it reads naturally at every call site,
and it silently makes split-screen impossible for every value behind it. The
symptom in a codebase that has it: a proliferation of "only the primary player
may edit this" conditions, which is the architecture apologising for itself.
Recipe: GS-03. If you find it, the fix is not to remove the conditions.
---
## 5. Apply and cancel is a transaction
1. store initial values on open;
2. edits update the model, and optionally preview;
3. apply commits and invokes application;
4. cancel restores initial values **and reapplies the prior runtime state**;
5. reset-to-defaults is another staged mutation, not an immediate save.
### Classify application timing deliberately
| Setting | Timing |
|---|---|
| volume, UI preview | immediate, restored on cancel |
| scalability channel | preview or apply, per policy |
| resolution and window mode | apply with a confirmation countdown |
| key binding | staged profile plus collision check |
| restart-required | save now, mark pending |
Applying a heavyweight global change from inside a change notification is a
decision, not a default. If it is done because staging was not written yet, mark
it as such — and treat a comment saying "for now" on a global apply path as a
finding rather than a note. Recipe: GS-05.
---
## 6. From setting to console variable
The preferred path keeps a typed object in the middle:
```text
model → typed settings setter → staged quality levels
→ apply → engine scalability API → expanded console variables
→ clamped by device profile
```
A typed object gives range validation, persistence, transactional restore,
platform clamping, change notification and a test seam. A widget writing a
console variable directly gives none of them.
When a setting genuinely maps to a custom variable: resolve and cache safely,
set a defined priority source, clamp, restore on cancel, and document shipping
availability. Never write to a read-only variable and assume it took.
---
## 7. Device profiles are constraints, not presets
```text
platform default → base profile → mode suffix → user request
→ platform clamp → effective value
```
Keep **requested** and **effective** separate, and be able to explain to the
player why a request was clamped.
Profile selection commonly composes a name from parts — base, mode, user choice —
and falls back through progressively shorter combinations until one exists. That
is a good pattern with one specific hazard: a composition part that is declared,
read, and never assigned. The fallback then silently always takes the shorter
branch, and the feature the part represents does not exist while appearing to.
Recipe: GS-07.
---
## 8. Wire the benchmark, do not merely implement it
```text
can run? → run benchmark → map indices through thresholds
→ set quality and resolution scale → save and apply
```
A function named "should this run at startup" with **no caller** means startup
autodetection does not exist, however complete the rest of the pipeline is.
Decide explicitly: automatic on first launch or manual only; how hardware changes
are detected; whether user overrides survive; what happens on failure. Then add a
**call-site test**, not only a unit test of the threshold mapping — the mapping
was never the part that was missing. Recipe: GS-08.
---
## 9. Performance statistics: collect once, present many
```text
engine frame data → one consumer → latest values + sampled ring buffers → widgets
```
Rules that keep this honest:
- one collector, never one per widget;
- a fixed-capacity ring buffer with a **documented sample cadence** — a buffer
size without a cadence describes no window at all;
- units written down: milliseconds, frames per second, bytes per second, percent;
- an unavailable statistic returns *unavailable*, never zero;
- availability in shipping is a deliberate setting.
A frame-rate counter is not evidence about rendering cost. Use it to notice, not
to conclude.
---
## 10. Reapply after composition changes
Global scalability and frame pacing may need reapplying after a game mode or
feature set finishes loading, because features can change device profile,
console variables and frontend policy.
Do it **once, at one named lifecycle point, after the composition is fully
known** — not from each feature. The same entry point should serve the other
trigger that changes the same state, such as a hotfixed device profile. Two
causes, one reaction, one idempotent function.
---
## 11. Guard engine-struct assumptions
Code that enumerates every member of an engine struct — copying, clamping or
maxing quality levels field by field — should carry a compile-time size guard
with a message telling the upgrader what to review.
A failing size assertion after an engine upgrade is a **feature**. It is the only
thing standing between a newly added quality channel and being silently ignored
by five functions. Update the guard only after reviewing every one of them, and
know how many there are before you start. Recipe: GS-09.
---
## 12. Review checklist
- [ ] Setting is a model; the widget is a view.
- [ ] Stable developer name is separate from localized display text.
- [ ] Every property path is validated at initialization and in a test.
- [ ] Machine-local versus player-shared ownership is explicit per value.
- [ ] No per-player accessor returns a global singleton.
- [ ] Apply and cancel restore runtime state, not only stored values.
- [ ] Expensive apply operations have deliberate, documented timing.
- [ ] Requested and effective values are distinguishable.
- [ ] Direct console-variable writes are justified and restorable.
- [ ] The benchmark has a real caller and a stated policy.
- [ ] Statistics are collected once, sampled at a documented cadence.
- [ ] Unavailable is distinct from zero.
- [ ] Reapplication after composition change is centralized and idempotent.
- [ ] Engine-struct assumptions carry compile-time guards.
---
## 13. When to simplify
A prototype can bind widgets straight to engine user settings. Adopt a model
registry when at least one holds: platforms hide or clamp settings differently;
apply and cancel are required; several local users need separate preferences;
search, categories or dynamic conditions matter; gamepad navigation needs
reusable rows; settings span ini, save games, console variables and subsystems.
Do not adopt a five-thousand-line framework for three toggles. Adopt it when the
settings screen has become an application subsystem rather than a panel.
---
## Provenance
The findings come from a source audit of Epic's Lyra Starter Game on Unreal
Engine 5.6 and the settings plugin it ships with — roughly 5700 lines of
framework plus 2100 of project-specific settings backends, read rather than run.
The framework itself contains no reference to the game and is the most portable
component examined in the whole research project; the findings below are about
its **wiring**, not its design.
Source addresses stay in the research archive. Each entry carries a stable
identifier (`GS-01`, `GS-03`, …) resolving back to the audited location there.
## Evidence boundary
One project, one engine version. Concrete widget assets are binary and were not
read, so claims here concern the model layer and its bindings. Values quoted from
configuration describe that project on that day.
@@ -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.