Files
MagentaDolphin ecd87ac96d 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

22 KiB

Failure modes: UI architecture

Fourteen ways a layered, slot-based UI puts the wrong thing in the wrong place, in the wrong order, or nowhere at all — without reporting anything.

The shared property: this architecture is built out of joins that nothing checks. A layer is a tag looked up in a map. A slot is a tag matched against another tag. A contribution is a class pointer that may or may not be loaded. Every one of those joins fails by producing nothing, and producing nothing is also what a correctly configured, currently-empty UI looks like.

A second property worth stating: UI failures are reported by people, not by tools. Nobody writes a test that asserts a widget appeared in a slot. So the detection recipes below matter more here than in most areas, because they are frequently the only instrument that exists.

Recipes use rg from a project root and were executed against the audited project while this file was written.


Ordering and placement

UI-01 - A priority field that never sorts

Mechanism. The extension API accepts a priority on every registration overload, stores it on the entry, and copies it into the request struct handed to the slot widget. Nothing ever compares two priorities.

Why it is silent. An order is produced on every run — registration order — and it is stable for as long as the set of contributors is stable. The UI looks deliberate. Nothing is missing; things are merely arranged by accident.

Why the obvious check misses it. The value travels through the entire public surface: eight registration entry points, the entry struct, the request struct. Any review asking "is priority used?" finds a dozen sites and stops. The right question is much narrower — does it appear inside a sort or a comparison? — and that is not a question anyone thinks to ask about a field that is so obviously plumbed.

Symptom. Widget order inside a slot 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 investigated in the loading system.

Detect. Count the plumbing, then look for the comparison:

rg -n "\bPriority\b" --glob "*.cpp" --glob "*.h" <ui-extension-module>/ | wc -l
rg -n "Sort|StableSort|Algo::" <ui-extension-module>/ | wc -l

In the audited project the first number was substantial and the second was exactly zero — no sort of any kind exists in the module. A removal that uses swap-with-last compounds it by reordering the array on every unregistration.

Guardrail. An ordering field ships with its comparator in the same change, or it does not ship. If order genuinely does not matter, do not expose a field that promises it — the field is a more expensive lie than the missing feature.


UI-02 - A duplicated key in the plugin descriptor silently drops a dependency

Mechanism. The descriptor is JSON. It contains the dependency array key twice. By JSON semantics the second occurrence replaces the first, so half the declared dependencies do not exist as far as any parser is concerned.

Why it is silent. The project builds and runs, because the lost dependency arrives transitively through the surviving one and is also enabled at the project level. The declaration was redundant in this configuration — which is exactly why nobody notices it is gone.

Why the obvious check misses it. Reading the file shows both blocks. A human reader sees two lists and mentally unions them; a parser sees one key assigned twice and keeps the last. The file looks more correct than it is, and no editor or build step warns about duplicate keys.

Symptom. Nothing, until the transitive path disappears — someone disables the intermediate plugin, or extracts this plugin into another project. Then a dependency that the descriptor appears to declare is simply not there.

Detect. Do not read it. Parse it, and compare against the raw text:

rg -c '"Plugins"' <plugin>/<Plugin>.uplugin        # occurrences in the text
python -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8-sig')); \
print([p.get('Name') for p in d.get('Plugins',[])])" <plugin>/<Plugin>.uplugin

A count above one from the first command means the second command is authoritative. In the audited project the text contained two dependency blocks and the parser reported exactly one surviving dependency — the other was discarded. This is the single cheapest recipe in this file and it generalises to every hand-edited descriptor in a project.

Guardrail. Validate every descriptor by parsing it in CI, not by reading it. Assert that the parsed dependency set equals the intended set.


UI-03 - Unknown layer tag resolves to a silent null

Mechanism. Pushing a screen looks the layer tag up in a map. A miss returns null and the push does not happen.

Why it is silent. Returning null is the correct behaviour for a container that does not exist. The caller usually ignores the return value, because in the working case it is a widget nobody needs a reference to.

Why the obvious check misses it. The call site reads perfectly: a tag, a class, a push. The tag is a constant that exists in the vocabulary, so it is spelled correctly — it simply names a layer that this particular root layout did not register. Layers are registered from a Blueprint layout asset, which no code search can inspect, so the set of valid tags is not knowable from source.

Symptom. A screen never appears. No log, no assertion. Investigation starts from the screen's own class, which is fine, and from the push call, which is also fine.

Detect. Compare the tags that are pushed against the tags that are registered, accepting that the second half may require the editor:

rg -o -N 'PushWidgetToLayerStack\w*\(\s*([A-Za-z_:]+)' -r '$1' --glob "*.cpp" .
rg -n "RegisterLayer" --glob "*.cpp" --glob "*.h" .
rg -o -N 'Tag="(UI\.Layer\.[^"]+)"' -r '$1' Config/ | sort -u

The third command gives the declared vocabulary; if a pushed tag is not in it, the defect is certain. If it is in the vocabulary, the layout asset still has to be checked in the editor — which is the finding in itself.

Guardrail. Make the push assert or log an error on an unknown layer, naming the tag and listing the registered ones. A lookup miss on a hardcoded key is never a legitimate runtime state.


Contribution lifetime

UI-04 - Contribution class resolved with a bare accessor and no fallback

Mechanism. The feature action resolves its widget classes with an accessor that returns whatever is already loaded, relying on asset bundles to have loaded them, and passes the result on without checking.

Why it is silent. When bundles work — which is always, inside the pipeline that was designed for them — the classes are loaded and everything is correct. A null is rejected downstream with at most a verbose log.

Why the obvious check misses it. The bundle annotation is right there on the property, so the loading question looks answered. The failure requires the action to run outside its intended pipeline, which is not a state the code was reviewed against.

Symptom. Widgets silently missing for one feature, in one configuration — typically a packaged build, or a manual invocation of the action — while everything works in the editor.

Detect. Find bare accessors on soft references in contribution paths:

rg -n "\.Get\(\)" --glob "*.cpp" . | rg -i "widget|layout|class"
rg -n -B4 "\.Get\(\)" --glob "*.cpp" . | rg "AssetBundles"

Then check each hit for a null branch. In the audited project both contribution loops used the bare accessor; the layout loop tested for null and the slot loop did not — the null travelled one function further and was rejected there with a verbose-level log.

Guardrail. Either load explicitly with a failure path, or check the result and log at warning level with the asset name. "The bundle guarantees it" is a statement about one pipeline, not about the code.


UI-05 - Two teardown paths with different strength

Mechanism. The feature action removes its two kinds of contribution differently: slot extensions are unregistered, which synchronously removes the entry widget; layouts are asked to deactivate, which is a request the stack may honour later or differently.

Why it is silent. Both calls succeed. Deactivation is a real operation with a visible effect, so the layout does disappear from view in the common case. Whether the stack still retains it, and whether it will reactivate when the layer is next popped to, is not observable from the action.

Why the obvious check misses it. Both halves of teardown exist and are symmetric in shape — a loop over layouts, a loop over handles. Reading the function gives no reason to suspect that one loop is weaker than the other. The difference is in the semantics of two APIs in a different plugin.

Symptom. A layout that reappears, or that is still in the stack's history after its owning feature is gone. On reactivation the layer may hold two.

Detect. Compare the verbs used in the two removal loops:

rg -n -A20 "::Remove\w*\(" --glob "*.cpp" . | rg "Deactivate|Unregister|RemoveWidget"

A Deactivate in one loop and an Unregister in the other is the finding. Confirm at runtime: activate, deactivate, reactivate, and count entries in both the slot and the layer.

Guardrail. Removal means removal. If the stack API distinguishes deactivation from removal, call the removing one and verify the stack no longer retains the widget. Assert the count returns to baseline.


UI-06 - Manual garbage-collection reference tracking

Mechanism. The object held by a registration is not a reflected property, so the subsystem walks its own maps in the reference-collection hook to keep those objects alive.

Why it is silent. It works, exactly and completely, for as long as the hook matches the data structure. There is no degraded mode — either the object is collected or it is not.

Why the obvious check misses it. The hook is correct when written and is far from the data structure it mirrors. Adding a third map, or a new object-holding field, is a local change that does not visibly relate to a function elsewhere in the file. Nothing links them.

Symptom. After a refactor, an object is collected while still registered. Symptoms are arbitrary: a null contribution, a crash on a stale pointer, a contribution that silently stops matching.

Detect. Find manual reference collection and check it covers every container:

rg -n -A15 "AddReferencedObjects" --glob "*.cpp" .
rg -n "TMap<.*TSharedPtr<|TObjectPtr<\w+> \w+;" --glob "*.h" <ui-extension-module>/

Compare the containers walked by the first against the containers declared by the second. Any container holding objects and not walked is a leak of correctness.

Guardrail. Prefer reflected properties. Where manual collection is unavoidable, put the hook immediately adjacent to the declarations it mirrors and add a comment at each declaration pointing at it.


UI-07 - Context matched by pointer, held weakly

Mechanism. A contribution is scoped to a context object — usually a local player — and matching compares pointers. The stored reference is weak.

Why it is silent. When the context dies, the contribution stops matching anything, which looks exactly like a contribution that was correctly removed. No stale widget appears, so nothing draws attention.

Why the obvious check misses it. The weak pointer is the right choice and prevents the dangerous failure. What it does not do is remove the record, and "does this leak memory?" is answered by the weak pointer while "does this leak records?" is not asked at all.

Symptom. Registration maps that grow across sessions with entries that can never match again. Only visible if something counts them.

Detect. Find weak context fields and check for a cleanup path keyed on their expiry:

rg -n "TWeakObjectPtr<UObject>\s+ContextObject|ContextObject ==" --glob "*.h" --glob "*.cpp" .
rg -n "IsExplicitlyNull|IsValid\(\)" --glob "*.cpp" <ui-extension-module>/

The finding is a comparison path with no eviction path. Confirm by logging map sizes across a local-player add/remove cycle.

Guardrail. Evict on context expiry, or key the map by context so an entire context can be dropped in one operation.


Diagnosis surface

UI-08 - Swapped logging branches

Mechanism. Two log statements in the two arms of a conditional have their format strings exchanged: the branch that has a context logs the format without it, and the branch that has none logs the format with it — printing a safe-name of null.

Why it is silent. It is a log line. It costs nothing at runtime, breaks no behaviour, and is emitted at verbose level, so it is invisible unless someone turns the category up.

Why the obvious check misses it. Reviewers read log statements for the message, not for which branch they are in. Both lines are individually plausible. The defect only exists in the pairing, and the pairing is exactly what the eye skips.

Symptom. When someone finally enables verbose logging — necessarily during a difficult investigation — the log lies about which registrations have a context. The cost is paid at the worst possible moment.

Detect. Read both arms of every logging conditional together:

rg -n -B3 -A8 "if\s*\(\s*ContextObject\s*\)" --glob "*.cpp" . | rg "UE_LOG|GetNameSafe"

Look for a format string mentioning the variable inside the branch where it is null. In the audited project this is present and unmistakable once the two lines are read side by side.

Guardrail. Log through one statement with a conditional argument rather than two statements with divergent formats. One format string cannot disagree with itself.


UI-09 - Missing focus target caught by a warning, not an error

Mechanism. A screen that does not declare a focus target compiles with a Blueprint warning.

Why it is silent. Warnings scroll. A project with any warning debt has normalised them, and this one appears only when the specific widget is compiled.

Why the obvious check misses it. The check exists — someone wrote it deliberately and worded it well. It is diligence that has been graded as optional. The failure is not the absence of a check but its severity, which no review of the checking code would flag.

Symptom. On a gamepad, opening the screen leaves focus nowhere: no navigation, no visible selection, and often no way back. It is one of the most expensive UI bugs to receive from a player and one of the cheapest to prevent.

Detect. Find the validation and check its severity, then find the screens that would trip it:

rg -n -B4 -A8 "ValidateCompiledWidgetTree|ValidateCompiledDefaults" --glob "*.cpp" . \
  | rg "Warning|Error|Note"
rg -n "GetDesiredFocusTarget|BP_GetDesiredFocusTarget" --glob "*.cpp" --glob "*.h" .

A validation that emits a warning for a gamepad-blocking condition is the finding.

Guardrail. Promote it to an error, or gate packaging on it. A check that cannot fail a build is documentation with extra steps.


UI-10 - The loading screen's only diagnostic is a string nobody must break

Mechanism. Arbitration polls a set of providers, each of which returns whether it needs the screen and why. The accumulated reason is the sole explanation for a stuck loading screen.

Why it is silent. A permanent loading screen is the failure, and it is loud. The silence is in the cause: the screen is a correct response to a provider that never releases.

Why the obvious check misses it. There is no artefact to inspect. The screen is showing because at least one of a dozen conditions is true, and only one of them names itself. A team that bypasses the interface with its own flag removes even that.

Symptom. A build that hangs on the loading screen with no information, in a configuration that cannot be attached to a debugger.

Detect. Enumerate the providers and check that nothing shows the screen outside them:

rg -n "ShouldShowLoadingScreen" --glob "*.cpp" --glob "*.h" .
rg -n "AddViewportWidgetContent|bShowLoadingScreen|LoadingWidget" --glob "*.cpp" . \
  | rg -v "LoadingScreenManager"

The second command should be empty. Any hit is a path that shows or hides the screen without contributing a reason, and it is the one you will be unable to diagnose. In the audited project the arbitration enforced a non-empty reason with an assertion — a design worth copying exactly.

Guardrail. One owner presents; everyone else reports need plus reason. Require the reason with an assertion, and expose the current reason to a console command.


UI-11 - Forced collection at the moment control returns to the player

Mechanism. Hiding the loading screen forces a full garbage collection.

Why it is silent. It is deliberate, it is defensible, and it is not a bug. The cost lands as a hitch precisely when the screen disappears, which is a moment the player already expects to be uneven.

Why the obvious check misses it. Nobody profiles the loading screen. It is infrastructure that ran before the thing you are measuring. The hitch is attributed to the level, to shader compilation, or to streaming.

Symptom. A consistent stall at the first frame of gameplay that resists explanation from gameplay profiling.

Detect. Find global side effects in the hide path:

rg -n -B6 -A6 "ForceGarbageCollection|SetBatchMode|SuspendHeartBeat|bDisableWorldRendering" \
  --glob "*.cpp" .

Every hit is a process-wide effect owned by the loading screen. Each may be correct; all of them are worth knowing about before copying the subsystem.

Guardrail. Measure before adopting. If a forced collection is wanted, do it while the screen is still up, not as it comes down.


UI-12 - Behaviour that differs in the editor by design

Mechanism. The loading screen's input blocker declines to consume input when running under the editor.

Why it is silent. It makes editor iteration bearable, which is why it was written. The divergence is invisible because the editor is where everyone looks.

Why the obvious check misses it. Testing happens in the configuration that has the exception. Confirming the difference requires running a packaged build specifically to compare input behaviour during loading, which is not on anyone's list.

Symptom. Input handled during loading behaves differently in a packaged build. Bugs that only reproduce for QA, in a build the developer cannot iterate on.

Detect. Find editor-conditional behaviour in shared runtime paths:

rg -n "GIsEditor|WITH_EDITOR" --glob "*.cpp" <loading-and-ui-modules>/ \
  | rg -v "^.*Editor.*\.cpp"

Each hit is a place where the editor is not representative. List them and decide which matter; the point is to hold the list, not to remove it.

Guardrail. Keep a written list of deliberate editor divergences in the UI and loading paths, and test each one in a packaged build at least once per milestone.


Structure

UI-13 - Layers used as z-order buckets

Mechanism. New layers are added to place widgets relative to each other, rather than to express modality.

Why it is silent. It works. A new layer is one registration and one tag, and the widget appears where intended.

Why the obvious check misses it. Each addition is individually reasonable and locally minimal. The cost is structural and accrues over releases: input and focus policy is per layer, so every added layer multiplies the modality matrix that must be reasoned about and tested.

Symptom. Focus and input behaviour that nobody can predict, because there are nine layers and the policy interaction between them was never designed.

Detect. Count layers against modalities:

rg -o -N 'Tag="(UI\.Layer\.[^"]+)"' -r '$1' Config/ Plugins/*/Config/ | sort -u

More than a handful, or names that describe position rather than modality, is the finding. In the audited project there were exactly four, named for modality.

Guardrail. Layers express modality: gameplay, game menu, menu, modal. Relative placement within one modality belongs to slots inside a layout, not to new layers.


UI-14 - The composition is knowable only from binary assets

Mechanism. Layers are registered, and slots are placed, from widget assets. Source contains the vocabulary and the API but not the arrangement.

Why it is silent. It is the intended design and its benefit is real: the shell's layout is authored by designers without engineering involvement.

Why the obvious check misses it. Reading all the source and finding no arrangement feels like an incomplete search rather than a property of the system. Time is lost widening greps that cannot succeed.

Symptom. "Which layers exist?" and "which slots does the HUD have?" cannot be answered from a repository checkout. Onboarding, audits and automated validation all stop at this boundary.

Detect. Get what source can give, and mark the rest as open:

rg -o -N 'Tag="(UI\.(Layer|Slot)\.[^"]+)"' -r '$1' Config/ Plugins/*/Config/ | sort -u
rg -n "RegisterLayer|ExtensionPointTag" --glob "*.cpp" --glob "*.h" .

The first command yields the declared vocabulary — the set of names that may exist. The mapping from those names to actual containers requires the editor. Record that as an open question rather than concluding from the empty result; this is the same rule as any other empty search, and it is the honest form of the answer.

Guardrail. Emit the registered layer and slot inventory to the log at startup, or to a console command. One dump converts a permanently unanswerable question into a cheap one.