Audience: anyone about to edit Ironfront_Reborn/Assets/Scripts/Assembly-CSharp/.
Closes the client track's phase-00 task 1 (read and understand the codebase). That spec was deleted 2026-08-26 with the rest of the track — git show 68acdd9:plans/unity-client/README.md records why and how to recover it.
Audited 2026-08-15 against the repository at 3676c6e. Every line number below was read, not
recalled; re-run the greps in § 7 if the files have moved since.
Assembly-CSharp/ is 169 files. Nine of them decide what happens when you press the trigger. This
document is those nine, plus the two lists the netcode needs out of them: what state has to be
replicated, and where the game reaches for a singleton that a headless server does not have.
flowchart TD
K["Keyboard / mouse<br/><i>UnityEngine.Input</i>"]
FPS["FpsActorController<br/><i>: ActorController</i><br/>37 Input.* lines"]
FPC["FirstPersonController<br/><i>Plugins/Assembly-CSharp-firstpass</i><br/>walk, jump, mouse-look"]
AI["AiActorController<br/><i>: ActorController</i><br/>2227 lines, no Input.*"]
A["Actor<br/><i>: Hurtable</i><br/>Update() → UpdateWeapon()"]
W["Weapon<br/>CanFire → Shoot → SpawnProjectile"]
P["Projectile<br/>Travel() → Raycast → Hit()"]
HB["Hitbox<br/>ProjectileHit()"]
HU["Hurtable.Damage()<br/><i>virtual, Actor overrides</i>"]
UI["IngameUi · ScoreUi · DecalManager<br/><i>client-only singletons</i>"]
K -->|"GetButton/GetAxis"| FPS
K -->|"CrossPlatformInputManager"| FPC
FPC -->|"CharacterController.Move"| A
FPS -->|"controller.Fire() / Aiming() / Reload()"| A
AI -->|"same ActorController surface"| A
A -->|"activeWeapon.Fire(dir, useMuzzle)"| W
W -->|"Instantiate at configuration.muzzle"| P
P -->|"layer 8/10/16"| HB
HB -->|"parent.Damage(...)"| HU
HU -->|"health -= dmg; Die() at 0"| A
A -.->|"SetHealth · Hit · AddScore · CreateBloodDrop"| UI
P -.->|"IngameUi.Hit()"| UI
| # | Where | What happens |
|---|---|---|
| 1 | Actor.Update():467 |
Per frame, if activeWeapon != null → UpdateWeapon() |
| 2 | Actor.UpdateWeapon():475 |
controller.Fire() && not fallenOver && seat allows it |
| 3 | Actor.UpdateWeapon():480 |
activeWeapon.Fire(controller.FacingDirection(), controller.UseMuzzleDirection()) |
| 4 | Weapon.CanFire():362 |
unholstered · not reloading · has ammo · auto-or-not-held · not cooling down |
| 5 | Weapon.Shoot():377 |
projectilesPerShot × SpawnProjectile, then ammo--, user.ApplyRecoil(...) |
| 6 | Weapon.SpawnProjectile():495 |
Quaternion.LookRotation(direction + Random.insideUnitSphere * configuration.spread) |
| 7 | Projectile.Travel():153 |
Raycast forward delta.magnitude * 2f, mask -2049 |
| 8 | Projectile.Hit():178 |
Hitbox.IsHitboxLayer(layer) → component.ProjectileHit(this, point) |
| 9 | Hitbox.ProjectileHit():22 |
parent.Damage(p.Damage() * multiplier, p.BalanceDamage(), piercing, …) |
| 10 | Actor.Damage():861 |
health -=, balance -=, blood decals, then knock-over / ragdoll / hurt |
| 11 | Actor.Die():770 |
drop weapons, ragdoll, ActorManager.SetDead, PathfindingManager.RegisterDeath, MatchScoreboard.AddScore |
- Damage is randomised at three points, all
UnityEngine.Random: spread (Weapon.cs:390), recoil kick (Weapon.cs:356) and the hurt animation (Actor.cs:1181). A shot is not reproducible from its inputs, so client-side prediction of a hit is not possible without seeding — which is whyServerFireResolverexists on the server side rather than being predicted. Hurtableis the damage interface, and it is a class, not an interface.Hurtable.csis 11 lines: ateamfield and avirtual Damage(...)that returnsfalse.ActorandVehicleoverride it. Anything that can be shot derives from it.- Hitboxes are identified by layer, not by component.
Hitbox.IsHitboxLayeraccepts 8 (alive), 10 (ragdoll) and 16 (seated);Actor.EnterSeatmoves its colliders to 16 andReactivateCollisionsWithmoves them back to 8 half a second after leaving. A hit-detection change that ignores the layer will start hitting players through vehicle armour. Actor.Updatethrottles itself.IsLowQuality()returns true for AI actors that are off-screen or beyond12000 / fovmetres, and those actors runUpdateFacing/UpdateMovementat 5 Hz instead of per frame (Actor.cs:403-422).Camera.mainis dereferenced unconditionally insideIsLowQuality, which is a headless landmine — see § 4.AiActorControllerconsumes the same 30-methodActorControllersurface and reads no input at all. GreppingInput\.over its 2227 lines returns zero hits. That is what makes anIInputSourceseam viable: the abstract surface inActorController.csis already the only thingActortalks to, and a third subclass fed by the network needs no change toActorwhatsoever.
The right-hand column is
ActorSnapshotEntry and
ActorStateFlags as they exist today.
Actor state |
Declared at | In the snapshot? | As |
|---|---|---|---|
| world position | transform / ragdoll.Position() |
✅ | PosX/Y/Z (i16, quantized) |
| facing yaw | transform.eulerAngles.y |
✅ | Yaw (u16) |
| aim pitch | camera, not on Actor |
✅ | Pitch (i8) |
| velocity | controller.Velocity() |
✅ | VelX/Y/Z (i8) |
health |
Actor.cs:89 |
✅ | Health (u8, 0–100) |
dead |
Actor.cs:95 |
✅ | IsAlive flag (inverted) |
fallenOver / ragdoll |
Actor.cs:98 |
✅ | IsRagdoll flag |
| crouching | FpsActorController.crouching |
✅ | IsCrouching flag |
| sprinting | controller.IsSprinting() |
✅ | IsSprinting flag |
| aiming | Actor.cs:137 |
✅ | IsAiming flag |
inWater |
Actor.cs:123 |
✅ | IsInWater flag |
seat occupancy |
Actor.cs:160 |
✅ | IsSeated flag + SeatInfo (stretch) |
team |
Hurtable.team |
✅ | Team (u8) |
| active weapon | Actor.cs:126 |
✅ | WeaponId (u8, via WeaponManager.NetworkIdOf) |
activeWeapon.ammo |
Weapon.ammo |
✅ | AmmoInClip (u8) |
Phase-00 asks for the table; the gap under it is the part that changes anyone's plan.
| Missing from the snapshot | Where it lives | Does it matter? |
|---|---|---|
balance (the stagger meter, 100 → −100) |
Actor.cs:92 |
Yes. balance < 0 is what triggers KnockOver (Actor.cs:899). It regenerates at 10/s and is decremented by every hit, so a client that does not have it cannot predict a knock-down and will show a player standing who is on the floor on the server. It is not derivable from health — a stun grenade does 0 health and 200 balance. |
spareAmmo[5] and the other four weapon slots |
Actor.cs:112,116 |
Own-player only. The HUD reads it via Actor.UpdateAmmoUi; other players never see it. A dedicated S_LOADOUT-style message or an own-player-only field, not a per-actor snapshot field. |
prone |
does not exist in the game | ActorStateFlags.IsProne is defined in the protocol and has no source. Either the protocol carries a bit nothing will ever set, or prone is a planned feature. Worth deciding rather than discovering. |
hasAmmoBox / hasMedipack / needsResupply |
Actor.cs:125-140 |
AI-only inputs (AiActorController resupply behaviour) and server-side. No replication needed. |
lqUpdatePhase / lqUpdate |
Actor.cs:153-160 |
Local rendering LOD. Deliberately not replicated; the server's equivalent is BotLodScheduler. |
animator parameters (movement x/y, lean, hurt x, falling, onBack) |
Actor.UpdateMovement / UpdateFacing / UpdateRagdollStates |
Derived on the receiving client from position, velocity and the state flags. Replicating them would be replicating a derived field. |
balance is the one real finding here. Everything else in the list is either correctly absent
or own-player-only. Whoever owns the snapshot format should decide explicitly whether balance
joins it or whether knock-down becomes a server event instead — both are defensible, silently
shipping neither is not.
Phase-00 task 1 deliverable 3, and the direct input to
assist step 03.
| Line | Call | Present on a server? |
|---|---|---|
| 211 | ActorManager.Register(this) |
✅ — but it calls MinimapUi.AddActorBlip internally (see below) |
| 252 | IngameUi.instance.Show() |
❌ guarded by if (!aiControlled) |
| 253 | IngameUi.instance.SetHealth(...) |
❌ guarded by if (!aiControlled) |
| 255 | ActorManager.SetAlive(this) |
✅ |
| 262 | controller.GetLoadout() → LoadoutUi.instance.loadout on the player path |
❌ unguarded, via the controller |
| 568 | IngameMenuUi.IsOpen() |
❌ unguarded |
| 798 | IngameUi.instance.Hide() |
❌ guarded by if (!aiControlled) |
| 800 | ActorManager.SetDead(this) |
✅ |
| 801 | PathfindingManager.RegisterDeath(point) |
✅ |
| 805 | MatchScoreboard.Current.AddScore(...) |
✅ — was ScoreUi.AddScore, which scored nothing headless. debt-closure phase 2 task 2c moved the state off the HUD (ledger C-4) |
| 885 | DecalManager.CreateBloodDrop(...) |
❌ unguarded — runs on every hit, in a loop of ceil(damage/10) |
| 911,913 | IngameUi.instance.SetHealth / ShowVignette |
❌ guarded by if (!aiControlled) |
| 941 | IngameUi.instance.SetWeapon(weapon) |
❌ guarded by if (!aiControlled) |
| 1213 | IngameUi.instance.SetAmmoText(...) |
❌ reached only from guarded callers |
| 1218 | IngameUi.instance.SetHealth(health) |
❌ reached only from guarded callers |
| 1275 | IngameUi.instance.Resupply() |
❌ guarded by if (!aiControlled) |
| 1292 | IngameUi.instance.Heal() |
❌ guarded by if (!aiControlled) |
| 1311,1313 | Camera.main inside IsLowQuality() |
❌ unguarded — reached from Update on every AI actor |
Actor already has an accidental server guard: if (!aiControlled). Ten of its eighteen
singleton touches sit behind it, and on a dedicated server every actor is AI-controlled, so ten of
them are already safe by luck rather than design.
The five that are not behind it are the ones that will actually throw:
ScoreUi.AddScore— fires on every deathDecalManager.CreateBloodDrop— fires on every hitMinimapUi.AddActorBlip, reached fromActorManager.Register— fires once per actor at startupIngameMenuUi.IsOpen()— fires fromUpdateMovement, every frame, for every actorCamera.maininIsLowQuality()— fires fromUpdate, every frame, for every AI actor
Note the shape they share: all five are static methods that dereference instance internally.
ScoreUi.AddScore, DecalManager.CreateBloodDrop, DecalManager.AddDecal,
MinimapUi.AddActorBlip and IngameUi.Hit are all public static void X(...) { instance.Y(); }.
That is a single choke point per singleton, not forty call sites — which is the fact that decides
how step 03 should be written.
Camera.main is the odd one out and the one nobody would have predicted: it is not on the task-5
list of 21 singletons at all, and it will throw before any of them, because Actor.Update runs for
all 32 bots from the first frame.
Added by the assist track for
step 02; gathering it while reading is nearly free
and it is the exact substitution list that step needs.
37 lines, 44 individual Input.* expressions — the anchored count; see the correction at the end of
this section. Read the middle column as "this read only matters when…".
This table describes the file before
step 02. It is kept in that form because it is the substitution list step 02 was built from, and becauseInputShadowComparenames its sites by these line numbers. After step 02, 11 of the 37 lines readinputSourceinstead and 26 remain: the 4 helicopter axes (phase-00 § 5 debt) and 22 edge-triggered UI, debug and weapon/seat-selection keys, whichIInputSourcecannot carry because it reports levels and those are edges.
| Line | Surrounding condition | Expression | Class |
|---|---|---|---|
| 130 | !IngameMenuUi.IsOpen() && !IsSprinting() && sprintCannotFireAction.TrueDone() |
(GetButton("Fire1") || GetMouseButton(0)) && !LoadoutUi.IsOpen() |
gameplay |
| 139 | !OptionsUi.GetOptions().toggleAim |
(GetButton("Fire2") || GetMouseButton(1)) && !LoadoutUi.IsOpen() |
gameplay |
| 144 | unconditional (Reload()) |
GetButton("Reload") && !LoadoutUi.IsOpen() |
gameplay |
| 164 | Actor.FixedUpdate, only while WaterLevel.InWater |
GetAxis("Vertical"), GetAxis("Horizontal") in a tpCamera basis |
gameplay |
| 188 | CarInput(), called only from a driver seat |
GetAxis("Horizontal"), GetAxis("Vertical") |
vehicle |
| 196–199 | helicopterType == 2 only |
GetAxis("Helicopter Pitch"/"Yaw"/"Roll"/"Throttle") |
vehicle |
| 202 | every HelicopterInput() call |
GetAxis("Mouse X"), GetAxis("Mouse Y") — a per-frame delta, not an absolute angle |
vehicle |
| 213, 215 | helicopterType == 0 / else |
GetAxis("Horizontal"), GetAxis("Vertical") |
vehicle |
| 378 | !IsSprinting() |
GetAxis("Lean") |
gameplay |
| 439 | Update, unconditional |
GetButtonDown("Fire2") → flips aimToggle |
gameplay (edge) |
| 468 | Update, unconditional |
GetButtonDown("Loadout") |
UI |
| 479 | Update, unconditional |
GetKeyDown(KeyCode.K) → suicide |
debug |
| 483 | Update, unconditional |
GetKeyDown(KeyCode.O) → ActorManager.debug |
debug |
| 487 | !IngameMenuUi.IsOpen() |
GetButtonDown("Slowmotion") |
debug |
| 504 | Update, unconditional |
GetButtonDown("Use") → enter/leave seat |
gameplay (edge) |
| 523–539 | inputEnabled |
GetKeyDown(Alpha1..Alpha5) → SwitchWeapon(0..4) |
gameplay (edge) |
| 543–571 | inputEnabled |
GetKeyDown(F1..F8) → SwitchSeat(0..7) |
vehicle (edge) |
| 575 | inputEnabled && OptionsUi.GetOptions().toggleCrouch |
GetButtonDown("Crouch") → flips crouchInput |
gameplay (edge) |
| 579, 583 | inputEnabled |
mouseScrollDelta.y → NextWeapon() / PreviousWeapon() |
gameplay (edge) |
| 675 | !OptionsUi.GetOptions().toggleCrouch |
GetButton("Crouch") |
gameplay |
| 715 | !Crouch() && !Aiming() && !IsReloading() && !actor.IsSeated() |
GetButton("Sprint") |
gameplay |
-
Walking, jumping and mouse-look are not in this file. They are in
FirstPersonController, which readsCrossPlatformInputManager.GetAxis("Horizontal"/"Vertical")at line 278 andGetButtonDown("Jump")at line 159. AnIInputSourcecovering onlyFpsActorControllertherefore covers aim, fire, reload, lean, crouch and sprint — not locomotion.That is less alarming than it sounds: the netcode does not drive locomotion through
FirstPersonControllerat all. It replaces it, viaMovementSimulation→MovementCore, which is already ported and unit-tested. Locomotion input reaches the netcode throughMovementSimulation.FromUnityInput, not throughFpsActorController. -
There is a 38th gameplay
Input.*read outsideAssembly-CSharp/.MovementSimulation.FromUnityInputreadsHorizontal,Vertical,Jump,SprintandCrouchdirectly. That file is the replication track's and marked "nobody else may edit" in code-conventions.md § 7, so it is named here rather than changed. It is the natural second consumer ofIInputSourceonce the interface exists. -
Line 202 is a mouse delta, not an angle. The phase-00 mapping table says to "move it into
LocalInputSource.Sample()", whereSample()accumulates an absolute yaw and pitch. Those are different quantities and substituting one for the other silently changes helicopter handling. A look-delta channel is needed alongside the absolute yaw/pitch, or line 202 has to stay onInput— see step 02.
The other 43 Input.* lines, in 16 files (SpectatorCamera 9, PathTypesDemo 9,
ObjectPlacer/GroupController/CommandRoomCamera 4 each, WeaponManager/ScoreUi 2 each, and
nine files with one apiece), are spectator, level-editor tooling and menus.
Phase-00 criterion 6 (spec deleted 2026-08-26)
explicitly permits them to keep calling Input directly.
Count correction. Both phase-00 ("59
Input.*call sites, ~40 inFpsActorController") and the assist-track README ("85 places", "MainMenu.cs3") are off, because the grep behind them was unanchored:victoryScoreInput.textinMainMenu.cs:85matchesInput\.but is aUnityEngine.UI.InputField, not input. Anchored (\bInput\.), the real figures forAssembly-CSharp/are 80 lines / 94 expressions across 17 files, of whichFpsActorControlleris 37 lines / 44 expressions.MainMenuhas none. The shape of the conclusion is unchanged — one file is still the entire gameplay job — but a number in a plan that nobody can reproduce is a number that gets argued about later.
ActorController.cs ·
72 lines. An abstract MonoBehaviour with one field (actor) and 30 abstract members. It is the
whole seam between "something decides" and "the actor does it": ten queries (Fire, Aiming,
Crouch, Reload, IsSprinting, Lean, Velocity, OnGround, FacingDirection,
SwimInput/CarInput/BoatInput/HelicopterInput) and thirteen commands (Die, SpawnAt,
StartRagdoll, EndRagdoll, GettingUp, StartSeated, EndSeated, EnableInput,
DisableInput, ApplyRecoil, StartCrouch, EndCrouch, SwitchedToWeapon, ReceivedDamage).
Read this file first and the other seven make sense.
Actor.cs · 1189 lines,
: Hurtable. The unit. Owns health, balance, the five weapon slots, seat state, ragdoll state and
the animator. Update() runs facing → movement → weapon; FixedUpdate() is buoyancy only.
Damage() at 761 is the single entry point for taking damage and Die() at 691 the single exit.
Everything it does to the world it does through controller, which is why the netcode can
substitute a controller and change nothing else.
FpsActorController.cs ·
752 lines. The player's controller. Holds static instance and static playerTeam, both read from
eleven other files. Also owns both cameras, the FOV/aim/recoil pipeline via fpParent, the
crouch height change, the use-ray, and the loadout screen. § 4 above is its input surface.
Weapon.cs · 564 lines.
Fire() → CanFire() → Shoot() → N × SpawnProjectile(). Configuration is a serialised nested
class (spread, cooldown, auto, projectilesPerShot, kickback, randomKick, ammo,
spareAmmo, aimFov, muzzle). ToggleableItem subclasses (ammo bag, medipack) reuse the slot
machinery without being weapons.
ActorManager.cs ·
368 lines. The actor registry and the spawn loop. StartGame() creates team0Bots + team1Bots
(16 + 16 by default) AI actors, then InvokeRepeating("SpawnWave", 1f, spawnTime) respawns anything
dead for more than 6 s. Explode() is the area-damage entry point, RegisterProjectile is the
"bots duck when shot at" hook. Note Register calls MinimapUi.AddActorBlip — a UI call on the
registration path of every actor.
GameManager.cs ·
108 lines and the smallest important file. DontDestroyOnLoad, subscribes to sceneLoaded, and any
scene with buildIndex > 1 counts as in-game. StartGame() instantiates the HUD prefab and the
player prefab at (0, 1000, 0), then starts ActorManager, CoverManager and DecalManager.
Holding S during the one-second OpenPlayerLoadout invoke starts spectator mode instead.
AiActorController.cs ·
2227 lines — skim, as phase-00 says. What matters for the netcode is what it consumes:
ActorManager.AliveActorsOnTeam, PathfindingManager, CoverManager, Squad, and
FpsActorController.instance.actor / .playerTeam at five sites (464, 503, 646, 686, 2047) for
"is the human nearby" behaviour. Those five are the reason a headless server needs
FpsActorController.instance guarded rather than absent.
Hitbox.cs (31 lines) and
Hurtable.cs (11 lines). The
entire damage interface. Hitbox carries a multiplier (head/limb) and a parent back-pointer;
Hurtable carries team and a virtual Damage(...). 42 lines decide everything about who can hurt
whom.
- Vehicles.
Vehicle,Seat,Helicopter,TankTurret,MountedTurretare named where they intersect the flow above and not explored. Phase-00 § 5 puts vehicle input outside the 14-week scope. Squadand the AI decision tree. Skimmed per the reading order.- Rendering, audio,
TimeOfDay,DecalManagerinternals. They matter for the headless build (§ 3) and nowhere else in this flow.
cd Ironfront_Reborn/Assets/Scripts/Assembly-CSharp
# § 4 — the input surface, per file. The \b matters: without it, InputField members
# in MainMenu.cs are counted as input reads.
grep -c "\bInput\." *.cs | grep -v ":0" | sort -t: -k2 -rn # 80 lines, 17 files
grep -o "\bInput\.[A-Za-z]*" *.cs | wc -l # 94 expressions
grep -n "\bInput\." FpsActorController.cs # 37 lines
# § 3 — every singleton touch, per singleton
for s in IngameUi IngameMenuUi LoadoutUi ScoreUi MinimapUi MinimapCamera OptionsUi \
SceneryCamera DecalManager ReflectionProber DetailObjectQuality TimeOfDay \
FpsActorController PlayerFpParent; do
echo "### $s"; grep -n "\b$s\." *.cs | grep -v "^$s\.cs:"
done
# § 1 fact 5 — AiActorController reads no input
grep -c "Input\." AiActorController.cs # 0
# the finding that started the assist track: the netcode is not wired into the game
grep -rl "NetContext\|NetMovementAgent\|MovementSimulation" *.cs | wc -l # 0plans/unity-client/phases/phase-00-foundation.md— the task this closes; deleted,git show 68acdd9:plans/unity-client/README.mddocs/movement-analysis.md— the movement port, in the depth this document does not go intocode-conventions.md§ 7 — scope discipline