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— aULocalPlayerSubsystemthat pushes/popsUInputMappingContexts onto an Enhanced Input stack and routes input actions to GAS by tag (V4InputRouter.cpp).UV4InputPipelineManifest— aUDataAssetthat 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 realUInputModifierthat 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:
- The architecture monolith's names drift from the code.
V4_ARCHITECTURE.mdsays the router lives inV4Input::FInputRouterand the parser inV4Input::FMotionParser"reused from V2." In the repo the router is the UObject subsystemUV4InputRouter, 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'sEV4MotionCommandhas 11 (V4InputTypes.h:31). Treat the "reused from V2" line as lineage, not a literal dependency. - The mapping contexts are specs, not authored assets. The manifest
stores contexts as
FName-keyedFV4InputMappingContextSpecrows (context name, cell, priority, action-name list) —IMC_Tactical,IMC_Stealth, … exist as data, not yet asUInputMappingContext.uassetbinaries. The router consumes realUInputMappingContext*objects at runtime; the manifest describes which actions each context should carry and validates that table. FPlanCursorlives inV4Input, under namespaceV4Tactics. The monolith writesV4Tactics::FPlanCursoras if it were in a separate Tactics module; it isV4Input/Public/V4PlanCursor.h, namespacedV4Tactics. The namespace matches the prose; the module location does not.- 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
PushInputMappingContexton a cell change — that wiring (EV4RulesetCellflip → pop old IMC, push new) is the integration point the cell-state layer owns. - There is no V2-style buffering or replay here. No input ring buffer, no
IsInputBufferWindowSatisfiedGAS gate, no replay byte encoder exists anywhere inV4Input,V4Gameplay, orV4Netcode. "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#
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 ofEV4RulesetCells it belongs to, an optionalGA.Activation.Tag.*, and thebUsesCoverStickModifierflag.IA_Moveis shared across Tactical/Stealth/ARPG and is the one action flagged for cover projection; cell-specific verbs likeIA_BreachStack(Tactical),IA_BodyDrag(Stealth),IA_Parry(ARPG),IA_TrainUnit(RTS), andIA_Jump2D(Arcade) are scoped to their one cell. - 7 mapping contexts —
IMC_Tactical,IMC_Stealth,IMC_Tactics,IMC_ARPG,IMC_RTS,IMC_Arcade(priority 100) andIMC_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).QuarterCircleForwardis{Down, DownForward, Forward},HalfCircleForwardis the five-step{Back, DownBack, Down, DownForward, Forward}, andFullCircleaccepts 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 whoseStartFrameis at leastChargeMinFramesearlier (:173).ChargeBackForwardandChargeDownUpare 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;RebuildAppliedContextsbails 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
AddDirectioncoalesces repeats into one sample, a held direction cannot inflate into a sequence, and charge timing reads off the preservedStartFramerather than sample count. - Window expiry. Any sample older than
WindowFramesis 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
IsAbilityCellCompatiblegate rejects it withwrong-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-
Axis2DIA_Move, or a cover graph with <2 anchors all failValidateInputPipeline, and the automation spec turns those issues into test failures.
Related#
- 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