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:
@@ -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.
|
||||
Reference in New Issue
Block a user