Tactical Action · Architecture

Input Pipeline

A focused page within the Tactical Action Architecture documentation. The full map and every sibling page live in the Architecture hub.

9sections11 minread1diagram

On this page

V4 is one game that has to feel like six. A tactical-FPS operator twin-sticking between cover, a social-stealth assassin tapping a single light-out button, a real-time-tactics commander dragging a cursor over a floor plan, a Wukong-class action-RPG hero charging a quarter-circle into a heavy, an RTS player box-selecting a battalion, and a 2D run-and-gun pilot holding eight-way on a d-pad — all of them share one pawn, one Ability System Component, and one input subsystem, and they differ only in which Input Mapping Context is currently on top of the stack. That is the entire job of V4's input layer: take Unreal's Enhanced Input, partition it cleanly into seven per-cell contexts, and route the resulting actions either into the Gameplay Ability System through a single GA.Activation.Tag.* namespace, into a fighting-game-style motion recognizer for the ARPG cell, or into a planning cursor for the top-down cells.

Unlike V2 — whose input pipeline was a rollback-deterministic ring buffer feeding a byte-exact replay encoder, because its entire combat core re-simulated frames — V4's GAS stack is a conventional, replicated, server-authoritative one (see GAS Layout). V4's input pipeline reflects that: it is real, working C++ built on Enhanced Input, but it is not a frame-buffer/replay machine. It is a context-switching router, a per-cell data manifest, a small directional motion parser, a cover-projection input modifier, and a tac-map cursor. Where this page names a class or a path it names a real artifact in V4/ue/Source/V4Input unless it says otherwise. This page is part of the Combat, Character & Input Systems group; the section hub is ../V4_ARCHITECTURE.md.

What ships, honestly#

The V4Input module is real, working Unreal C++, not a skeleton. It builds against Core/CoreUObject/Engine/V4Core/EnhancedInput/GameplayAbilities/GameplayTags (V4Input.Build.cs) and ships five cooperating pieces, all exercised by a single automation spec, V4.Input.MotionParser.RecognizesArpgMotions (V4/ue/Source/V4Tests/Private/V4InputTests/MotionSpec.cpp):

  • UV4InputRouter — a ULocalPlayerSubsystem that pushes/pops UInputMappingContexts onto an Enhanced Input stack and routes input actions to GAS by tag (V4InputRouter.cpp).
  • UV4InputPipelineManifest — a UDataAsset that authors the full per-cell input table in code (48 input actions, 7 mapping contexts, 31 ability bindings, 6 cover-anchor graphs) and validates it (V4InputPipelineManifest.cpp, 404 lines).
  • FMotionParser / UV4MotionParser — a directional motion recognizer over a 16-frame window (V4MotionParser.cpp, 276 lines).
  • UV4CoverStickInputModifier — a real UInputModifier that projects stick input onto a cover tangent (V4CoverStickInputModifier.cpp).
  • FPlanCursor / UV4TacMapCursorComponent — the top-down planning cursor with waypoint and vision-cone authoring (V4PlanCursor.cpp, V4TacMapCursorComponent.cpp).

Five honest qualifications, so the rest reads at face value:

  1. The architecture monolith's names drift from the code. V4_ARCHITECTURE.md says the router lives in V4Input::FInputRouter and the parser in V4Input::FMotionParser "reused from V2." In the repo the router is the UObject subsystem UV4InputRouter, and the motion parser is a fresh, smaller V4 reimplementation — a clean-room cousin of V2's idea, not shared code. V2's parser recognized 22 motions (EV2MotionInput) with negative-edge, plink, tiger-knee and Korean-backdash logic; V4's EV4MotionCommand has 11 (V4InputTypes.h:31). Treat the "reused from V2" line as lineage, not a literal dependency.
  2. The mapping contexts are specs, not authored assets. The manifest stores contexts as FName-keyed FV4InputMappingContextSpec rows (context name, cell, priority, action-name list) — IMC_Tactical, IMC_Stealth, … exist as data, not yet as UInputMappingContext .uasset binaries. The router consumes real UInputMappingContext* objects at runtime; the manifest describes which actions each context should carry and validates that table.
  3. FPlanCursor lives in V4Input, under namespace V4Tactics. The monolith writes V4Tactics::FPlanCursor as if it were in a separate Tactics module; it is V4Input/Public/V4PlanCursor.h, namespaced V4Tactics. The namespace matches the prose; the module location does not.
  4. The cell-switch driver is a mechanism, not yet a shipping caller. The router's push/pop is real and tested, but no shipping subsystem outside the test yet calls PushInputMappingContext on a cell change — that wiring (EV4RulesetCell flip → pop old IMC, push new) is the integration point the cell-state layer owns.
  5. There is no V2-style buffering or replay here. No input ring buffer, no IsInputBufferWindowSatisfied GAS gate, no replay byte encoder exists anywhere in V4Input, V4Gameplay, or V4Netcode. "Buffering" in V4 means exactly two things: Enhanced Input's own trigger/hold state, and the motion parser's 16-frame sliding window. Nothing in this pipeline is frame-replayed.

The shape of the pipeline#

flowchart LR DEV[Device event] --> EI[EnhancedInput\nLocalPlayer subsystem] EI --> STK[Active IMC stack\nUV4InputRouter push/pop] STK --> IA[Input Action\nIA_Fire / IA_Move / IA_LightAttack ...] IA -->|Axis2D move in cover| MOD[UV4CoverStickInputModifier\nproject onto cover tangent] IA -->|ability input| ROUTE[UV4InputRouter::TriggerAbilityInput] ROUTE -->|TryActivateAbilitiesByTag| GAS[UV4GameplayAbilityBase\nIsAbilityCellCompatible gate] IA -->|ARPG stick history| MP[FMotionParser\n16-frame window] MP -->|MatchMotion| CMD[EV4MotionCommand\nQCF / HCF / charge] CMD --> GAS IA -->|Tactics cursor / waypoints| CUR[FPlanCursor\nwaypoints + RTST cones] MAN[UV4InputPipelineManifest] -. authors + validates .-> IA MAN -. ApplyAbilityBindings .-> ROUTE

Everything begins at Enhanced Input: a device event resolves, against the currently applied mapping contexts, into a named Input Action. From there the action takes one of four paths — the cover modifier reshapes a move axis, the router fires a GAS ability by tag, the motion parser accumulates a directional history for ARPG combos, or the plan cursor moves and drops waypoints. The manifest is the off-to-the-side authority that declares every action and binding and asserts the table is coherent.

Enhanced Input and the mapping-context router#

UV4InputRouter is a ULocalPlayerSubsystem (V4InputRouter.h:13) — one per local player, the natural home for per-player context state. It keeps a MappingStack of FV4InputMappingStackEntry rows, each a {UInputMappingContext*, Priority, LayerName} triple (V4InputTypes.h:61). Three calls drive it:

  • PushInputMappingContext(Context, Priority, LayerName) appends an entry and rebuilds (V4InputRouter.cpp:8).
  • PopInputMappingContext(LayerName) removes the last entry with that layer name — FindLastByPredicate, so push/pop nests correctly when the same layer is stacked twice — and rebuilds (V4InputRouter.cpp:26).
  • ClearInputMappingStack() empties it.

The rebuild is the load-bearing part. RebuildAppliedContexts fetches the UEnhancedInputLocalPlayerSubsystem from the owning ULocalPlayer, calls ClearAllMappings(), then re-adds every stacked context at its stored priority (V4InputRouter.cpp:108). This is a genuine Enhanced Input integration: the V4 stack is the source of truth, and Enhanced Input's own context set is re-derived from it on every change. Priority is how the menu layer wins: in the manifest the cell contexts sit at priority 100 while IMC_UI sits at 1000 (V4InputPipelineManifest.cpp:148), so pushing the UI context on top of, say, IMC_Tactical lets menu navigation shadow fire/reload without popping the gameplay context. Switching cells mid-session is therefore "pop the old cell's LayerName, push the new cell's UInputMappingContext" — the router supplies that mechanism; the cell-state layer (EV4RulesetCell, V4Core/Public/V4CellState.h:7) supplies the trigger.

From input action to ability: the GA.Activation.Tag.* seam#

GAS is reached without any per-action C++ wiring. The router holds a TMap<FName, FGameplayTag> of activation tags by input-action name. RegisterAbilityInputBinding populates it (rejecting None names and invalid tags), and TriggerAbilityInput(ActionName, ASC) looks the tag up and calls AbilitySystemComponent->TryActivateAbilitiesByTag(...) with a one-tag container (V4InputRouter.cpp:64). So IA_Fire does not know about GA_Fire; it knows the tag GA.Activation.Tag.Fire, and GAS resolves which granted ability answers it. The ability then self-gates on cell: UV4GameplayAbilityBase::ActivateAbility fails with reason wrong-cell if IsAbilityCellCompatible is false (V4GameplayAbilities.cpp:32), which is what keeps a Tactical IA_Fire from firing an ARPG verb even if a stray tag leaks through. The bindings themselves are authored in the manifest as FV4InputAbilityBindingSpec rows — {IA_Fire → GA_Fire → GA.Activation.Tag.Fire} — and ApplyAbilityBindings(Router) pushes all 31 into the router in one call (V4InputPipelineManifest.cpp:393). The verbs those tags reach are detailed in GAS Layout.

The per-cell input manifest#

UV4InputPipelineManifest::LoadDefaultInputPipeline is the single source of truth for "what can the player press, in which cell, and what does it do." It populates four arrays in code (V4InputPipelineManifest.cpp:91):

  • 48 input actions (FV4InputActionSpec), each carrying a value type (Boolean/Axis1D/Axis2D), the set of EV4RulesetCells it belongs to, an optional GA.Activation.Tag.*, and the bUsesCoverStickModifier flag. IA_Move is shared across Tactical/Stealth/ARPG and is the one action flagged for cover projection; cell-specific verbs like IA_BreachStack (Tactical), IA_BodyDrag (Stealth), IA_Parry (ARPG), IA_TrainUnit (RTS), and IA_Jump2D (Arcade) are scoped to their one cell.
  • 7 mapping contextsIMC_Tactical, IMC_Stealth, IMC_Tactics, IMC_ARPG, IMC_RTS, IMC_Arcade (priority 100) and IMC_UI (priority 1000) — each listing the action names it activates.
  • 31 ability bindings mapping ability-input actions to GA_* ids and activation tags.
  • 6 cover-anchor graphs, one per launch map, each built from named anchors (EntryStack/FlankLeft/FlankRight) with location, surface normal, width, and left/right peek flags, plus edges describing connectivity (V4InputPipelineManifest.cpp:73).

The value of a manifest is its validator. ValidateInputPipeline(OutIssues) is a real gate, not a smoke test (V4InputPipelineManifest.cpp:199). It asserts: all seven required IMCs are present; every action has a name; every context names only actions that exist and is non-empty; IA_Move is Axis2D and carries the cover-stick modifier (:261); every bGameplayAbilityInput action's tag begins with GA.Activation.Tag. and has a matching binding whose AbilityId begins with GA_; and every cover graph has ≥2 named anchors, ≥1 edge, positive anchor widths, and edges that reference only declared anchors. The spec runs the validator and fails the test on any issue, so the table cannot drift silently.

The motion parser (ARPG input scheme)#

Only the ARPG cell uses fighting-game motions, and FMotionParser is the recognizer (V4MotionParser.h:10). It is a stateless-by-config value type with three tunables: WindowFrames = 16, DirectionDeadZone = 0.35, ChargeMinFrames = 10 (V4MotionParser.h:20). The UObject wrapper UV4MotionParser exposes the same surface to Blueprint and copies the editable settings into the native parser before each query (V4MotionParser.cpp:262).

The sample model. AddDirection(Direction, Frame) ignores Neutral, coalesces a repeat of the current direction by just updating its frame (so a held direction is one sample, not many), otherwise appends a new FV4MotionSample{Direction, Frame, StartFrame}, then prunes any sample older than WindowFrames (V4MotionParser.cpp:17). StartFrame is preserved across coalescing, which is what makes charge timing measurable. The raw-stick path AddDirectionalInput(RawInput, Frame) first quantizes: vectors shorter than the deadzone are Neutral, otherwise the X/Y signs against the deadzone resolve all eight compass directions, with diagonals taking precedence (V4MotionParser.cpp:92). The spec checks FVector2D(0.8, -0.8) → DownForward.

Matching. MatchMotion(Command, CurrentFrame) switches on EV4MotionCommand and dispatches to one of three matchers (V4MotionParser.cpp:44):

  • Sequences (MatchSequence) walk the sample history newest-to-oldest, consuming the required direction list in reverse and ignoring samples older than the window (:144). QuarterCircleForward is {Down, DownForward, Forward}, HalfCircleForward is the five-step {Back, DownBack, Down, DownForward, Forward}, and FullCircle accepts either rotation direction via two sequences OR-ed together (:64). Because it scans for the directions in order but not contiguously, intermediate noise inside the window is tolerated — sloppy diagonals still read.
  • Charges (MatchCharge) find a release sample in the target direction within the window, then look further back for a hold sample in the charge direction whose StartFrame is at least ChargeMinFrames earlier (:173). ChargeBackForward and ChargeDownUp are the two classic charge inputs.
  • Motion-charges (MatchMotionCharge) require both a full directional sequence and a held transition between two adjacent samples separated by ≥ChargeMinFrames (:199) — the Mishima-style "hold then roll" command.

What it's tested against. MotionSpec.cpp builds real frame histories and asserts: a {Down@1, DownForward@4, Forward@8} reads as both QCF and partial-circle at frame 8 but expires by frame 40 (window enforcement); a five-step input reads as half-circle-forward; a back-hold-then-forward with ChargeMinFrames = 6 reads as a back-forward charge; a longer hold-into-quarter-circle reads as a motion-charge; an eight-direction sweep reads as a full circle; and the native FMotionParser recognizes partial circles directly. These would fail against a stub — they pin specific frames and specific commands. The button that confirms the motion is not matched here: it arrives as its own ability-input action (IA_LightAttack, IA_HeavyAttack), and combat correlates motion + button. The parser only answers "was this shape drawn within 16 frames."

Cover-stick and tac-map cursor#

Two per-cell schemes round out the module. UV4CoverStickInputModifier is a real UInputModifier whose ModifyRaw_Implementation reshapes a 2D stick value by projecting it onto a CoverTangent: ProjectInputOntoCover normalizes the tangent (falling back to (1,0) if degenerate) and returns tangent * dot(input, tangent) (V4CoverStickInputModifier.cpp:3). That is the "left-stick scoots along cover" feel — any push gets flattened onto the cover wall's direction. The spec confirms (1,1) projected onto (1,0) yields (1,0). The anchor graph it slides along is the manifest's cover data; the modifier consumes a tangent, the graph supplies anchors and edges, and the traversal that walks between them is the cover component's job in the per-cell layer, not this module's.

FPlanCursor (and its UV4TacMapCursorComponent wrapper) is the Tactics/RTS cursor (V4PlanCursor.h:15). MoveCursor(Input, DeltaSeconds) clamps the stick to unit length, advances at CursorSpeed, and clamps to WorldBounds (V4PlanCursor.cpp:5). Beyond moving, it does two authoring jobs the architecture calls out: it records plan waypoints (AddPlanWaypoint snapshots the cursor location — the Raven Shield planning step) and it authors vision cones for the RTST cone tool via BeginConeAuthoring / UpdateConeAuthoring (origin → aim direction, range, half-angle clamped to [1,179]°) / CommitConeAuthoring (V4PlanCursor.cpp:41). The spec drives the shared cursor through a commit and asserts one stored waypoint and one authored cone, which is exactly the "shared by the planning step and the cone-authoring tool" claim made concrete.

Failure modes and edge cases#

  • Null and empty. PushInputMappingContext(nullptr, …) is a no-op; RebuildAppliedContexts bails if there is no Enhanced Input subsystem (e.g. no local player yet), so the stack stays consistent and re-applies later.
  • Held vs. tapped. Because AddDirection coalesces repeats into one sample, a held direction cannot inflate into a sequence, and charge timing reads off the preserved StartFrame rather than sample count.
  • Window expiry. Any sample older than WindowFrames is pruned on insert and skipped on match, so a motion that takes too long simply never completes — the tested "QCF expires by frame 40" behavior.
  • Wrong-cell leakage. Even if an activation tag reaches the wrong cell's ability, the GAS IsAbilityCellCompatible gate rejects it with wrong-cell, so a mis-stacked context cannot fire a foreign verb.
  • Manifest incoherence. A context that names a missing action, an ability action with no binding, a non-Axis2D IA_Move, or a cover graph with <2 anchors all fail ValidateInputPipeline, and the automation spec turns those issues into test failures.
  • GAS Layout — the GA.Activation.Tag.* verbs these actions fire and the cell-compatibility gate that scopes them
  • Animation Pipeline — the montages, combo machines, and dodge/parry frame windows the ability activations drive
  • Per-Cell Deep-Dives — the cover-traversal, planning, and combo components the cover graph, plan cursor, and motion parser feed
  • The section hub: ../V4_ARCHITECTURE.md