Stealth is not a bonus mode in V4 — it is one of the launch cells, and the
perception model that decides whether a guard sees you is the single most
load-bearing AI system in the project. A Hitman waiter, a Commandos Wehrmacht
patrol, a Splinter Cell mercenary, and a Wukong yaoguai all have to read the
world with the same semantics: the same notion of a stimulus, the same
0-to-1 suspicion gauge, the same Calm → Curious → Alert → Hostile escalation,
the same faction-and-disguise recognition. V4 achieves that by making AI
data-oriented rather than tree-driven. There is no per-NPC Behavior Tree
deciding what to think; there is a UV4PerceptionComponent that batches typed
stimuli, an UV4SuspicionStateMachine that integrates them into a scalar, and a
small library of pure functions (UV4PerceptionSenseLibrary) that turn geometry
— a cone, a distance, a lux value, a stance — into stimulus strength. The
stealth layer (V4Stealth) sits on top, modelling the player's side of the
same equation: how much light is on you, how loud your footsteps are on this
surface, whether your disguise survives a line-of-sight check.
This split is deliberate and it is what keeps the system honest across six very
different cells. The perception math lives in V4/ue/Source/V4Perception; the
player-facing stealth verbs live in V4/ue/Source/V4Stealth; dense NPC
populations live in V4/ue/Source/V4Crowd. None of it is a TypeScript contract
package — the source of truth is the Unreal C++ under those modules and the
automation specs under V4/ue/Source/V4Tests. This page is the
architecture-side companion for V4's AI and stealth, part of the AI, Stealth &
Per-Cell Combat group; the section hub is
../V4_ARCHITECTURE.md.
What ships, honestly#
The perception and stealth logic is real, deterministic-by-construction, and
value-tested. Six automation specs cover it —
V4.Perception.Stimulus.Aggregates AndPropagates and
V4.Perception.Suspicion.DecaysThroughStates, V4.Stealth.LightGauge.Sampling
and a disguise spec, and V4.Crowd.Panic.PropagatesAcrossPopulation plus a perf
spec — and they assert computed values, not shapes: that an enforcer's
detection time is exactly half a civilian's, that two stimuli sum to a
hand-derived suspicion amount, that a camera profile decays 0.8 → 0.55 in one
second, that a panic signal reaches all 400 generated agents in under 8 ms.
Those would fail against placeholders.
Four honest qualifications, so the rest of the page reads at face value.
First, there is no Behavior Tree, Blackboard, EQS, or StateTree anywhere in
V4 — a grep for BehaviorTree|Blackboard|EnvQuery|StateTree|UAISense over
V4/ue/Source returns nothing. UV4PerceptionComponent extends
UAIPerceptionComponent and the module links AIModule/NavigationSystem, but
it registers no UAISense; the decision logic is V4's own stimulus loop.
"AI behavior" in V4 means the suspicion state machine, the NPC schedules, and
the crowd tiering — not Unreal's BT stack. Second, V4Crowd links the six
Mass framework modules (MassEntity, MassNavigation, MassAIBehavior,
MassMovement, MassReplication, MassSignals) and
GetMassIntegrationStatus() honestly reports their loaded state via
FModuleManager::IsModuleLoaded (not a struct of constant trues) — but the
panic / tier / flee simulation runs on a plain TArray<FV4CrowdAgentRecord>,
not a Mass processor graph. Each agent carries a real FMassEntityHandle, yet
the per-frame work is array iteration that mirrors Mass concepts (signals, LOD
tiers) rather than a live ECS pipeline. Third, the Splinter Cell light gauge
samples a data-driven set of authored point lights (FV4StealthLuxSample
summed with squared falloff in CalculateLuxAtCapsule), not a real Volumetric
Lightmap query — SampleLuxAtCapsule is the seam where a true engine lux probe
would plug in. Fourth, the enforcer suspicion multiplier field
(EnforcerSuspicionMultiplier = 2.0) exists but is not referenced by
ComputeSuspicionMultiplier; the enforcer mechanic that is wired is
detection-time halving (in two places, below). The sections say where each claim
is backed.
The AI core: stimulus in, suspicion out#
Every hostile NPC carries a UV4PerceptionComponent
(V4Perception/Public/V4PerceptionComponent.h). It does not evaluate every
frame. It accumulates real time into EvaluatorAccumulatorSeconds and only
fires when it crosses GetEvaluatorIntervalSeconds() —
1.0f / max(1, EvaluatorTickRateHz), default 10 Hz (background NPCs can be
set to 2 Hz). On each evaluation it does one of two mutually exclusive things:
if PendingStimuli is non-empty it FlushV4Stimuli, otherwise it
DecaySuspicion. That one-or-the-other rule is important — a guard actively
perceiving you never decays on the same tick.
The integration target is UV4SuspicionStateMachine
(V4Perception/Private/V4SuspicionStateMachine.cpp), a UObject holding a
single CurrentSuspicion in [0, 100]. Stimuli become suspicion through
BuildStimulusAggregate, whose core line is domain-specific, not generic:
StimulusAmount = (Strength * 100 * DeltaSeconds) / DetectionTimeSeconds
and DetectionTimeSeconds comes from CalculateDetectionTimeSeconds: a base
time divided by a proximity scale clamp(1 - Distance/MaxDistance, 0.05, 1),
then halved if the source is an enforcer, floored at 0.05 s. Closer
targets are detected faster (smaller divisor → larger per-second gain); an
enforcer who has clocked your disguise detects you twice as fast. Stimuli past
MaxDistance are dropped, and the aggregate also records StrongestStimulus,
bSawEnforcer, and the LastKnownLocation that the search behaviour reads.
CurrentSuspicion maps to EV4SuspicionState { Calm, Curious, Alert, Hostile }
through thresholds CuriousThreshold = 25, AlertThreshold = 60,
HostileThreshold = 90, with a HysteresisMargin = 5. The state is not a
naive comparison: CalculateStateWithHysteresis requires suspicion to fall a
full margin below an entry threshold before de-escalating, so a value hovering
at 59 does not strobe between Curious and Alert. Crossing a boundary resets
TimeInStateSeconds to zero and broadcasts OnSuspicionStateChanged. Decay is
DecayRatePerSecond (default 12.5/s) applied only when no stimulus arrived,
and a FV4NpcSuspicionProfile lets each NPC class override both the decay rate
and per-EV4PerceptionStimulusKind weights — a security camera weights Sight at
1.5× and Hearing at 0.25×, verified in StimulusSpec.cpp.
Perception senses: sight, hearing, faction, disguise#
UV4PerceptionSenseLibrary
(V4Perception/Private/V4PerceptionSenseLibrary.cpp) is the pure-function layer
that produces stimuli from geometry.
Sight. EvaluateSight gates on Cone.RangeCentimeters, then takes the
dot-product of the observer's forward and the to-target direction, converts to a
half-angle test against Cone.HalfAngleDegrees (default 60°, range 12 m via
RangeCentimeters = 1200). Inside the cone it computes a CenterWeight and a
DistanceWeight; if either clears InnerInstantDetectionFraction (default 0.5)
the target is in the inner cone with a 0.05 s base detection time (near-
instant), otherwise 1.5 s. Crucially, low light scales the result:
LowLightMultiplier = lerp(LowLightFalloff, 1.0, clamp(lux, 0, 1)), so a target
in shadow yields a weaker sight stimulus — the hook the stealth layer drives.
Hearing. GetHearingRadiusForStance is a real table keyed to the source's
stance: Prone 200, Crouch 400, Walk 800, Run 1600, Sprint 2500 cm;
gunfire overrides to 5000 cm, or 800 cm when silenced. EvaluateHearing
returns strength from the distance falloff, tagging the stimulus Gunfire or
Hearing accordingly.
Faction and disguise. RecognizeFaction(OtherFactionId, PresentedDisguise)
returns EV4FactionDisposition { Friendly, Neutral, Hostile, Disguised }: an
accepted disguise short-circuits to Disguised, then own/friendly factions are
Friendly, listed hostiles are Hostile, everything else Neutral.
CanTreatAsAlly collapses Friendly-or-Disguised to true, and that gate governs
alert spread: PropagateAlertToAllies only adds suspicion to allies within a
radius (default AlertPropagationRadius = 400 cm = 4 m), while
PropagateGunfireToAllies uses GunfirePropagationRadius = 2500 cm = 25 m.
This is how one guard going Hostile pulls his squad up the suspicion curve
without a global broadcast.
Presentation. BuildSuspicionPresentation maps state to an animation tag
(Perception.Calm/Curious/Alert/Hostile), a bShowHUDSignal, a bWeaponReady
(true from Alert up), and a vision-cone colour. Rendering is mode-aware:
UV4VisionConeDecalRendererComponent projects a colour-coded floor decal
for the top-down RTST cell (EV4VisionConeRenderMode::FloorDecal), while
first-person cells leave the cone invisible and lean on animation tells — the
same green / yellow / orange / red FV4VisionCone colours drive both.
Stealth detection: light, sound, disguise, marks#
The V4Stealth module models the player's exposure — the inputs the perception
layer consumes.
Light / shadow gauge. UV4LightGaugeComponent collapses lux into
EV4LightExposureState { Dark, Dim, Lit } at thresholds Dark < 0.3,
Dim < 0.65, else Lit, over an AmbientLux = 0.02 floor.
CalculateLuxAtCapsule sums each enabled FV4StealthLuxSample's contribution
with a squared distance falloff measured to the capsule's axis (a vertical
segment, not a point), so a crouched silhouette under a light is lit correctly.
The Splinter Cell rule is the payoff: CanBeSightDetected returns false in
Dark unless the observer is within
DarkCloseDetectionDistanceCentimeters = 150 cm with line of sight — darkness
hides you at range but not at arm's length. Shooting a bulb calls
ApplyLightOut(LightId) (toggling bEnabled off, reversible via
RestoreLight), which drops the next capsule sample below the dark threshold.
LightGaugeSpec.cpp walks this end to end: lit overhead light → light-out →
dark → close-LOS gate.
Sound footprint. UV4SoundFootprintComponent is the emission side, separate
from NPC hearing. Per-stance base radii are Sprint 1200, Run 900,
Walk 500, Crouch 250, Crawl 120 cm, scaled by a surface multiplier
resolved from the physical material: Concrete 1.0, Wood 1.15, Metal 1.35,
Gravel 1.45, Water 1.6, Carpet 0.55. EvaluateHearingRange answers
whether a listener is inside that radius and with what strength. (Note the two
complementary stance enums — EV4PerceptionStance for the listener's read of a
source's stance, EV4StealthMovementStance including a Crawl for the
player's emission.)
Disguise. UV4DisguiseComponent enforces the Hitman/Commandos fantasy.
BeginDisguisePickupSwap fails loud — returns false — unless the target was
knocked out and dragged and hidden in a container and the swap takes
≥ 2 s; only then is the disguise worn. ComputeSuspicionMultiplier is the
live knob: an allowed zone scales suspicion by
0.3 × lerp(1.0, 0.5, Stealthiness), a restricted zone by 1.75×, a suspicious
action by an extra 2×, an obvious weapon by 1.5×. The enforcer interaction
is detection-time, not multiplier: ComputeEnforcerDetectionTimeSeconds
shortens the base detection time by a clamped scale, mirroring the perception
layer's bEnforcer 0.5× — so a disguise is a liability precisely around the
NPCs who wear it.
Mark and execute. UV4MarkAndExecuteComponent is the Splinter Cell
tag-then-fire verb: AwardMarkSlot earns capacity (capped at
MaxMarkSlots = 4), TryMarkTarget consumes a slot, and ExecuteMarkedTargets
fires the queue and clears it — a small, honest slot economy, not a hardcoded
list.
Crowds and panic — the Mass-adjacent layer#
UV4CrowdSubsystem drives dense Hitman/Commandos populations.
EvaluateTierForAgent assigns EV4CrowdTier { Background, Medium, Hero } from
squared distance (HeroRadius 600, MediumRadius 1800 cm) and a relevance
heuristic (Hero ≥ 0.85, Medium ≥ 0.35), and GetTierBehavior declares what
each tier runs — Background is path-following only, Medium adds cheap perception
and schedule ticks, Hero adds full perception. Panic is a FV4CrowdMassSignal:
HandlePanicMassSignal applies a distance-falloff panic amount to every agent
in radius, promotes panicked Background agents to Medium so they don't freeze,
and calls AssignFleeTargetsForPanic, which routes each agent to the nearest
exfil node with remaining capacity (spilling to a neighbour when full) over
a default four-way exfil graph. AdvanceFleeingAgents then steps them at
FleeSpeedCentimetersPerSecond = 400 (= 4 m/s, matching
ComputePanicArrivalSeconds). A disguise line-of-sight signal runs the same
faction recognition as the perception layer and flips bDisguiseCompromised.
EnforcePerformanceBudget sorts by relevance and demotes tiers until the
estimated Mass tick fits MassTickBudgetMilliseconds = 8 — the perf spec proves
400 agents panic in under that budget.
Edge cases and failure modes#
- Suspicion can't strobe. The
HysteresisMarginforces a full drop below an entry threshold before de-escalating; a value parked at a boundary holds state. - Decay never races a sighting. The evaluator flushes or decays per tick, never both, so a guard with a live stimulus this interval keeps all of it.
- Out-of-range stimuli are discarded, not clamped to zero — they never enter the aggregate, so distant noise can't slow-cook a guard to Hostile.
- Dark ≠ invisible.
CanBeSightDetectedstill trips inside 150 cm with LOS; hugging a shadow next to a guard does not work. - Disguise pickups fail loud. Miss any precondition (not knocked out, not
dragged, not hidden, sub-2 s) and
BeginDisguisePickupSwapreturns false rather than half-applying a disguise. - Enforcers cut both ways. They halve detection time, so the disguise that fools civilians is your fastest tell near the people who wear it.
- Fleeing agents stay simulated. Panicked agents are pinned to at least Medium tier even when the player walks 100 m away, so a crowd mid-flight doesn't pop to Background and stop moving.
- The un-enforced seams. Lux is summed from authored light samples, not a real Volumetric Lightmap probe; the crowd's panic/flee model is array logic that mirrors Mass rather than a live processor graph. Both are correct and tested — but they are the documented places where deeper engine integration would land.
How it connects#
Perception is the cross-cell hub the rest of V4's AI reads. The suspicion gauge,
faction recognition, and vision-cone presentation feed the cell-specific
subsystems — Hitman crowds and schedules, Splinter Cell light gating, the
Desperados/Commandos time-stop planning loop — detailed in
./per-cell-deep-dives.md. The stealth verbs
(GA_BodyDrag, GA_LightKill, disguise) and NPC attribute blocks mount on the
shared Ability System described in ./gas-layout.md, and the
server-authoritative replication of suspicion, schedule clocks, and crowd state
— distinct from V2's rollback model — is covered in
./networking-determinism.md. The section hub is
../V4_ARCHITECTURE.md.
Related#
- Per-Cell Architecture Deep-Dives — how stealth, RTST, and the disguise/showdown subsystems consume this perception model
- GAS Layout — the attribute sets and ability families the stealth verbs mount on
- Networking & Determinism — replicating suspicion, schedules, and crowd state across the client-server cells
- The section hub: ../V4_ARCHITECTURE.md