# AI, Perception & Stealth

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](../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 `true`s) — 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`.

```mermaid
stateDiagram-v2
    [*] --> Calm
    Calm --> Curious : suspicion ≥ 25
    Curious --> Alert : ≥ 60
    Alert --> Hostile : ≥ 90
    Hostile --> Alert : < (90 − 5)
    Alert --> Curious : < (60 − 5)
    Curious --> Calm : < (25 − 5)
    note right of Curious
        per-tick: flush stimuli OR decay,
        never both. Hysteresis margin = 5
        gates every downward edge.
    end note
```

## 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 `HysteresisMargin` forces 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.** `CanBeSightDetected` still 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 `BeginDisguisePickupSwap` returns 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](./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](./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](./networking-determinism.md). The section hub is
[../V4_ARCHITECTURE.md](../V4_ARCHITECTURE.md).

## Related

- [Per-Cell Architecture Deep-Dives](./per-cell-deep-dives.md) — how stealth,
  RTST, and the disguise/showdown subsystems consume this perception model
- [GAS Layout](./gas-layout.md) — the attribute sets and ability families the
  stealth verbs mount on
- [Networking & Determinism](./networking-determinism.md) — replicating
  suspicion, schedules, and crowd state across the client-server cells
- The section hub: [../V4_ARCHITECTURE.md](../V4_ARCHITECTURE.md)
