Files
ue-toolchain/plugins/ue-design-skills/skills/ue-game-settings-architecture/references/failure-modes.md
T
ue-toolchain dab3f35079 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>
2026-09-05 23:48:55 +07:00

349 lines
14 KiB
Markdown

# 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.