ADR-0007: Input Action Map — The Project-Wide .inputactions Asset, Three Maps, Two Schemes, the InputFrame Snapshot, and Rebind Persistence
Status
Accepted
Accepted 2026-08-28 —
architecture-review-2026-08-28.md(verdict CONCERNS; review amendments applied before acceptance).
Date
2026-08-28
Engine Compatibility
| Field | Value |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) · Input System package (com.unity.inputsystem 1.20.x — specialist-verified current for 6000.x, changelog through 2026-07) · C# |
| Domain | Input |
| Knowledge Risk | LOW for the core action/rebinding API (InputActionAsset, control schemes, WasPressedThisFrame, PerformInteractiveRebinding, Save/LoadBindingOverridesFromJson — all live-verified unchanged); MEDIUM for two Unity-6-era shifts the training era did not have: project-wide actions (InputSystem.actions, auto-instantiated and auto-enabled) and UI Toolkit consuming the project-wide UI map directly with no InputSystemUIInputModule/EventSystem (2023.2+). Both are designed around below, not assumed. |
| References Consulted | docs/engine-reference/unity/VERSION.md, modules/input.md, modules/ui.md, deprecated-apis.md, current-best-practices.md (Awaitable); live: Input System package docs about-project-wide-actions.md, understand-ui-compatibility.md, configure-ui-input-action-map.md, ui-action-map-reference.md, polling-actions.md, timing-optimize-dynamic-update.md, binding-resolution.md, save-load-rebinds.md, interactive-rebinding.md, default-value-properties.md, CHANGELOG 1.20.0; ADR-0002/0003/0004/0006 |
| Post-Cutoff APIs Used | InputSystem.actions (project-wide actions, 1.8+); Awaitable<T> (6.x). Verified facts: (a) UI Toolkit on 2023.2+ consumes the project-wide asset's UI map via its built-in, scene-independent default runtime event system — no EventSystem GameObject and no InputSystemUIInputModule required (corrected 2026-08-28, architecture review E2: PanelEventHandler/PanelRaycaster exist only when a uGUI EventSystem is present, and then route through InputSystemUIInputModule — the opposite path; this project has no uGUI, so that path never exists); the UI map's name, action names and action types must not be changed; (b) ProcessEventsInDynamicUpdate + polling WasPressedThisFrame in Update is the documented pattern; (c) removing a device cancels any in-progress action bound to it → a Value stick action reads zero (binding-resolution docs) — the Move → 0 claim is settled; (d) 1.20.0 removed per-control pressPoint on stick controls and made action-level press APIs follow an explicit Press interaction's threshold; (e) LoadBindingOverridesFromJson removes existing overrides first by default (replace semantics — never pass false). |
| Verification Required | (1) Generated C# wrapper compiles under 6.3's [SerializeField] fields-only rule (Migration step 1's exit criterion — owner: that story). (2) PerformInteractiveRebinding's OnComplete wraps into AwaitableCompletionSource<T> cleanly on 6.3 (first Settings-screen story, ADR-0014). (3) Binding-override JSON keying on a renamed/removed action: silently skipped vs thrown — decides whether §4's recovery is "partial apply + log" or "catch + .bad.json" (Migration step 4). (4) Setting a per-binding Press interaction threshold from data at boot (ApplyParameterOverride) — Migration step 3. (5) That referencing InputSystem.actions and never new DriftlandsControls() yields exactly one enabled instance (the double-instantiation hazard is documented as the general model, not this exact collision) — an EditMode test counts enabled Player maps. |
Documents Amended by This ADR
| Document | Amendment |
|---|---|
| ADR-0006 | §1 file list gains input.json; manifest.json.files lists it; §2 step 3 Configs.{…} gains Input; stage 9 becomes "Movement / Camera / Input / Inventory / Save / Land" and gains the §5 rows + the aim-persistence schema-negative. |
docs/architecture/architecture.md | §2 Input row (three maps, project-wide, InputFrame); §4 IPlayerInput becomes IPlayerInput.Current : InputFrame; IAimSource retained (Movement pass-through — ruled); Required-ADR row 7. |
| BB AC-33 | unchanged — its "Build map contains no move/relocate verb" assertion is carried by this ADR's asset test. |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0003 (Driftlands.Runtime is the only assembly referencing Unity.InputSystem; InputFrame/IPlayerInput in Rules per the IHostPositions precedent; InputConfig in Data), ADR-0006 (input.json — amended above), ADR-0004 (the atomic writer, reused for bindings.json) |
| Enables | ADR-0009 (PlayerMovement consumes InputFrame.Move/Aim, publishes IAimSource as a pass-through), ADR-0015 (combat: Strike/Dash edges, the Revive channel via Held duration, Downed/Dead suppression inside its state machine), ADR-0011 (Build map actions, Demolish, the ghost from PointerWorld/Aim), ADR-0014 (UI map, capture flag, IRebindService, the settings screen), ADR-0008 (nothing on the wire — input never leaves the machine) |
| Blocks | First playable-character story; build-mode story; rebinding-screen story |
| Ordering Note | Architecture Required-ADR row 7 = ADR-0007 — the last Foundation-layer ADR. Accept after ADR-0003/0006; ADR-0009/0011/0015 read InputFrame and cannot leave Proposed before this is Accepted. |
Context
Problem Statement
Architecture fixes a flat IPlayerInput and "one .inputactions, Player + Build maps, KB/M + gamepad schemes, decoupled aim, rebind persistence" — but not the asset's action list and types, how mouse position and right-stick direction become one aim vector that persists on stick release (movement Rule 3), who detects button edges (combat, harvesting and build mode all react to presses; three edge detectors is a double-fire bug), how the Build map coexists with the Player map (BB Rule 5: any non-Build action exits, move and camera stay live), where rebinds persist, or what input.json holds. Unity 6 adds a question none of the docs asked: the project-wide actions asset is auto-instantiated and auto-enabled, and UI Toolkit reads its UI map directly — so "who owns the instance" must be decided before anything else.
Constraints
- Technical preferences: Input System only; KB/M primary (WASD + mouse-aim), gamepad secondary twin-stick; no hover-only-critical interactions; every action reachable on gamepad.
- Owner rail (ADR-0002): move, dash, aim, ghost preview are local; input never rides the network.
hardcoded_gameplay_value(ADR-0006): deadzones and thresholds are data, validated at boot.- Pillar 4: no input gated by an animation; combat: dash i-frames start on input, owner-authoritative.
- Harvesting Rule 1 / combat Rule 1: Strike is one input; the referent rule decides enemy vs node client-side; no hold-to-channel harvest.
- Combat Rule 9: revive is a short channel (
reviveChannelTime3 s) cancelled by damage (EC5) or the reviver dashing (EC6) — the one hold in the game. - BB Rule 5 + transition table: build mode is client-local; "non-build action" = attack, interact, inventory, menu; move and camera never exit. BB Rule 7: no rotation at MVP (schema-banned). BB Rule 9: demolish is an instant targeted action from Idle/Move, not a state. BB AC-33: the Build map contains no move/relocate verb.
- UI/HUD: explicit gamepad navigation order + visible focus on every panel; rebinding surface in Settings (ADR-0014).
Requirements
- One asset, one live instance, one adapter; consumers read an immutable per-frame snapshot.
- One aim vector, one code path, parity by construction.
- Edges computed once, observed identically by every consumer in that frame; edge consumers run in
Update. - Rebinds persist per machine, atomically, outside the world save; conflicts surfaced; reset always reachable in-game.
- Device switch, disconnect and reconnect safe mid-session.
Decision
**Alternative A: Driftlands.inputactions is the project's project-wide actions asset (generated C# wrapper DriftlandsControls for typed access) with three maps — Player, Build, UI (Unity's reference names/types, consumed by UI Toolkit directly) — and two control schemes — KeyboardMouse, Gamepad. A single Runtime InputAdapter references the one instance through InputSystem.actions (never new), takes ownership of enabling/disabling the Player/Build maps, and reads every action once per frame in Update into an immutable InputFrame (in Driftlands.Rules.Input) exposed through IPlayerInput.Current; aim is resolved by one pure function with last-direction persistence; BB's build-mode routing is one pure function; binding overrides persist as JSON under persistentDataPath/Driftlands/settings/bindings.json; tunables live in input.json.**
1. The asset
| Map | Action | Type | KeyboardMouse | Gamepad | Notes |
|---|---|---|---|---|---|
| Player | Move | Value/Vector2 | WASD (2D composite) | Left stick | moveStickDeadzone |
| Player | AimPointer | Value/Vector2 (pass-through) | Mouse position (screen) | — | KB&M only; world-converted by the adapter |
| Player | AimStick | Value/Vector2 | — | Right stick | aimStickDeadzone |
| Player | Strike | Button | LMB | RT — per-binding Press interaction, threshold triggerPressPoint (not the global defaultButtonPressPoint) | shared by combat + harvesting |
| Player | Dash | Button | Space | RB | i-frames on the edge |
| Player | Interact | Button | E | West | chests, stations, pickup |
| Player | Revive | Button (read as Held duration) | E (held, when an ally is Downed in reach) | West (held) | combat Rule 9 channel — the only hold; see Rule (d) |
| Player | Demolish | Button | RMB (outside build mode) | D-pad Down | BB Rule 9: instant, targeted, from Idle/Move; raises the refund-preview prompt (ruled 2026-08-28) |
| Player | Inventory | Button | Tab | North | opens the bag panel (UI capture) |
| Player | BuildToggle | Button | B | D-pad Up | enters/exits build mode |
| Player | Menu | Button | Esc | Start | pause/settings — protected |
| Build | Confirm | Button | LMB | South | placement commit (BB Rule 6) |
| Build | Cancel | Button | RMB / Esc | East | exits build mode |
| Build | PickPrev | Button | Q (scroll down — fixed secondary) | D-pad Left | structure picker |
| Build | PickNext | Button | R (scroll up — fixed secondary) | D-pad Right | structure picker (row split 2026-08-31 — the pair previously shared one row naming only Q / scroll; R was unbound) |
| UI | Navigate, Submit, Cancel, Point, Click, RightClick, MiddleClick, ScrollWheel, TrackedDevicePosition, TrackedDeviceOrientation | Unity's reference set, names and types unchanged | consumed by UI Toolkit's built-in default runtime event system directly — no EventSystem, no InputSystemUIInputModule, no PanelEventHandler (ADR-0014 owns the panels) |
Rules: (a) Strike and Confirm share LMB; Demolish and Cancel share RMB — deliberate; the adapter routes by build-mode state (§3), so one physical button is "strike/demolish" outside and "place/cancel" inside (BB's "no menu confirmation"). (b) Every Player/Build action has a binding in both schemes except the two aim sources — asserted by the EditMode asset test. (c) Player/Build are disabled by the adapter at boot (project-wide auto-enable would otherwise enable them before the adapter runs) and enabled by it; UI is enabled only while a panel is open. (d) Exactly one action is a hold: Revive. Harvesting Rule 1 and combat Rules 1–3 are press-only. The revive channel is modelled as ButtonState.HeldFor seconds read by ADR-0015 — not as a Hold interaction on the asset — so combat EC5/EC6's cancel rules stay in a pure state machine. The asset test asserts no Hold/MultiTap/SlowTap interaction exists on any binding. (e) No Rotate action — BB Rule 7 bans structure rotation at MVP and AC-2 makes the absent field a schema check; adding rotation is a BB design change. (f) The Build map contains no move/relocate verb (BB AC-33 — asset test). (g) Menu exits build mode before opening pause (BB transition table) — the player never resumes from pause into a live ghost with LMB bound to Confirm. (h) Processors on the asset are limited to deadzones and the RT Press threshold; everything else is adapter/Rules logic.
2. The snapshot
namespace Driftlands.Rules.Input // Rules declares, Runtime implements — ADR-0003's IHostPositions precedent
{
public readonly record struct ButtonState(bool Pressed, bool Held, bool Released, float HeldFor); // edges are THIS-FRAME; HeldFor = seconds held (Revive channel)
public enum InputScheme : byte { KeyboardMouse, Gamepad }
/// One immutable snapshot per rendered frame. Every consumer in a frame reads the same instance.
public sealed record InputFrame(
int FrameIndex,
InputScheme Scheme, // PRESENTATION ONLY (prompt glyphs, settings list) — referenced from Driftlands.Presentation alone (analyzer test)
Vector2 Move, // normalised, deadzoned; zero when no device
Vector2 Aim, // unit; persists at last value when neither source is live
bool AimIsPointer, // this frame's aim came from the pointer — THE discriminator for BB Rule 5's cursor-vs-stick ghost
Vector2? PointerWorld, // mouse world position when present — BB ghost follows this on KB/M
ButtonState Strike, Dash, Interact, Revive, Demolish, Inventory, BuildToggle, Menu,
ButtonState Confirm, Cancel, PickPrev, PickNext, // Build map; all-false unless build mode is active
bool BuildModeActive,
bool UiCaptured); // a panel is open: every Player/Build edge is false EXCEPT Menu and Inventory (§3)
public interface IPlayerInput { InputFrame Current { get; } }
}
namespace Driftlands.Data { public sealed record InputConfig(float MoveStickDeadzone, float AimStickDeadzone, float AimPointerMinDistance, float TriggerPressPoint); } // input.json — NO aim-persistence key (schema-negative)
Vector2 is System.Numerics.Vector2 (ADR-0003). Architecture's flat IPlayerInput is superseded by IPlayerInput.Current. IAimSource is retained (ruled 2026-08-28): PlayerMovement implements AimVector => _input.Current.Aim as a pass-through, and Camera, Combat and the BB ghost keep depending on Movement — this ADR fixes where aim is computed, not who publishes it.
3. The adapter — one instance, one read point, one aim function, one routing function
InputAdapter : MonoBehaviour (Runtime; injected as IPlayerInput; one per machine; [DefaultExecutionOrder(-1000)]):
- Instance ownership.
_controls = new DriftlandsControls(InputSystem.actions)is not used — the wrapper is a typed view overInputSystem.actions(FindActionMap/FindActionwith the generated ids); the adapter never creates a secondInputActionAssetinstance. AtAwakeit disablesPlayer/Build/UI, then enablesPlayer. An EditMode test asserts exactly one enabledPlayermap exists in play (Verification 5). Rebind overrides therefore apply to the one live instance. - Update mode
ProcessEventsInDynamicUpdate; the adapter reads inUpdateat −1000 so every otherUpdatein the frame sees the finished snapshot (the documented pattern: latest events at the start of each frame; poll edges inUpdate). - Binding on consumers: edge consumers run in
Update. Movement (ADR-0009), the combat player state machine and dash i-frame start (ADR-0015 — "i-frames start on input" means the sameUpdate), harvest intent, and build-mode ghost/confirm areUpdate-driven — a constraint this ADR imposes, not an observation. NoFixedUpdateconsumer may readInputFrameedges:FixedUpdateruns zero-or-more times per rendered frame (60 Hz render vs 50 Hz physics ≈ one frame in six has none) and would drop a one-frame press.Held/HeldFor/Moveare safe anywhere. A consumer that needs edges on the physics step takes them from a queue this adapter fills — a new ADR. This says nothing about ADR-0002's open QQ-01 (Drain vsNetworkTransformreceive): this ADR fixes the owner-rail sample point only. - Aim — one pure function.
AimResolver.Resolve(prev, pointerWorld?, playerWorld, stick, cfg): stick aboveAimStickDeadzone→normalize(stick); else pointer present and|pointerWorld − playerWorld| ≥ AimPointerMinDistance→normalize(pointerWorld − playerWorld); elseprev. Last live source wins per frame; the result persists when neither is live (movement Rule 3 — theelse prevbranch, not a config).Camera.ScreenToWorldPointis the only Unity call in the aim path, converted throughUnityInteropbeforeResolve; the world point is also published asPointerWorld. - Edges once.
ButtonState(action.WasPressedThisFrame(), action.IsPressed(), action.WasReleasedThisFrame(), heldFor)per action, once per frame;heldForaccumulates from the press frame. Consumers never call the Input System. - Build-mode routing (BB Rule 5 / transition table) —
BuildModeRouting.Step(active, scheme, rawEdges) → (active, EdgeMask, exitedThisFrame), pure. Exit set =BuildToggle,Cancel, and any Player-map edge other thanMoveand the aim sources —Strike,Dash,Interact,Revive,Demolish,Inventory,Menu(BB's enumerated "attack, interact, inventory, menu") — except that whereStrike/Confirm(LMB) orDemolish/Cancel(RMB) share a physical control (KB/M), the edge inside build mode is consumed as the Build action and does not exit: LMB places, RMB cancels. The exception is keyed on control identity, not action identity (amended 2026-08-31): whichever Player action currently holds a control that a Build action shares is the one masked inside build mode — so after a rebind movesStrikeoff LMB, the Player action now on LMB is masked andConfirmstill places. Exits happen first, then the exiting edge is delivered in the same frame ("ghost vanishes; nothing committed" — then the action applies). Scheme asymmetry, intended: on gamepadStrike(RT) andConfirm(South) are distinct controls — RT exits and strikes, South places; a gamepad player has a dedicated place button.EdgeMasknames every suppressed action so tests assert the whole mask. While inactive the Build map is disabled and itsButtonStates are all-false. - UI capture. ADR-0014's panel manager calls
SetUiCaptured(bool)from anUpdateafter −1000, so capture applies from the next frame (the opening frame delivers theInventoryedge; the panel owns the closing edge). While captured: theUImap is enabled; every Player/Build edge is false exceptMenuandInventory—Menuis protected and must reach the player from every state (it closes the open panel via the panel stack rather than opening pause);Inventorycloses the bag it opened (a panel you cannot dismiss with the key that opened it fails the new-friend test).Moveis still reported (locomotion decelerates naturally; ADR-0009 stops onUiCapturedregardless). Build mode does not survive capture. - Title-screen map state (added 2026-09-01 by ADR-0014 §5; architecture review). Rule (c) above enumerates only the in-world states, where
UIis enabled while a panel is open. The title screen is a fourth state it does not cover: there is no world behind it, soUIis enabled standing (no panel-open handoff) whilePlayeris present but onlyMenuis consumed — the 3-second reset floor (§4) readsMenu, which is a Player-map action. ADR-0014's panel manager derives this from an explicitUiContext(Title|World) set once at scene load; it never inspects which screen is open (that would bescheme_gating_gameplay's sibling). This is a clarification of rule (c)'s scope, not a change to it —title-screen.mdOQ 10 requested it and is closed by ADR-0014. - Combat state is not input state. Downed (crawl-speed, no combat) and Dead suppress
Strike/Dash/Demolishinside ADR-0015's state machine, never here;UiCapturedis not a proxy for it. The adapter's one gate is UI capture. - Devices. Scheme = the last device that produced input (from the action's active control's device); no modal "controller mode". Device removal cancels in-progress actions (documented) →
Movezero;Aimkeepsprev. Reconnect needs no action. - No
PlayerInputcomponent, no Send Messages, no per-consumer callbacks. Nothing butInputAdapterand the rebind service referencesUnityEngine.InputSystem(ADR-0003 reflection testInput_OnlyAdapterReferencesInputSystem);UnityEngine.Inputnowhere.
4. Rebinding and persistence
- Mechanism:
action.PerformInteractiveRebinding(bindingIndex).WithControlsExcluding(<other scheme's devices>)per scheme, wrapped inAwaitable<RebindResult>(project preference —current-best-practices.md; Verification 2). - Protected actions:
MenuandUI.Cancelcannot be unbound; a rebind leaving any Player-map action with no binding in that scheme is refused with a named reason. - **Conflict rule (ruled 2026-08-28: swap on confirm; amended 2026-08-31: confirming exchanges — see Amendments)**: a rebind duplicating an existing binding within the same map and scheme surfaces the colliding action; confirming exchanges the two controls (the colliding action receives the control the rebound action vacated; the empty-binding refusal remains for the residual case), cancelling keeps both unchanged. Cross-map duplicates are legal by design (§1 Rule (a)); the
Interact/Revivepair is exempt (bound together by design — Amendments). - Recovery floor: Reset-to-defaults is reachable without a working binding — holding
Menu's default control (Esc / Start) for 3 s at the title screen resets all overrides regardless of the current set, and the settings screen exposes the same reset. A player can never rebind themselves out of Settings. - Persistence:
SaveBindingOverridesAsJson()→persistentDataPath/Driftlands/settings/bindings.json, written with ADR-0004's atomic writer (temp +File.Replace; noF_FULLFSYNC— a lost rebind is not data loss). Loaded at adapter start withLoadBindingOverridesFromJson(json)(default replace semantics — never passfalse, which merges). Missing file = defaults. Malformed JSON → defaults + loud log + file renamedbindings.bad.json. Overrides naming an unknown action: behaviour decided by Verification 3 (skip-and-log vs catch) — either way the log names the action. Never in the world save — bindings are per machine; ADR-0004's save has no settings table (schema-negative).
5. input.json (ADR-0006 stage 9 — amended)
| Key | Default | Range | Validation |
|---|---|---|---|
moveStickDeadzone | 0.2 | 0.05–0.5 | in range (warning outside) |
aimStickDeadzone | 0.25 | 0.05–0.5 | in range; ≥ moveStickDeadzone − 0.1 |
aimPointerMinDistance | 0.5 u | 0.1–2.0 | > 0 |
triggerPressPoint | 0.5 | 0.2–0.9 | in range; applied as the Press interaction threshold on Strike's RT binding at boot (Verification 4) |
Schema-negative (movement Rule 3): input.json has no aimPersistsOnRelease key and no key that could disable aim persistence — persistence is AimResolver's else prev branch, not a configuration; an EditMode test asserts InputConfig declares no such field (ADR-0003 Schema_* family, the MoveSpeedModifier pattern). No hold durations exist here (reviveChannelTime is combat's, in combat.json).
Amendment to ADR-0006 (same change as Migration step 3): §1 file list gains input.json moveStickDeadzone, aimStickDeadzone, aimPointerMinDistance, triggerPressPoint, listed in manifest.json.files; §2 step 3 Configs.{…, Camera, Input, Save, …}; stage 9 title gains Input and these rows. Source assets/data/input.json, build-mirrored to StreamingAssets/data/; the data-overrides/ folder applies identically.
Architecture Diagram
Project Settings ─► project-wide actions = Driftlands.inputactions (Player / Build / UI · KeyboardMouse / Gamepad)
│ InputSystem.actions — ONE instance, Unity-owned
InputAdapter (Runtime, Update @ -1000) — references, never new()s; owns Player/Build enable
│ reads each action once; ScreenToWorldPoint → PointerWorld
AimResolver.Resolve (Rules, pure) ◄── InputConfig (Data, input.json)
BuildModeRouting.Step (Rules, pure) · UI capture · edges/HeldFor
▼
InputFrame (Rules.Input, immutable) ◄── IPlayerInput.Current
┌──────────────┬──────────────────┬──────────────────┬──────────────────┐
PlayerMovement Combat (ADR-0015) Harvest intent BaseBuilding (ADR-0011) UI Toolkit panels
(ADR-0009) Strike/Dash/Revive Strike + referent Confirm/Cancel/Demolish ◄── UI map (UI Toolkit default event system, direct)
│ IAimSource.AimVector (pass-through, retained)
└──► Camera (aimBiasFrac) · Combat · BB ghost (PointerWorld on KB/M, projected Aim on gamepad — AimIsPointer decides, never Scheme)
bindings.json (persistentDataPath/Driftlands/settings) ◄──► Save/LoadBindingOverridesFromJson (replace semantics)
Key Interfaces
namespace Driftlands.Rules.Input
{
public static class AimResolver
{ /// Pure. One path for both schemes; returns prev when neither source is live (movement Rule 3).
public static (Vector2 aim, bool isPointer) Resolve(Vector2 prev, Vector2? pointerWorld, Vector2 playerWorld, Vector2 stick, InputConfig cfg); }
[Flags] public enum EdgeMask { None = 0, Strike = 1, Demolish = 2, Confirm = 4, Cancel = 8 /* … one bit per maskable action */ }
public static class BuildModeRouting
{ /// Pure. BB Rule 5 + transition table incl. the shared-control exception (LMB/RMB on KB/M) and the gamepad asymmetry.
public static (bool active, EdgeMask mask, bool exitedThisFrame) Step(bool active, InputScheme scheme, RawEdges raw); }
}
namespace Driftlands.Runtime.Input
{
public sealed class InputAdapter : MonoBehaviour, IPlayerInput
{
public InputFrame Current { get; private set; }
public void SetUiCaptured(bool captured); // ADR-0014's panel manager; takes effect next frame
public IRebindService Rebind { get; }
}
public interface IRebindService
{
Awaitable<RebindResult> RebindAsync(string actionId, InputScheme scheme, CancellationToken ct); // Awaitable, not Task; refuses protected actions / empty-scheme results; surfaces conflicts for swap-on-confirm
void ResetToDefaults();
IReadOnlyList<BindingDisplay> Display(InputScheme scheme);
}
}
Alternatives Considered
Alternative B: PlayerInput component with Send Messages / Unity Events
- Cons: Callbacks arrive at arbitrary points in the frame, so combat, harvesting and build mode see edges at different times (the double-fire class); aim persistence and build-mode routing would live in three places;
PlayerInputcouples input to a scene object, but input must exist before the character spawns and survive despawn (Downed/Dead, land teardown). - Rejection Reason: The GDDs' shared-verb rules need one frame view.
Alternative C: Direct device polling in each consumer
- Rejection Reason: No rebinding, no schemes, Unity references in every consumer (ADR-0003 forbids) — fails technical preferences outright.
Alternative D: Exclusive Build map
- Rejection Reason: BB keeps Move and camera live in build mode; an exclusive map duplicates them and a rebind of
Movewould apply to two bindings. Ruled: both maps enabled, routing in the adapter.
Alternative E: Adapter-owned asset instance, no project-wide asset
- Description: Leave project-wide unassigned;
new DriftlandsControls()in the adapter; wire UI Toolkit throughInputSystemUIInputModule+EventSystemby hand. - Cons: Unity 6 documents the module as not required for UI Toolkit and the project-wide asset as the recommended single instance; hand-wiring adds two components and a second input path for the UI to diverge on.
- Rejection Reason: Ruled 2026-08-28 — project-wide asset, referenced by the adapter.
Consequences
Positive
- One asset, one Unity-owned instance, one read point, one frame: every "on press" rule in five GDDs observes identical edges.
- Gamepad parity by construction (one aim function; both-schemes binding test); the one intended asymmetry (RT vs South in build mode) is stated.
- UI Toolkit needs zero input components; rebinds apply to the instance the UI reads.
- Build-mode routing, aim resolution and the revive channel's cancel rules are pure functions with unit tests.
Negative
- Input is sampled at the top of
Update; edge consumers are pinned toUpdateby rule. - Architecture's flat
IPlayerInputbecomesCurrent; the generated wrapper is a checked-in artefact (merge noise on asset edits). Player/Buildmust be explicitly disabled at boot against the project-wide auto-enable — a line that, if lost, double-drives nothing but reads input before the adapter's first frame.
Risks
- Edge starvation on
FixedUpdate— closed by construction (edgesUpdate-only); enforcement candidate: an EditMode test that noFixedUpdatebody referencesIPlayerInput. - Mouse aim ↔ camera aim-bias is a real closed loop (camera offsets toward
Aim→ world point under a stationary cursor moves →Aimrotates → camera moves): bounded, not absent —Aimbeing unit-length bounds the bias magnitude, not the loop; convergence depends onfollowSmoothTime × aimBiasFrac; ataimBiasFrac = 0there is no loop; the gamepad path has none. Camera's "identical on mouse and stick" AC holds in the steady state — stated. Verification (camera story, PlayMode): mouse held still 5 s ataimBiasFrac = 0.20→Aimsettles within 1° without oscillation; if it oscillates, the camera reads the previous frame'sAim(one-frame lag breaks the loop at zero perceptual cost). - Trigger press point: per-binding
Pressinteraction onStrike's RT, not the globaldefaultButtonPressPoint(which would also affect every analog button); 1.20.0 made action-level press APIs follow the interaction's threshold — the asset test asserts the interaction is present with the data-sourced parameter. - Override-file recovery semantics depend on Verification 3 (skip vs throw); both paths log the action name.
- Protected/collision rules drift: asset + EditMode tests over the action set.
- Allocation: one
InputFramerecord per frame (~4 KB/s at 60 Hz); pool only if it appears in a GC profile at ≥ 0.1 % of frame budget — it will not.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| player-movement | Rule 3: decoupled aim, persists on stick release, one vector, mouse/stick parity AC | AimResolver.Resolve (else prev); aim-persistence schema-negative |
| player-movement | AC: input → full speed same frame; release → stop next frame | adapter at −1000; consumers in Update (binding) |
| combat | Rules 1/3/4: three verbs; dash cancels windup; i-frames on input | Strike/Dash edges once per frame; edge consumers in Update |
| combat | Rule 9 / EC5–6: revive short channel, cancelled by damage or dash | Revive HeldFor; cancel rules in ADR-0015's pure state machine; the only hold |
| combat | Downed/Dead: no combat verbs | suppressed in ADR-0015, never in the adapter (stated) |
| harvesting | Rule 1: reuses Strike; no hold-to-channel | one Strike; no Hold interaction on the asset (test) |
| base-building | Rule 5 + transition table: client-local; ghost follows cursor (KB/M) / aim stick (gamepad); any of attack/interact/inventory/menu exits; move + camera live; Rule 6 no menu confirmation | BuildModeRouting.Step; PointerWorld + AimIsPointer (never Scheme) for the ghost; LMB/RMB shared-control exception; Menu exits |
| base-building | Rule 7 no rotation; Rule 9 demolish instant from Idle/Move; AC-33 no move verb on the Build map | no Rotate; Demolish on the Player map; asset test |
| ui-hud | Gamepad nav + focus; no hover-only; no input gated by animation; rebinding surface | Unity-reference UI map for UI Toolkit; IRebindService; capture semantics |
| camera | Rule 2: shared aim vector, identical on both schemes | via IAimSource pass-through; loop risk stated + tested |
| inventory-hauling | AC: own bag updates within one frame of pickup input | Interact edge same frame |
| technical preferences | Input System only; KB/M primary, gamepad partial; no hover-critical | the asset; both-schemes test; no legacy Input (reflection) |
Deferred, with owners
| Item | Owner | Trigger |
|---|---|---|
AwaitableCompletionSource wrap of interactive rebinding (Verification 2) | ADR-0014 / first Settings-screen story | blocks the rebinding UI story, not this ADR |
| Wrapper compiles under 6.3 (Verification 1) | Migration step 1 | the step's exit criterion |
| Override keying on renamed actions (Verification 3) | Migration step 4 | decides §4's recovery wording |
Press threshold from data (Verification 4) | Migration step 3 | Strike RT story |
| Edge queue for a physics-step consumer | new ADR | only if a consumer ever needs it |
Performance Implications
- CPU: ~16 action reads + one
ScreenToWorldPoint+ two pure functions per frame — microseconds. - Memory: one asset instance (Unity's), one frame record per frame.
- Load Time:
bindings.jsonread once. - Network: none — input never leaves the machine.
Migration Plan
No production code. (1) Driftlands.inputactions assigned project-wide + generated wrapper; EditMode asset tests (both schemes bound; protected actions; no hold interactions; no Rotate; Build map has no move verb; UI map matches Unity's reference set; one enabled Player map); (2) InputFrame/ButtonState/IPlayerInput/AimResolver/BuildModeRouting in Rules.Input with UNIT tests; InputConfig in Data; (3) InputAdapter + input.json + stage-9 rows + the aim-persistence schema-negative (ADR-0006 amendment applied in this change); (4) bindings.json via ADR-0004's atomic writer; (5) IRebindService (Awaitable) for ADR-0014; (6) architecture §2/§4 amendments (IPlayerInput.Current; IAimSource retained as pass-through); (7) registry sync in the same change.
Validation Criteria
- UNIT:
AimResolver— stick above deadzone wins; pointer wins when the stick is dead; neither live →prev; identical output for a mouse direction and the same stick direction. - UNIT:
BuildModeRouting— toggle enters;Dash/Interact/Inventory/Menu/Reviveedge exits and is delivered;Movenever exits; KB/M in build mode: LMB →Confirm.Pressed,Strikemasked,BuildModeActiveunchanged; RMB →Cancel,Demolishmasked; Gamepad: RT →Striketrue,Confirmfalse,BuildModeActivefalse; South →Confirm. - UNIT (asset): every Player/Build action bound in both schemes (except aim sources);
Menu/UI.Cancelprotected; noHold/MultiTap/SlowTapinteractions; noRotate; Build map has no move/relocate verb (BB AC-33);UImap name/actions/types equal Unity's reference set;StrikeRT carries aPressinteraction. - UNIT (schema-negative):
InputConfigdeclares no aim-persistence toggle. - UNIT (reflection):
UnityEngine.InputSystemreferenced only byInputAdapter/rebind types;UnityEngine.Inputnowhere;InputSchemereferenced only fromDriftlands.Presentation; noFixedUpdatebody referencesIPlayerInput. - PlayMode: exactly one enabled
Playermap; a one-frame Strike yields exactly onePressedacross combat and harvest consumers; gamepad unplug mid-aim leavesAimunchanged andMovezero. - PlayMode: rebind Dash, restart → restored; corrupt
bindings.json→ defaults +bindings.bad.json; hold default Esc 3 s at title → overrides cleared. - PlayMode (camera story): stationary-mouse aim settles within 1° at
aimBiasFrac = 0.20. - Playtest: the new-friend test on both schemes.
Amendments
- 2026-08-31,
design/ux/build-mode.md(via/ux-review) —Demolishrelease semantics and the hold count. (1)Demolishis consumed onReleased:Pressedopens the targeting preview (highlight + refund, pattern #9); the release resolves the action in one frame — BB Rule 9's "instant" describes the commit, not the gesture. Inside build mode: on KB/M RMB isBuild.Cancel(rule (a), unchanged); on gamepad D-pad Down'sPressededge exits build mode (§3 exit set) and the subsequentReleaseddemolishes only if a candidate is under the reticle — ordinarily nothing.InputFrame.Demolish.Releasedis therefore a consumed edge (edge consumers stayUpdate-only). (2) Rule (d) is clarified: "exactly one action is a hold" means exactly one asset-levelHoldinteraction (Revive— and even that is read asHeldFor, not an asset interaction; the asset test stands). At the design layer threeHeldreads exist —Revive(#10), the title-screen reset floor (§4), and hold-to-repeat placement (Confirm.Held, pattern #31) — each with a no-hold alternative inventoried indesign/accessibility-requirements.md. - 2026-08-31,
design/ux/settings.md— conflict rule = exchange; the Interact / Revive pair; aim sources. (1) §4's conflict rule as written — "confirming clears the other binding (subject to the empty-binding refusal)" — is refused on every conflict in the default set: each Player/Build action has exactly one rebindable binding per scheme (rule (b) is a minimum; the two KB/M actions with a second binding —Build.CancelRMB / Esc,PickPrev/PickNextQ / scroll — carry it as a fixed secondary, see (4)), so clearing the colliding action's binding always trips the refusal; the prompt said "swap" and never swapped. Amended: confirming exchanges — the rebound action takes the captured control and the colliding action takes the control the rebound action vacated, applied as one atomicRebindResult(fields:actionIds,scheme,newPath,exchangedActionId?,exchangedPath?— nothing is cleared under exchange); the empty-binding refusal remains only for the residual case where an action would still end unbound (possible after earlier edits gave an action several bindings). Protected collision: a captured control that belongs to a protected action (Menu) is refused before any prompt —Menunever moves, so the reset floor's "default control" premise stays true. Shared-control pairs:Strike/ConfirmandDemolish/Cancelare not paired for rebinding; §3's shared-control exception follows control identity (amended above), so moving one half away changes which Player action is masked in build mode, never whetherConfirm/Cancelwork. When the Interact / Revive pair is the rebound row and collides with a third action, that action receives the pair's single vacated control. (2)InteractandReviveshare E / West in both schemes by design (§1) — the same-map duplicate rule exempts this pair,IRebindServiceapplies one override to both action ids, and the settings screen shows one row ("Interact / Revive (hold)"). (3)AimPointer(mouse position) is not rebindable — there is nothing to rebind a pointer to;AimStickis (stick swap) — a UX ruling for ADR-0014 to confirm. (4) Fixed secondary bindings (ruled 2026-08-31, second/ux-reviewpass): a rebind row edits one binding — the action's primary for that scheme (binding index 0 of the scheme's group, passed toPerformInteractiveRebinding).Build.Cancel's Esc andPickPrev/PickNext's scroll (down / up) are fixed secondaries — their KB/M primaries are Q and R (§1 row split in this amendment; the pair's single row had leftPickNextwith no rebindable key): never displayed, never rebindable, never present inbindings.jsonoverrides — Esc because it isMenu's protected control (protection is keyed onMenu's control regardless of sharers, so a capture of Esc is refused pre-prompt andCancelcan never leave Esc — rule (g) and BB's Esc exit always hold), scroll because it is a pointer affordance likeAimPointer. An override file that names a secondary binding index is treated like an unknown action (Verification 3 path). Validation Criteria gain: UNIT (rebind service) — an exchange on conflict leaves no action unbound; the Interact / Revive row yields one override on both ids; a capture ofMenu's control is refused with no conflict surfaced; UNIT (BuildModeRouting) — withInventoryrebound to LMB, LMB inside build mode →Confirm.Pressed,Inventorymasked,BuildModeActiveunchanged; UNIT (asset):Build.Cancelkeeps Esc andPickPrev/PickNextkeep scroll after any override load, and no override targets a secondary index; UNIT (rebind service): rebindingInventoryto LMB and confirming yieldsStrike= Tab,Inventory= LMB; PlayMode: rebindDashto E →Dash= E andInteract/Revive= Space.
Related Decisions
- ADR-0002 (owner rail; QQ-01 untouched), ADR-0003 (assemblies;
Rulesdeclares/Runtimeimplements; reflection tests), ADR-0004 (atomic writer; settings never in the save), ADR-0006 (amended:input.json,Configs.Input, stage 9), ADR-0009/0011/0014/0015 (consumers) design/quick-specs/player-movement-2026-08-27.md,design/gdd/combat-system.md,design/gdd/harvesting-system.md,design/gdd/base-building.md,design/quick-specs/ui-hud-2026-08-27.md,design/quick-specs/camera-2026-08-27.md,.claude/docs/technical-preferences.mddocs/architecture/architecture.md§2 Input row, §4IPlayerInput(amended) /IAimSource(retained), Required-ADR row 7