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,387 @@
---
name: ue-input-architecture
description: >-
Design or review Enhanced Input architecture in Unreal Engine: physical
mapping contexts, input actions, semantic gameplay tags, input config data,
native versus ability input paths, per-frame buffering on the ability system
component, player-mappable settings, device switching, and runtime injection
and removal of input by feature plugins. Use when adding controls, rebinding,
gamepad or touch support, ability input, mode-specific input, or debugging
actions that bind correctly and never fire.
---
# UE input architecture
The invariant:
> Physical controls, semantic commands and gameplay implementations are three
> separate layers joined by validated data.
```text
key / gamepad / touch
→ input mapping context
→ input action
→ input config: input action → input tag
→ native handler OR ability system component
→ ability set: input tag → ability
```
Measured patterns from a reference product: [patterns](references/patterns.md).
Detection recipes for silent input failures: [failure modes](references/failure-modes.md).
**The characteristic failure of this architecture is a button that does nothing
and logs nothing.** Read the failure modes before adopting it — most of them are
variations on that one observable.
Related skills: `ue-gameplay-tag-governance`, `ue-gas-architecture`,
`ue-modular-gameplay`, `ue-ui-architecture`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. Keep the three layers independent
### Physical — the mapping context
Owns key, button and axis to input action; modifiers and triggers; priority
relative to other contexts; player-mappable metadata.
Changing a keyboard layout or a gamepad binding should stop here.
### Semantic — the input config
An immutable asset owning pairs:
```text
IA_Move → InputTag.Move
IA_WeaponFire → InputTag.Weapon.Fire
IA_Dash → InputTag.Ability.Dash
```
Gameplay depends on tags, not on asset paths or physical keys.
### Capability — handlers and ability sets
Native tags bind to explicit handlers. Ability tags travel to the ability system
component as payload. Granted ability specs carry the matching semantic tag.
Changing an ability implementation should not require touching a mapping context.
---
## 2. Split native and ability actions
Make the distinction explicit in the config, with two separate lists.
**Native** — deterministic controller and pawn functions: move, look, crouch,
autorun. Each costs one explicit binding line, and the tag is a **lookup key at
bind time only**. The handler receives an input value; the tag does not exist at
runtime.
**Ability** — bound through one pressed/released pair with the row's tag passed
as a payload argument. One pair of functions serves any number of abilities, and
the count of new abilities you can add without writing code is unbounded.
### Classification test
Native when the action controls continuous motion, must execute immediately and
deterministically, and needs no ability lifecycle.
Ability when the action activates a grantable capability, can be blocked or
cancelled by tags, has cost or cooldown or prediction, or changes with equipment
or mode.
### Watch what the ability path fixes at bind time
If ability binding is hardcoded to two trigger events, then hold, tap and
double-tap variants must be expressed through triggers and modifiers in the
mapping context rather than through trigger-event selection. That is a
reasonable constraint — but it is a constraint, and it is invisible until
someone needs a third event.
---
## 3. Join semantic tags at grant time
When an ability set grants an ability, add the configured input tag to the
spec's dynamic source tags. Lookup then becomes:
```text
pressed input tag
→ scan activatable specs
→ exact tag match
→ buffer the spec handle
```
Do not store an input action pointer on the ability. That couples a capability to
one content asset and blocks per-mode and per-device remapping.
### The match is exact, not hierarchical
A spec tagged `InputTag.Weapon` is **not** matched by a press of
`InputTag.Weapon.Fire`. The tag hierarchy organises the vocabulary and filters
the editor picker; it does not participate in lookup. Assuming otherwise
produces a binding that is correct in every visible respect and never fires.
Recipe: IN-01.
### Validate the join
The input config and the ability set are independent assets. Their tag equality
is a foreign key with no database behind it, so validation is the only compiler
it will ever have:
- every input-driven ability has a non-empty tag;
- every intended ability tag is reachable from at least one active config;
- every config ability tag has a granted consumer in the intended composition;
- duplicates are intentional;
- category constraints hold on both sides.
---
## 4. Buffer ability input, process once per frame
Forwarding a tag must not activate immediately. Record held handles, pressed this
frame, released this frame; process from the controller's post-input step in
three passes — held with the while-active policy, then pressed with the
on-triggered policy, then activate everything in one batch, then releases.
This removes event-order dependence and makes simultaneous input resolvable.
See `ue-gas-architecture` for the activation policies and the global block tag.
**Accept the consequence:** the ability path is one input-processing step behind
the native path within the same frame. For most games that is invisible. It is
still a real difference between the two paths, and it belongs in the
classification decision rather than being discovered later.
---
## 5. Initialize input at a named readiness boundary
Binding depends on several independently arriving pieces: a local controller, the
input subsystem, the pawn data and its config, the project's input component
subclass, and any contexts added by features.
Do not treat `BeginPlay` as ready. Use init states and emit a semantic event —
"bind inputs now" — once the dependencies hold.
Feature actions must respond to **both** the generic extension-added event and
the semantic ready event, because either can arrive first. Set the readiness flag
*before* broadcasting, or a handler that checks it during the broadcast will see
false.
### Base initialization checklist
- [ ] the component is the expected input subclass, checked with a diagnostic
that names the fix;
- [ ] clearing existing mappings is deliberate and its consequences are handled;
- [ ] default contexts added to the local player subsystem;
- [ ] settings registration separated from runtime activation (§6);
- [ ] native actions bound explicitly;
- [ ] ability actions bound **and their handles retained** (§7);
- [ ] the ready event is sent exactly once per initialization;
- [ ] late feature handlers can bind safely.
### Clearing all mappings is a broadcast dependency
If initialization clears every mapping on the subsystem, it also removes contexts
added by already-active features. Recovery then depends on those features hearing
the ready event and re-adding their contexts — which makes correctness a property
of event ordering rather than of stored state. It works; it is fragile; and it
must be a documented decision rather than a side effect. Recipe: IN-08.
---
## 6. Separate "register with settings" from "activate now"
These are two different operations:
```text
RegisterInputMappingContext(IMC) // discoverable and rebindable
AddMappingContext(IMC, priority) // actually active
```
A context may be active but not remappable, or registered but not currently
active. All four combinations are meaningful.
**One flag must not gate both.** If it does, unchecking "expose this for
rebinding" also silently removes the input — a designer changes a settings
checkbox and the character stops responding, with nothing in the log. Recipe:
IN-06.
---
## 7. Modular input needs ownership receipts
### Adding a mapping context
Data: context, priority, register-with-settings flag. Retain per activation
context: the extension and delegate handles, the exact players touched, and the
exact contexts and priorities activated.
### Adding ability bindings
Data: a list of input configs. Retain per pawn: **every returned bind handle**,
which config produced it, and the readiness subscription.
### Symmetric removal
```text
remove the exact bind handles this config created
remove the exact contexts this action added
unregister from settings only if this action registered them
release extension and delegate handles
```
**Never implement removal by clearing all bindings or all mappings on the pawn.**
That destroys other features' ownership, and it will look like it works.
The measured reference composes this well and tears it down badly: bind handles
are declared as locals and discarded, so the removal function *cannot* be
implemented without changing the component that binds. Copy the composition;
write the teardown yourself. Recipes: IN-03, IN-04.
---
## 8. Device switching belongs above gameplay semantics
Gameplay should not branch on keyboard versus gamepad for the same command. The
UI layer owns the active input type, glyphs, controller brand and style,
change notifications, and disconnected-device handling.
Gameplay may still use **distinct actions and tags** where the semantics genuinely
differ — mouse delta and analog stick look need different modifiers and
sensitivity, so:
```text
InputTag.Look.Mouse
InputTag.Look.Stick
```
That is semantic distinction, not device sniffing. The test is whether the two
paths do different arithmetic; if they do, they are different commands.
Per-player sensitivity, dead zones and inversion belong in input modifiers that
read settings — not in handler code. Note that a modifier which resolves a setting
by property **name** through reflection will break silently on a rename, with no
compiler help. Recipe: IN-10.
---
## 9. Context priority and consumption
A handler can be perfectly bound and never fire because an earlier context
consumes the key.
Before adding a mapping: list every active context in priority order; find all
mappings for the physical key; inspect triggers, modifiers and consume-input
behaviour; establish whether the contexts can be active simultaneously; and
confirm no raw key path bypasses the system.
Priority is meaningful only between concurrently active contexts. Document why a
feature needs to outrank the baseline.
### Avoid raw key fallback
Raw key handling bypasses rebinding, device parity, triggers and modifiers,
context priority, and input profiles. Use it only for editor and debug controls,
with a stated reason.
---
## 10. The config-file boundary
With Enhanced Input, the project input config file should contain the
player-input and input-component classes, user-settings class registration, debug
bindings, UI input defaults, and legacy axis properties only where the engine
still requires them.
Gameplay mappings live in mapping context assets; semantic mappings live in input
config assets. **Legacy axis configuration that sits beside Enhanced Input is
actively misleading** — it looks like the place sensitivity is set, and it is not.
Recipe: IN-09.
---
## 11. Test matrix
### Composition
Baseline pawn data and config; a mode action set adding a second config;
equipment granting and removing matching abilities; a feature activating both
before and after pawn readiness; two features adding distinct configs at once.
### Lifecycle
```text
spawn → bind → activate feature → press / hold / release
→ deactivate feature → verify no response
→ reactivate → verify exactly one response
```
### Devices
Keyboard and mouse; gamepad; touch; hot-swap while UI is open; a saved and
reloaded remap; two local players with separate profiles.
### Network and abilities
Owning-client predicted activation; server rejection with failure feedback; input
held across a possession or avatar change; the block tag clearing buffered state;
an ability removed while its input is held.
---
## 12. Review checklist
- [ ] Physical key knowledge stops at the mapping context.
- [ ] Gameplay uses semantic tags, not asset references.
- [ ] Native versus ability classification is intentional.
- [ ] Ability input is buffered, not activated inside the event callback.
- [ ] Config and ability-set tags are validated as a join, in both directions.
- [ ] Tag matching semantics are known to be exact, and the data suits that.
- [ ] Binding waits for a named readiness state, not for begin play.
- [ ] Base and feature bindings both retain handles.
- [ ] Runtime activation and settings registration are independent.
- [ ] Deactivation removes only what this owner added.
- [ ] Priority and consumption conflicts have been audited for shared keys.
- [ ] Device icons and method switching stay in the UI layer.
- [ ] Activate, deactivate, reactivate produces no duplicate callbacks.
---
## 13. When this architecture is too much
For a single-mode project with one device family, no dynamic abilities and no
rebinding, direct bindings are honest and this indirection is overhead.
Introduce semantic tags when at least one becomes true: abilities are granted
dynamically; several pawn archetypes reuse controls; features add input at
runtime; rebinding and device parity matter; gameplay must be independent of
content asset paths.
The architecture is paid for by **modularity of content** and **many archetypes
with different action sets**. Without either, the cost is real and the benefit is
not: to answer "what does the left mouse button do?" a reader must traverse
mapping context, action, config, tag, pawn data, ability set and spec tags —
seven assets instead of one call stack.
---
## Provenance
The findings behind the failure modes come from a line-by-line source audit of
the input layer of Epic's Lyra Starter Game on Unreal Engine 5.6 — about 870
lines across seven file pairs, plus its integration points in the hero component
and two feature actions — read as source rather than run.
Source addresses stay in the research archive that produced this skill; what
ships is the detection recipe. Each entry carries a stable identifier (`IN-06`
and up) that resolves back to the audited location.
## Evidence boundary
One project, one engine version, one workspace. Mapping contexts and input
actions are binary assets and were not read, so every claim about which keys map
to which actions is out of scope rather than concluded. Re-run the recipes
against your own tree before acting on any specific claim.
@@ -0,0 +1,405 @@
# Failure modes: input architecture
Eleven ways a tag-addressed input layer binds correctly and never fires.
The shared property is unusually literal here: **the ability input path has no
error case.** A press resolves to a tag, the tag is compared against granted
ability specs, and no match means no activation. That is the same code path as
"you do not currently have this ability", which is a normal state. Nothing logs,
because from the framework's view nothing went wrong.
The native path is louder — an unresolved tag produces an error at bind time —
which is worth knowing when triaging: **if the button is native and silent, the
problem is upstream of the config; if it is an ability and silent, the problem
could be anywhere along seven assets.**
Recipes use `rg` from a project's source root, and were executed against the
audited project while this file was written.
---
## The join
### IN-01 - Tag lookup is exact, and the hierarchy suggests otherwise
**Mechanism.** The ability spec lookup compares the pressed tag against the
spec's dynamic source tags with an exact match. A spec tagged with a parent is
not found by a press of its child.
**Why it is silent.** No match means no activation, which is indistinguishable
from not having been granted the ability. There is no "close match" concept to
warn about.
**Why the obvious check misses it.** Everything else about the tag layer is
hierarchical — the editor picker filters by category, the vocabulary is a tree,
and neighbouring systems in the same codebase do use hierarchical matching. The
reasonable assumption is wrong here, and nothing at either the config or the
ability-set end states the matching semantics.
**Symptom.** An ability that is granted, bound, and correct in every inspectable
respect simply does not activate. Designers re-author the tag, re-save the
assets, and eventually route around it by duplicating the leaf.
**Detect.** Read the comparison and confirm the semantics, then check whether any
authored tag relies on the other one:
```bash
rg -n "HasTagExact|MatchesTag" --glob "*.cpp" . | rg -i "input|spec"
```
Then list every input tag used on an ability spec and every tag used in a config,
and confirm the sets are equal rather than merely overlapping. In the audited
project both lookup sites use exact matching, on adjacent lines.
**Guardrail.** State the matching semantics in a comment at the lookup and in the
tag namespace's own documentation. If hierarchical matching is wanted, implement
it deliberately — do not let readers infer it from the tag shape.
---
### IN-02 - Two tag sources for one namespace
**Mechanism.** Native input tags are declared in C++ as compile-time symbols;
ability input tags are declared in a config file as strings. Both live under the
same namespace root.
**Why it is silent.** Both work. The engine resolves both to the same registry,
and a tag from either source behaves identically at runtime.
**Why the obvious check misses it.** Looking at either source shows a complete,
well-organised list. There is no artefact anywhere that shows both, so the
vocabulary appears smaller than it is and its split appears not to exist.
**Symptom.** An audit of the input vocabulary misses half of it. A rename sweep
covers one source. A developer looking for a tag symbol in C++ concludes a tag
does not exist when it is declared in config.
**Detect.** Count both sources and compare against the namespace:
```bash
rg -n 'UE_DEFINE_GAMEPLAY_TAG\w*\([^,]+,\s*"InputTag\.' --glob "*.cpp" .
rg -n 'GameplayTagList=\(Tag="InputTag\.' Config/ Plugins/*/Config/
```
Both non-empty is the finding. In the audited project the native movement tags
come from C++ and the ability tags from config, and the two sets do not overlap
at all — which is a defensible split, undocumented.
**Guardrail.** One namespace, one declaration source, documented. If a split is
deliberate, draw the line at a sub-namespace boundary and write it down where
both halves are visible.
---
## Ownership and teardown
### IN-03 - Bind handles discarded, making removal impossible
**Mechanism.** The binding helper returns handles through an out-parameter. Both
callers declare that array as a local and let it fall out of scope.
**Why it is silent.** The bindings work perfectly. Handles are needed only to
undo, and nothing undoes during a normal session — pawns are recreated between
matches, which disposes of the input component and hides the leak.
**Why the obvious check misses it.** The out-parameter is passed correctly, the
call is idiomatic, and a removal function exists elsewhere in the class. The
defect is the *storage duration* of a local variable, which no review of API
usage examines.
**Symptom.** Deactivating a feature leaves its ability bindings on a live pawn.
Harmless while the ability is also revoked — the tag reaches the component and
finds no spec — but a re-grant resurrects phantom input.
**Detect.** Find handle arrays declared at the call site rather than as members:
```bash
rg -n -B3 "BindAbilityActions|BindAction\(" --glob "*.cpp" . \
| rg "TArray<uint32>\s+\w+;|TArray<\w*Handle>\s+\w+;"
```
In the audited project this appears at two sites in one component: the initial
binding and the feature-driven addition.
**Guardrail.** Handles live on the object whose lifetime governs the binding,
stored in the same change that creates them. If there is nowhere to store them,
the input is not removable and that must be stated before it ships.
---
### IN-04 - Removal API that exists and does nothing
**Mechanism.** The class exposes a removal function whose body is a comment. A
feature action calls it during teardown.
**Why it is silent.** The call compiles and returns. Nothing distinguishes
"removed the bindings" from "did nothing", because neither returns a result.
**Why the obvious check misses it.** The teardown path *is* implemented in the
feature action — it iterates its tracked pawns and calls removal on each — so a
review of the action finds a complete, symmetric implementation. The empty body
is one call away in a different subsystem.
**Symptom.** As IN-03, from the other end. A team scopes "implement the missing
removal" from the empty function, and finds the estimate was an order of
magnitude low because the handles no longer exist to remove.
**Detect.** Find removal functions with no statements, and dead removal helpers:
```bash
rg -n -A4 "void \w+::Remove\w+\(" --glob "*.cpp" . | rg -B2 "@?TODO"
rg -c "RemoveBinds|RemoveBindingByHandle" --glob "*.cpp" --glob "*.h" .
```
The second command matters: in the audited project a working removal helper
exists on the input component and has **zero callers** — the mechanism is
present, and the data it needs was thrown away by IN-03.
**Guardrail.** A removal function with no body must not compile quietly: make it
pure virtual, assert, or delete the caller. Then check that its helper has at
least one caller — a correct utility nobody can call is the same as no utility.
---
### IN-05 - Tracking container that is never written
**Mechanism.** The context-adding action keeps a list of the controllers it
touched, removes from it, and iterates it during reset — but the add path takes
the per-context data as a parameter and never writes to it.
**Why it is silent.** The reset loop finds the list empty and completes. Contexts
still come off, but only reactively — when the receiver is reported as going
away — so behaviour depends on teardown order.
**Why the obvious check misses it.** Four of five operations on the container
exist, including an assertion at activation that it starts empty. Any check of
"is this container used?" answers yes emphatically. Only the producer is missing.
**Symptom.** Mapping contexts survive feature deactivation when the receiving
controller outlives the feature. Intermittent, ordering-dependent.
**Detect.** Compare reads against writes per container:
```bash
C='ControllersAddedTo'
rg -n "\b$C\b" --glob "*.cpp" --glob "*.h" .
rg -n "\b$C\b\s*\.\s*(Add|AddUnique|Emplace|Push)" --glob "*.cpp" .
```
In the audited project the second search is empty, the authors left a comment on
the reset path noting exactly that, and a sibling action in the same directory
does the equivalent correctly — so the two files diff against each other.
**Guardrail.** A container whose only mutations are removals has no producer.
Assert non-empty after the add path runs.
---
## Configuration traps
### IN-06 - One flag secretly gating two operations
**Mechanism.** Registering a context for rebinding and activating it are separate
operations. In one code path the activation call is nested **inside** the
`if` that tests the register-with-settings flag.
**Why it is silent.** Both operations are legitimate and both are performed when
the flag is set — which it is by default. Nothing about the default case is
wrong, so the coupling never manifests during development.
**Why the obvious check misses it.** The flag's name describes exactly one of the
two things it controls. A designer reading the property, and a reviewer reading
the property, both understand it correctly and neither is looking at the brace
structure twenty lines away. Worse: the *same* project has a second
implementation of the same feature where the flag correctly controls only
registration, so a reader who checks one path and generalises is wrong.
**Symptom.** Unchecking "expose this for rebinding" makes the character stop
responding entirely, with nothing in the log. Diagnosed as an asset problem.
**Detect.** Find activation calls nested inside a settings-registration branch:
```bash
rg -n -B8 "AddMappingContext\(" --glob "*.cpp" . | rg "bRegisterWithSettings|RegisterInputMappingContext"
```
Any hit is the finding. Then check every other implementation of the same
operation in the project and compare — the discrepancy between two paths is
stronger evidence than either path alone.
**Guardrail.** Registration and activation are separate statements at the same
brace level, each guarded by its own condition. Test all four combinations of the
two flags, because all four are meaningful.
---
### IN-07 - Nested condition that disables an unrelated feature
**Mechanism.** The loop that applies default mapping contexts sits inside an
`if` that tests for a semantic config. A pawn with no config therefore gets no
contexts either.
**Why it is silent.** Every shipped pawn has a config, so the guard is always
true and the nesting is never exercised. The two things are unrelated in concept
and adjacent in code.
**Why the obvious check misses it.** The guard is correct for the block it was
written for. The extra scope it captures is a brace position, not a statement,
and nothing names the coupling.
**Symptom.** A new pawn archetype authored without a config is completely
unresponsive — not "no abilities", but no movement, no look, nothing. Read as a
much deeper problem than a missing asset reference.
**Detect.** Read the initialization function's brace structure:
```bash
rg -n -A25 "::InitializePlayerInput" --glob "*.cpp" . | rg -n "if \(|for \(|AddMappingContext"
```
A mapping-context loop indented inside a config guard is the finding.
**Guardrail.** Contexts and configs are independent inputs to initialization and
belong in sibling blocks. Validate that a pawn without a config is a deliberate
configuration rather than a broken one.
---
### IN-08 - Clearing all mappings, then relying on a broadcast to restore them
**Mechanism.** Pawn input initialization clears every mapping on the subsystem
before adding its own — removing contexts that active features added earlier. A
readiness broadcast afterwards prompts those features to re-add theirs.
**Why it is silent.** It works, because the broadcast happens and the features
listen. Correctness is real; it is just a property of event ordering rather than
of stored state.
**Why the obvious check misses it.** The clear is a defensible line with an
obvious intent — start from a known state. The features' re-add is in a different
file, reached by an event. Nothing at the clear site says what it is destroying,
and nothing at the re-add site says why it is necessary.
**Symptom.** A feature's input disappears when a pawn re-initializes, in any
ordering where that feature does not receive or does not act on the ready event —
a feature activated during the broadcast, one whose handler early-returns, or one
whose receiver is not yet registered.
**Detect.** Find the clear and confirm the recovery path:
```bash
rg -n -A3 "ClearAllMappings" --glob "*.cpp" .
rg -n "SendGameFrameworkComponentExtensionEvent" --glob "*.cpp" . -B4
```
A clear with no explicit re-application, relying on an event broadcast elsewhere,
is the finding. In the audited project the flag that gates readiness is set
*before* the broadcast — the correct order, and worth verifying rather than
assuming.
**Guardrail.** Either do not clear what you do not own, or re-apply from a stored
list rather than from a broadcast. If the broadcast is the mechanism, comment it
at the clear site so the dependency is visible from both ends.
---
## Cost and misleading surfaces
### IN-09 - Legacy axis configuration beside Enhanced Input
**Mechanism.** The project input config retains legacy axis entries — dead zones,
sensitivities — while actual sensitivity and dead zone are applied by input
modifiers.
**Why it is silent.** The legacy values are read by the legacy path, which is not
in use. Changing them does nothing, and doing nothing produces no error.
**Why the obvious check misses it.** They are exactly where a developer would
look for sensitivity, with exactly the names they would search for. The file
answers the question asked; the answer is stale.
**Symptom.** Time lost tuning values that have no effect, and a plausible but
wrong belief about where input tuning lives — which then propagates into
documentation.
**Detect.** Compare the legacy entries against the modifiers that really apply:
```bash
rg -n "AxisConfig|Sensitivity=|DeadZone" Config/*.ini
rg -n "class \w*InputModifier" --glob "*.h" .
```
Both non-empty is the finding. In the audited project eleven legacy axis entries
coexist with four modifiers that read player settings, and the modifiers win.
**Guardrail.** Delete legacy entries the engine does not require, or annotate them
in place as inert with a pointer to the modifiers. A misleading config is more
expensive than a missing one.
---
### IN-10 - Setting resolved by property name through reflection
**Mechanism.** An input modifier scales a value by looking up a settings property
**by name** through reflection, with a cache.
**Why it is silent.** A rename produces no compile error, because the name is a
string in data. The lookup fails at runtime and the modifier returns unscaled
input — a plausible value.
**Why the obvious check misses it.** Renaming a property is exactly the operation
IDEs make safe. The developer performs a rename refactor, everything compiles,
every reference updates, and this one does not because it is not a reference.
**Symptom.** Sensitivity, inversion or dead zone silently stops respecting the
player's setting after an unrelated refactor. Reported as "the setting does
nothing" long after the rename.
**Detect.** Find name-based property resolution:
```bash
rg -n "FindPropertyByName|FindFProperty|GetPropertyByName" --glob "*.cpp" .
```
Each hit is a link the compiler does not check. Add a startup assertion for each.
**Guardrail.** Resolve settings through typed accessors. Where reflection is
unavoidable, assert at startup that every name resolves, so a rename fails loudly
at launch rather than quietly in gameplay.
---
## Loading
### IN-11 - Blocking load on the pawn initialization path
**Mechanism.** A mapping context soft reference is resolved with a synchronous
load during pawn input initialization.
**Why it is silent.** It succeeds. The asset arrives, input works, and the cost is
a stall measured in milliseconds on a warm cache.
**Why the obvious check misses it.** A synchronous load is the simplest correct
code, and correctness is what review checks. The cost appears only on a cold
cache, on slower storage, or at a moment — player spawn — that nobody profiles
because it happens before the part they were measuring.
**Symptom.** A hitch at spawn that is attributed to the level, the character mesh
or the ability system, because the input layer is not where anyone looks for a
frame spike.
**Detect.** Find synchronous loads on initialization paths, and contrast them with
the bare accessors elsewhere:
```bash
rg -n "LoadSynchronous\(\)" --glob "*.cpp" . -B6 | rg "Init|BeginPlay|Setup"
rg -n "\.Get\(\)" --glob "*.cpp" . -A2 | rg -v "ensure|check|if\s*\(|UE_LOG"
```
The pairing is the interesting part: the audited project loads synchronously on
the base path and uses bare, unguarded accessors on the feature paths — so one
route hitches and the other silently skips when a bundle did not load.
**Guardrail.** Preload input assets with the bundle that owns them and resolve
with a guarded accessor that logs on failure. Neither blocking nor silently
skipping is acceptable on a path this visible.
@@ -0,0 +1,225 @@
# Patterns: tag-addressed input in a measured reference product
A worked example of the three-layer architecture from `SKILL.md`, measured in one
reference project. It exists to answer the question the norms cannot: **what does
this architecture actually cost, and what does it actually buy?**
The short version, and it is the reason this file is worth reading: the
architecture works, the composition benefits are real and measurable, and the
teardown half was never finished. Those three facts are not in tension. They are
what a reference project looks like.
Markers: **[measured]** — read in source or config; **[derived]** — conclusion
from measured facts; **[open]** — not answerable from source alone.
---
## 1. The shape, in one chain
```text
key
→ mapping context
→ input action
→ [payload: input tag] handler on the hero component
→ ability system component buffers the spec handle
~~~ end of the input-processing step ~~~
→ controller post-process input
→ activation policy decides
→ activate
```
Seven hops, five of them across assets **[measured]**. That count is the whole
trade: it is why a feature plugin can add controls without touching the base
module, and it is why a broken binding takes an afternoon to diagnose.
---
## 2. What the split actually buys, with numbers
| Benefit | Measured evidence |
|---|---|
| Code cost per new ability control | **zero lines** — one loop binds every row of the config **[measured]** |
| Code cost per new native control | one explicit line; the project has exactly five and they have not changed **[measured]** |
| Feature-plugin controls without base-module edits | one plugin ships 11 input actions and its own config, and the base module contains no reference to them **[measured]** |
| One switch to suppress all ability input | a single tag on the ability system component clears the buffers and returns early **[measured]** |
| Same action, different ability per character | the join is a string in two assets; no code path is per-character **[derived]** |
The fourth row is the one most often underestimated. Without a semantic layer,
suppressing input during a menu means either a flag consulted in every handler or
unbinding and rebinding at runtime. Here it is one tag, checked once per frame,
in one place.
---
## 3. What it costs, also with numbers
**Compile-time errors become runtime silence.** The native path logs an error
when a tag cannot be resolved at bind time. The ability path has **no error case
at all** — the pressed tag is compared against granted specs, and no match is
indistinguishable from not having the ability **[measured]**. This asymmetry is
worth internalising as a triage rule: a silent native control is a config
problem; a silent ability control could be anywhere along the chain in §1.
**The lookup is exact, and everything around it looks hierarchical.** Both lookup
sites use exact tag matching **[measured]**, while the editor picker filters by
category and the vocabulary is a tree. A spec tagged with a parent is never found
by a press of its child. Nothing at either end of the join states this. Recipe:
IN-01.
**Two declaration sources for one namespace.** Native tags are C++ symbols;
ability tags are config strings; the two sets do not overlap **[measured]**. Both
are defensible individually. Together they mean no single artefact lists the input
vocabulary. Recipe: IN-02.
**Linear scans on every press.** The lookup iterates all activatable specs
**[measured]**. At the scale of this project that is free; it is proportional to
ability count times presses per frame, and neither number is bounded by the
design.
---
## 4. Teardown: the half that was not finished
This is the cluster worth studying, because the four findings compound into one
outcome and each looks minor alone.
| Finding **[measured]** | Alone | In combination |
|---|---|---|
| Bind handles declared as locals at two call sites and discarded | a missed `TArray` member | removal has no data to work with |
| The removal function's body is a single TODO comment | an unimplemented method | the feature action's teardown calls into it and returns successfully |
| A working `RemoveBinds` helper exists with **zero callers** | dead code | the mechanism was built and could never be reached |
| The context action's tracking list is read, iterated and removed from — and never written | an empty loop | contexts come off only reactively, so behaviour depends on teardown order |
**[derived]** Deactivating a feature leaves its ability bindings on a live pawn.
The practical damage is limited because pawns are recreated between matches and
because an orphan binding sends a tag that matches no spec — but the leak is real,
and a later re-grant of the same ability resurrects phantom input.
The instructive part is the third row. Someone wrote the removal helper. It is
correct. It has never been called, because the handles it needs were thrown away
one function earlier. **A correct utility that nothing can call is
indistinguishable from a utility that does not exist** — and it is worse, because
its presence answers "is removal supported?" with a yes.
The original authors had also left a comment on the broken tracking line noting
that the container is never modified **[measured]**. That is a known defect that
shipped, which is the most honest possible evidence for the general rule: *finding
a defect and fixing a defect are different projects, and reference code shows you
the first.*
Recipes: IN-03, IN-04, IN-05.
---
## 5. One flag, two operations — the sharpest single finding
In the base initialization path, the call that makes a mapping context **active**
sits *inside* the branch that checks whether the context should be registered with
the player's settings **[measured]**.
In the feature-plugin path for the same data structure, the two are separate: the
settings registration is skipped by the flag, and the activation call runs
unconditionally **[measured]**.
**[derived]** So the same checkbox means "expose this for rebinding" in one place
and "expose this for rebinding **and also make it work at all**" in another. Clear
the flag on a base mapping and the controls go silent, with no error and no
obvious connection between cause and effect.
Two properties make this the best example in the file:
1. **It is verifiable in ten seconds** by reading the two files side by side, and
invisible in any amount of reading of either file alone.
2. **The correct version exists in the same codebase**, which removes any argument
about intent. This is not a design decision applied inconsistently; one of the
two is wrong.
Recipe: IN-06. The same initialization path carries a second nesting defect: the
loop over default mapping contexts is nested inside a check for the pawn's input
config, so a pawn with no config gets no contexts either and is completely mute
**[measured]** — two unrelated features disabled by one condition. Recipe: IN-07.
---
## 6. Initialization order as a broadcast dependency
Initialization clears **every** mapping on the subsystem before adding its own
**[measured]**. That includes contexts added by features that activated earlier.
Recovery works, and it works by broadcast: the component sets its readiness flag
and then sends a semantic "bind inputs now" event, which both feature actions
listen for alongside the generic extension event **[measured]**. Features re-add
what was just removed.
**[derived]** This is correct and it is fragile in a specific way: correctness is a
property of *event ordering* rather than of stored state. Nothing reconciles what
was cleared against what was restored, and a feature that is active but not
listening for the ready event loses its contexts permanently.
Worth copying from this: the readiness flag is set **before** the broadcast, not
after **[measured]**. A handler that checks readiness during the broadcast sees
true. That ordering is one line and it is the difference between the pattern
working and not. Recipe: IN-08.
---
## 7. Sediment: things that exist and do nothing
A reference project accumulates these, and each one costs a reader time:
- two mapping-management functions whose bodies contain only a check and a comment,
called from the initialization path, doing nothing **[measured]** — the remains of
a superseded settings API;
- a user-settings class and a key-profile class registered in config, both of which
are empty overrides **[measured]** — extension points presented as features;
- a legacy axis-configuration block sitting beside the modern input system, whose
deadzone and sensitivity values are overridden by modifiers that read player
settings **[measured]**. A reader tuning the legacy values changes nothing.
Recipe: IN-09;
- a settings-driven modifier that resolves a property **by name through
reflection** **[measured]**, so renaming the underlying setting breaks it
silently with no compiler involvement. Recipe: IN-10.
**[derived]** None of these is a bug. Collectively they are the reason "read the
reference and copy what it does" is a bad adoption strategy: a meaningful share of
what it does is nothing.
---
## 8. What the reference got right
1. **The payload trick.** Passing the semantic tag as a bound-delegate payload is
what makes one function pair serve unlimited abilities **[measured]**. It is
the single most portable idea in this file.
2. **Buffer then activate in one batch.** Collecting handles and activating after
all input is processed removes event-order dependence, and the authors left a
comment explaining precisely why — held input would otherwise activate an
ability and then deliver a press event to it **[measured]**.
3. **Setting readiness before broadcasting it** (§6).
4. **Separate actions for mouse look and stick look**, with different tags and
different handlers, because the stick needs frame-time scaling and the mouse
does not **[measured]**. This is semantic distinction done correctly — not
device sniffing in gameplay code, but two genuinely different commands.
5. **Device switching kept entirely out of the input layer.** The input module
contains no reference to input-device types at all **[measured]**; device
changes affect icons, haptics and UI style, and never gameplay bindings.
---
## Provenance
Measured against Epic's Lyra Starter Game on Unreal Engine 5.6, read as source and
config in a single workspace: roughly 870 lines across the input module, plus the
hero component, two feature actions and the input configuration file. Blueprint
assets — the mapping contexts and input actions themselves — are binary and were
not read, so every statement here about which keys map to what is **[open]**.
Source addresses stay in the research archive that produced this skill; each
`IN-` identifier resolves back to the audited location there.
## Evidence boundary
These are examples and failure evidence from one project on one engine version,
not guarantees about others. The counts are properties of that project on that
day and are quoted to show the shape of a contrast, not to be imported. Re-run the
recipes against your own tree.