Vehicle/VehicleController.cs

VehicleController component for a car, plus the DriveInputs struct. It reads inputs (keyboard/gamepad or an override), runs steering, drivetrain and wheel substeps, applies assists (ABS, traction control, spin recovery, stability, wall-glance, brake assist), handles collisions for wall-glance, telemetry logging and respawn.

NetworkingFile Access
namespace VehicleProto;

/// <summary>
/// Drives one car: input → assists → drivetrain → wheel substeps (spec §5.2).
/// Runs 4 internal substeps per fixed update. PRECISION NOTE (audit 2026-07-12): this is
/// drivetrain/wheel-STATE substepping, not a full 200 Hz contact simulation — the ground
/// trace happens once per fixed update, the rigidbody does not advance between substeps
/// (velocity-at-contact is re-sampled against the same body state), and the accumulated
/// tire force is applied once, averaged. What the substeps genuinely refine: wheel angular
/// velocity integration (slip-ratio stability at 4× the rate), clutch/RPM coupling, and the
/// TC feedback loop. Contact/chassis transients (washboard, curb strikes, landings) resolve
/// at 50 Hz. Owner-simulated; proxies early-out (D2).
/// </summary>
public sealed class VehicleController : Component, Component.ICollisionListener
{
	public const int Substeps = 4;

	public CarDefinition Definition { get; set; }
	public List<VehicleWheel> Wheels { get; } = new();
	public Drivetrain Drivetrain { get; private set; }

	[Property] public AssistLevel Assists { get; set; } = AssistLevel.Casual;

	public float Throttle { get; private set; }
	public float Brake { get; private set; }
	public float Steer { get; private set; } // -1..1
	public bool Handbrake { get; private set; }
	public float SpeedMs => _rigidbody.IsValid() ? (_rigidbody.Velocity * Units.UnitsToMeters).Length : 0f;

	/// <summary>
	/// Signed longitudinal speed (m/s) for the HUD speedo: the magnitude equals <see cref="SpeedMs"/>
	/// (forward reads exactly as before), and the sign is the car's travel direction along its own
	/// facing — NEGATIVE while rolling backwards. The sign source is the SAME forward-axis projection
	/// <see cref="ApplySpinRecoveryAssist"/> and gear-engage already use
	/// (velocity · <see cref="Sandbox.GameObject.WorldRotation"/>.Forward), so the speedo agrees with
	/// the house reverse-detection. DISPLAY read only — no physics or telemetry consumes it.
	/// </summary>
	public float SignedSpeedMs
	{
		get
		{
			if ( !_rigidbody.IsValid() )
				return 0f;

			float forward = Vector3.Dot( _rigidbody.Velocity, WorldRotation.Forward );
			return forward < 0f ? -SpeedMs : SpeedMs;
		}
	}

	/// <summary>
	/// Input-source seam (test pilot; forward-compat for the <see cref="DriveInputs"/>
	/// pluggable-source abstraction — keyboard/gamepad/wheel/pilot as peers at ONE seam). When
	/// non-null this value-struct is consumed by <see cref="ReadInput"/> INSTEAD of sampling live
	/// keyboard/gamepad, so whatever set it drives through the exact same input → assists →
	/// drivetrain path a human uses — it never applies forces itself (an intent-injection
	/// pattern). Null = normal keyboard/gamepad. The test pilot sets it each tick
	/// while a maneuver runs and clears it when the run ends. This is the ONLY addition to
	/// VehicleController for the harness (a deliberately narrow seam); future device
	/// sources plug in here without touching this class again.
	/// </summary>
	public DriveInputs? InputOverride { get; set; }

	Rigidbody _rigidbody;
	Vector3 _spawnPosition;
	Rotation _spawnRotation;

	protected override void OnStart()
	{
		_rigidbody = Components.Get<Rigidbody>();
		Definition ??= CarDefinitions.Hatch;
		Drivetrain = new Drivetrain( Definition );
		Assists = Definition.DefaultAssists;
		_spawnPosition = WorldPosition;
		_spawnRotation = WorldRotation;

		// suspension needs continuous simulation — a sleeping body ignores our forces
		if ( _rigidbody.PhysicsBody is not null )
			_rigidbody.PhysicsBody.AutoSleep = false;

		// brief freeze so the car initializes perfectly level and still
		_rigidbody.MotionEnabled = false;
		_settleFreeze = 0.4f;
	}

	TimeSince _telemetry;
	TimeSince _sinceSpawn;
	float _settleFreeze;

	protected override void OnFixedUpdate()
	{
		if ( IsProxy || !_rigidbody.IsValid() || Drivetrain is null )
			return;

		if ( _settleFreeze > 0f )
		{
			_settleFreeze -= Time.Delta;
			if ( _settleFreeze <= 0f )
			{
				_rigidbody.MotionEnabled = true;
				_sinceSpawn = 0;
			}
			return;
		}

		ReadInput();
		ApplySteering();

		// handbrake = drift button: rears instantly lose lateral bite, snap back on release
		foreach ( var wheel in Wheels )
		{
			if ( !wheel.IsSteering )
				wheel.GripScale = Handbrake ? Definition.HandbrakeGripScale : 1f;

			// throttle dissolves low-speed parking stiction on ALL wheels so full-lock
			// launches actually launch (undriven steered fronts were the drag)
			wheel.ParkBrakeScale = 1f - Throttle;
		}

		foreach ( var wheel in Wheels )
			wheel.BeginStep();

		var driven = Wheels.Where( w => w.IsDriven ).ToList();
		float dt = Time.Delta / Substeps;

		// ground-truth wheel speed for shifting: actual forward speed over the tire radius
		float forwardSpeed = Vector3.Dot( _rigidbody.Velocity * Units.UnitsToMeters, WorldRotation.Forward );
		float groundWheelSpeed = forwardSpeed / Definition.WheelRadius;

		// arcade launch boost: extra shove off the line, fully faded by 15 m/s
		float launchBoost = MathX.Lerp( Definition.LaunchBoost, 1f, Math.Clamp( SpeedMs / 15f, 0f, 1f ) );

		// drift-catch assist (candidate #3): arm on the handbrake RELEASE edge, compute
		// this tick's throttle factor (1 = no cut).
		if ( _wasHandbrake && !Handbrake )
			_sinceHandbrakeRelease = 0f;
		_wasHandbrake = Handbrake;
		float driftCatch = DriftCatchFactor();

		for ( int step = 0; step < Substeps; step++ )
		{
			float avgDrivenSpeed = driven.Count > 0 ? driven.Average( w => w.AngularVelocity ) : 0f;
			float throttle = ApplyTractionControl( Throttle * driftCatch, driven );
			float torquePerWheel = Drivetrain.Simulate( dt, throttle, avgDrivenSpeed, groundWheelSpeed, driven.Count ) * launchBoost;

			foreach ( var wheel in Wheels )
			{
				float drive = wheel.IsDriven ? torquePerWheel : 0f;
				float brake = BrakeTorqueFor( wheel );
				wheel.Substep( dt, drive, brake );
			}
		}

		foreach ( var wheel in Wheels )
			wheel.EndStep();

		ApplyBrakeAssist();
		ApplySpinRecoveryAssist();
		ApplyStabilityAssist();
		ApplyWallGlanceAssist();

		// dense driving telemetry: 2 Hz while moving or on input — parseable for analysis
		if ( _telemetry > 0.5f && (SpeedMs > 0.5f || Throttle > 0f || Brake > 0f) )
		{
			_telemetry = 0;
			float yawRate = _rigidbody.AngularVelocity.z.RadianToDegree();
			var rears = Wheels.Where( w => !w.IsSteering ).ToList();
			var fronts = Wheels.Where( w => w.IsSteering ).ToList();
			// car=<id> token: added because free-drive session telemetry had to be segmented
			// by redline signature because the line didn't say which car produced it.
			Log.Info( $"[vp] tele car={Definition?.Name ?? "?"} v {SpeedMs * 3.6f:F0}kmh rpm {Drivetrain.Rpm:F0} gear {Drivetrain.Gear} | thr {Throttle:F2} brk {Brake:F2} hb {(Handbrake ? 1 : 0)} steer {Steer:F2} | yaw {yawRate:F0}deg/s | rearK {rears.Average( w => w.SlipRatio ):F2} frontA {fronts.Average( w => w.SlipAngle.RadianToDegree() ):F1} rearA {rears.Average( w => w.SlipAngle.RadianToDegree() ):F1}" );

			// per-wheel trace hits whenever contact is abnormal — names what we're driving
			// on (or falling through); this diagnostic has caught three bugs, keep it
			int grounded = Wheels.Count( w => w.IsGrounded );
			if ( grounded < 4 || _sinceSpawn < 6f )
			{
				var detail = string.Join( " | ", Wheels.Select( w =>
					$"{w.GameObject.Name[^2..]} {w.DebugTrace} Fz {w.Load:F0}" ) );
				Log.Info( $"[vp] wheels z {WorldPosition.z * Units.UnitsToMeters:F1}m | {detail}" );
			}
		}
	}

	protected override void OnUpdate()
	{
		if ( IsProxy )
			return;

		if ( Input.Pressed( "Reload" ) )
			Respawn();

		// Sequential-shift edges are latched HERE (per-frame) — Input.Pressed is frame-scoped and
		// unreliable read from OnFixedUpdate, which may run zero or several times a frame. ReadInput
		// (fixed step) consumes + clears these. A frame with no fixed tick still keeps the latch until
		// the next one, so no press is lost at any framerate.
		if ( Input.Pressed( "ShiftUp" ) ) _liveShiftUp = true;
		if ( Input.Pressed( "ShiftDown" ) ) _liveShiftDown = true;
		if ( Input.Pressed( "ShiftMode" ) ) _liveShiftMode = true;
	}

	// Gamepad tier: deadzone + response curve for the analog steer axis.
	// TODO: promote to CarDefinition dials once the controller layer gets its own tuning
	// surface — CarDefinition/CarDefinitions lives in the VehicleFactory/Parts layer, so these
	// stay VehicleController consts for now.
	const float GamepadSteerDeadzone = 0.12f;
	const float GamepadSteerCurvePower = 1.6f; // >1 softens the center for fine control, still reaches full lock

	// Analog throttle/brake tier (owner feel 2026-07-17): a small trigger deadzone so resting
	// triggers can't creep the pedals. NOTE the engine ALSO applies its own 12.5% deadzone one layer
	// down (Controller.SetAxis zeroes any |axis| <= 0.125 before Input.GetAnalog ever sees it — read
	// from sbox-public engine source), so in practice values arrive as 0 or >~0.125 and this floor is a
	// belt-and-suspenders guard that also rescales so full pull still reaches 1.0. Linear only — the ask
	// was "variable", not a shaped curve.
	const float GamepadTriggerDeadzone = 0.05f;

	/// <summary>Sample the live input devices into a DriveInputs value (this is the
	/// keyboard/gamepad source; other sources produce the same struct and set InputOverride).
	///
	/// Gamepad tier: steering rides <see cref="Input.AnalogMove"/>.y
	/// straight off the left stick — the engine computes AnalogMove from the Movement action
	/// bindings (project-setup.md) and reports it as a continuous SDL axis value, so no digitizing
	/// happens here; <see cref="ApplyGamepadSteerCurve"/> only reshapes it (deadzone + curve).
	/// Keyboard emits exact -1/0/1 through the same path and passes through unchanged (see the
	/// curve's own doc), so this one seam covers both devices without a branch.
	///
	/// Throttle/brake: VARIABLE per device (owner feel 2026-07-17). Keyboard W/S ride
	/// <see cref="Input.AnalogMove"/>.x as an exact ±1 digital forward/back; the gamepad triggers are
	/// read as a true ANALOG 0..1 pull via <c>Input.GetAnalog(InputAnalog.RightTrigger|LeftTrigger)</c>
	/// (right = gas, left = brake, matching the GasTrigger/BrakeTrigger config binds). The two devices
	/// combine per channel by MAX — throttle = max(keyboard-forward, right-trigger), brake =
	/// max(keyboard-back, left-trigger) — so either device works and neither fights the other, then the
	/// net (throttle − brake) folds back into the single signed <see cref="DriveInputs.MoveForward"/>
	/// scalar that <see cref="ReadInput"/> already splits into Throttle/Brake with the gear/reverse
	/// logic. Keyboard-only players are byte-identical: on keyboard <c>UsingController</c> is false so
	/// <c>Input.GetAnalog</c> returns 0, leaving max(±1, 0) = the old W/S value.
	///
	/// Why this is the clean read (CORRECTS the 2026-07-11 note that claimed no per-action analog axis):
	/// the confusion was conflating two different enums. <c>InputAction</c> (a named Input.config entry
	/// like GasTrigger) really is digital-only — no analog flag. But <c>InputAnalog</c> is a SEPARATE
	/// public enum that, in the installed SDK, carries explicit per-axis members —
	/// LeftStickX/Y, RightStickX/Y and, crucially, <c>LeftTrigger</c>/<c>RightTrigger</c> — and
	/// <c>Input.GetAnalog(InputAnalog)</c> is public, returning the trigger pull 0..1 (0 = released).
	/// It reads the physical trigger axis directly (independent of the GasTrigger/BrakeTrigger config
	/// action), so no NativeEngine/internal-Controller access is needed. Verified against sbox-public
	/// engine source (Systems/Input/Controller/{InputAnalog.cs,Input.Controller.cs}) AND the installed
	/// Sandbox.Engine.dll metadata (2026-07-17).
	///
	/// Handbrake: keyboard Space ("Jump") is untouched; a gamepad bumper ("Handbrake" action,
	/// SwitchLeftBumper) is ADDED alongside it in Input.config — gamepad A already reaches Jump
	/// too (Jump's own GamepadCode), so the bumper is a second, driving-specific way in.</summary>
	static DriveInputs SampleDeviceInputs()
	{
		var move = Input.AnalogMove;

		// keyboard/stick forward+back split off the shared move axis (W = +x, S = -x)
		float keyThrottle = MathF.Max( 0f, move.x );
		float keyBrake = MathF.Max( 0f, -move.x );

		// gamepad triggers, true analog 0..1 (right = gas, left = brake)
		float triggerThrottle = ReadTrigger( InputAnalog.RightTrigger );
		float triggerBrake = ReadTrigger( InputAnalog.LeftTrigger );

		// MAX blend per channel so either device drives the pedal, neither overrides the other
		float throttle = MathF.Max( keyThrottle, triggerThrottle );
		float brake = MathF.Max( keyBrake, triggerBrake );
		float moveForward = Math.Clamp( throttle - brake, -1f, 1f );

		return new DriveInputs
		{
			MoveForward = moveForward,
			Steer = ApplyGamepadSteerCurve( move.y ),
			Handbrake = Input.Down( "Jump" ) || Input.Down( "Handbrake" ),
		};
	}

	/// <summary>Read a gamepad trigger as a linear 0..1 pull with a small deadzone floor (rescaled so
	/// full pull still reaches 1.0). <c>Input.GetAnalog</c> already returns 0 for a trigger on keyboard
	/// (UsingController false) and the engine pre-applies a 12.5% deadzone, so this only shapes the
	/// gamepad path.</summary>
	static float ReadTrigger( InputAnalog trigger )
	{
		float v = Math.Clamp( Input.GetAnalog( trigger ), 0f, 1f );
		if ( v < GamepadTriggerDeadzone )
			return 0f;
		return (v - GamepadTriggerDeadzone) / (1f - GamepadTriggerDeadzone);
	}

	/// <summary>Deadzone + power curve for the analog steer axis. Values under the deadzone snap
	/// to 0; the remaining range is rescaled so full stick deflection still reaches ±1 (no lost
	/// lock), then raised to <see cref="GamepadSteerCurvePower"/> for a softer center. Keyboard's
	/// exact -1/0/1 passes through unaffected: 0 is inside the deadzone (stays 0) and 1 rescales
	/// to 1 before and after the power (1^n == 1) — so this is gamepad-only in practice despite
	/// running on every sample.</summary>
	static float ApplyGamepadSteerCurve( float raw )
	{
		float mag = MathF.Abs( raw );
		if ( mag < GamepadSteerDeadzone )
			return 0f;

		float t = Math.Clamp( (mag - GamepadSteerDeadzone) / (1f - GamepadSteerDeadzone), 0f, 1f );
		return MathF.Sign( raw ) * MathF.Pow( t, GamepadSteerCurvePower );
	}

	void ReadInput()
	{
		// one seam: live devices by default, or whatever an input source (the test pilot, an input
		// device) staged in InputOverride this tick. The struct carries the SAME raw intent the
		// keyboard sampled, so all the gear/reverse/steer-ramp logic below is source-agnostic.
		var inputs = InputOverride ?? SampleDeviceInputs();

		// forward stick/W = accelerate, back = brake — unless (near-)stopped, then back = reverse
		float forwardInput = inputs.MoveForward;
		float forwardSpeed = Vector3.Dot( _rigidbody.Velocity * Units.UnitsToMeters, WorldRotation.Forward );

		// direction changes engage while still rolling ~1 m/s the wrong way — waiting
		// for a dead stop is what made K-turns feel like a driving test
		if ( Drivetrain.Gear >= 0 )
		{
			Throttle = MathF.Max( 0f, forwardInput );
			Brake = MathF.Max( 0f, -forwardInput );

			if ( forwardInput < -0.15f && forwardSpeed < 1.0f )
				Drivetrain.EngageReverse();
		}
		else // reverse gear: back = reverse throttle, forward = brake
		{
			Throttle = MathF.Max( 0f, -forwardInput );
			Brake = MathF.Max( 0f, forwardInput );

			if ( forwardInput > 0.15f && forwardSpeed > -1.0f )
				Drivetrain.EngageForward();
		}

		Handbrake = inputs.Handbrake;

		// reverse is for maneuvering, not backward land-speed records
		if ( Drivetrain.Gear == -1 && SpeedMs > Definition.ReverseSpeedCap )
			Throttle = 0f;

		// steering: keyboard ramps, analog is direct; both rate and lock reduce with
		// speed — fast full-lock flicks at 70+ km/h were driver-induced weave
		float speedT = Math.Clamp( SpeedMs / 22f, 0f, 1f );
		float steerTarget = inputs.Steer;
		float rate = (MathF.Abs( steerTarget ) > 0.01f
			? MathX.Lerp( 4.5f, 1.8f, speedT )
			: MathX.Lerp( 6f, 3f, speedT )) * Definition.SteerRateScale;
		Steer = MathX.Lerp( Steer, steerTarget, Math.Clamp( rate * Time.Delta, 0f, 1f ) );

		// ── sequential MANUAL shift requests (feature 2026-07-15) ──
		// Two sources at ONE seam, exactly like MoveForward/Steer/Handbrake: a scripted InputOverride
		// (the test pilot) carries the shift bits in the struct; live play uses the per-frame edges
		// latched in OnUpdate. Live latches are consumed either way so a stray key pressed during a
		// pilot run can't leak into a live shift when the override clears.
		bool reqUp, reqDown, reqMode;
		if ( InputOverride is DriveInputs ov )
		{
			reqUp = ov.ShiftUp;
			reqDown = ov.ShiftDown;
			reqMode = ov.ShiftModeToggle;
		}
		else
		{
			reqUp = _liveShiftUp;
			reqDown = _liveShiftDown;
			reqMode = _liveShiftMode;
		}
		_liveShiftUp = _liveShiftDown = _liveShiftMode = false;

		ApplyShiftRequests( reqUp, reqDown, reqMode, forwardSpeed / Definition.WheelRadius );
	}

	// ── sequential MANUAL shift state (feature 2026-07-15) ──
	bool _liveShiftUp, _liveShiftDown, _liveShiftMode;   // per-frame edges latched in OnUpdate
	bool _prevReqUp, _prevReqDown, _prevReqMode;          // rising-edge memory across fixed ticks

	/// <summary>Rising-edge-detect this tick's shift requests and drive the drivetrain. The live path's
	/// requests are already one-tick pulses (latched Input.Pressed edges), and this ALSO edge-gates the
	/// override/pilot path so a source that holds a bit across ticks shifts exactly once (the
	/// edge-through-InputOverride trap). <paramref name="groundWheelSpeed"/> (rad/s) feeds the
	/// down-shift over-rev guard.</summary>
	void ApplyShiftRequests( bool up, bool down, bool mode, float groundWheelSpeed )
	{
		if ( mode && !_prevReqMode )
			ToggleShiftMode();
		if ( up && !_prevReqUp )
			TryShiftUp();
		if ( down && !_prevReqDown )
			TryShiftDown( groundWheelSpeed );

		_prevReqUp = up;
		_prevReqDown = down;
		_prevReqMode = mode;
	}

	void ToggleShiftMode()
	{
		Drivetrain.ManualMode = !Drivetrain.ManualMode;
		Log.Info( $"[vp] shiftmode {(Drivetrain.ManualMode ? "MANUAL" : "AUTO")} gear {Drivetrain.Gear}" );
	}

	void TryShiftUp()
	{
		// In AUTO a manual shift request is a no-op (the box shifts itself; mode changes only via G).
		if ( !Drivetrain.ManualMode )
			return;
		if ( Drivetrain.ShiftUp() )
			Log.Info( $"[vp] shift UP -> gear {Drivetrain.Gear}" );
	}

	void TryShiftDown( float groundWheelSpeed )
	{
		if ( !Drivetrain.ManualMode )
			return;
		if ( Drivetrain.ShiftDown( groundWheelSpeed ) )
			Log.Info( $"[vp] shift DOWN -> gear {Drivetrain.Gear}" );
		else
			Log.Info( $"[vp] shift DOWN denied gear {Drivetrain.Gear} " +
				$"(predicted {Drivetrain.PredictedDownshiftRpm( groundWheelSpeed ):F0} / redline {Drivetrain.Redline:F0})" );
	}

	void ApplySteering()
	{
		float speedFactor = Math.Clamp( SpeedMs / 22f, 0f, 1f );
		float maxAngle = MathX.Lerp( Definition.MaxSteerAngle, Definition.HighSpeedSteerAngle, speedFactor );
		float angle = Steer * maxAngle;

		foreach ( var wheel in Wheels.Where( w => w.IsSteering ) )
		{
			wheel.SteerAngle = angle;
			wheel.LocalRotation = Rotation.FromYaw( angle );
		}
	}

	float BrakeTorqueFor( VehicleWheel wheel )
	{
		bool isFront = wheel.IsSteering;
		float bias = isFront ? Definition.BrakeBias : 1f - Definition.BrakeBias;
		float torque = Brake * Definition.BrakeTorque * bias * 0.5f; // per wheel (2 per axle)

		if ( Handbrake && wheel.HasHandbrake )
		{
			// Drift-exit soft-lock (feel session 2026-07-13): when a slip cap is active and this
			// rear is already sliding PAST it (SlipRatio more negative than the cap), withhold the
			// handbrake torque this substep so the wheel spins back up toward the cap — the rears keep
			// rotating and retain lateral authority instead of dead-locking into 60°+ slip angles.
			// Same one-substep-lagged SlipRatio the ABS branch below reads. Default cap -1.0 leaves
			// capActive false, so full lock (today's behavior) is byte-identical for every other car.
			bool capActive = Definition.HandbrakeSlipCap > -1f;
			if ( !capActive || wheel.SlipRatio > Definition.HandbrakeSlipCap )
				torque += Definition.HandbrakeTorque;
		}

		// ABS: release when the wheel locks under braking (Casual + Sport). Thresholds are
		// per-car dials (see CarDefinition.AbsSlipThreshold).
		if ( Assists != AssistLevel.Sim && torque > 0f && wheel.IsGrounded
			&& wheel.SlipRatio < -Definition.AbsSlipThreshold )
			torque *= Definition.AbsReleaseFactor;

		return torque;
	}

	/// <summary>
	/// Arcade brake assist: extra chassis-level deceleration while braking. The tire
	/// model alone stops at sim rates, which reads as "slow" against arcade expectations.
	/// Capped so it can never reverse the car within a step.
	/// </summary>
	void ApplyBrakeAssist()
	{
		if ( Definition.BrakeAssist <= 0f || Brake < 0.1f || SpeedMs < 0.5f )
			return;

		if ( Wheels.Count( w => w.IsGrounded ) < 2 )
			return;

		var flat = _rigidbody.Velocity.WithZ( 0f );
		if ( flat.IsNearZeroLength )
			return;

		float decel = Definition.BrakeAssist * Brake; // m/s²
		float stopCap = flat.Length * Units.UnitsToMeters / Time.Delta;
		float applied = MathF.Min( decel, stopCap );

		_rigidbody.ApplyForce( -flat.Normal * applied * Definition.Mass * Units.MetersToUnits );
	}

	TimeSince _recoverLog = 999f;

	/// <summary>
	/// Arcade SPIN-RECOVERY assist (feel session 2026-07-15). After a handbrake flick spins the
	/// car ~180°, the player holds full FORWARD throttle but the car keeps rolling BACKWARDS (its old
	/// travel direction) for too long before the tires pick up and drive it the new way. BrakeAssist
	/// can't cover this: with a forward gear + W held, ReadInput sets Throttle=1, Brake=0 — so the only
	/// thing arresting the backward slide is deep-slip tire tail grip, further scaled down by the
	/// friction ellipse sharing with lateral demand. This adds chassis-level deceleration along
	/// -flat-velocity WHENEVER the input throttle commands the gear's direction while ground velocity
	/// along the car's facing opposes it (the quadrant BrakeAssist's opposing-input→Brake mapping never
	/// reaches), fading out via an opposition ramp as the car rotates to face its motion. Capped by the
	/// same never-reverse-within-a-step stopCap BrakeAssist uses. Sim keeps the raw accepted feel.
	///
	/// Interaction with drift-catch (VehicleController.DriftCatchFactor): drift-catch cuts DRIVETRAIN
	/// throttle for ≤0.5 s after handbrake release while the rear is deeply SIDEWAYS; this reads INPUT
	/// throttle and applies a CHASSIS force. They serve different states — sideways (realign) vs
	/// backwards (kill stale velocity) — and act together in a spin-recovery WITHOUT being merged: keep
	/// them separate (a sideways car may not be backwards, and a backwards car may already be aligned).
	/// </summary>
	void ApplySpinRecoveryAssist()
	{
		if ( Definition.SpinRecoveryAssist <= 0f || Assists == AssistLevel.Sim )
			return;

		// Throttle is the gear-direction INPUT throttle magnitude set in ReadInput (before the
		// drift-catch / TC drivetrain governors scale it) — exactly the raw request this assist wants.
		if ( Throttle <= 0.1f )
			return;

		if ( Wheels.Count( w => w.IsGrounded ) < 2 )
			return;

		var flat = _rigidbody.Velocity.WithZ( 0f );
		float planarSpeed = flat.Length * Units.UnitsToMeters;
		if ( planarSpeed < 0.5f )
			return;

		// velocity along the car's facing. commandedDir folds forward/reverse gear into one sign: in a
		// forward gear the throttle commands +forward, so a NEGATIVE forwardSpeed (still sliding
		// backwards under forward throttle) is the uncovered case; in reverse gear it commands
		// -forward, so a POSITIVE forwardSpeed (reverse throttle while still rolling forward) mirrors it.
		float forwardSpeed = Vector3.Dot( _rigidbody.Velocity * Units.UnitsToMeters, WorldRotation.Forward );
		float commandedDir = Drivetrain.Gear >= 0 ? 1f : -1f;
		float alongCommanded = forwardSpeed * commandedDir; // <0 ⇒ velocity opposes the throttle direction
		if ( alongCommanded > -0.5f )
			return; // already moving the commanded way (or ~stopped) — nothing to recover

		// opposition ramp: fraction of speed pointing the wrong way — 1 when velocity fully opposes
		// facing, fading to 0 as the car rotates to face its motion (so the assist bows out on its own).
		float oppositionRamp = Math.Clamp( -alongCommanded / planarSpeed, 0f, 1f );

		float decel = Definition.SpinRecoveryAssist * oppositionRamp; // m/s²
		float stopCap = planarSpeed / Time.Delta; // never reverse the flat velocity within a step
		float applied = MathF.Min( decel, stopCap );

		_rigidbody.ApplyForce( -flat.Normal * applied * Definition.Mass * Units.MetersToUnits );

		// throttled ~2 Hz while active — parseable for free-drive sessions (wallglance log style).
		if ( _recoverLog > 0.5f )
		{
			_recoverLog = 0f;
			Log.Info( $"[vp] recover v {planarSpeed * 3.6f:F0}kmh along {alongCommanded:F1}m/s ramp {oppositionRamp:F2} decel {applied:F1}m/s2" );
		}
	}

	// ── drift-catch assist (feel session 2026-07-13) ──
	// The measured drift-exit anatomy: on handbrake release the driver goes to full throttle while
	// the rear slip angle is still 60°+, so the drive torque spends the rear tires' friction ellipse
	// LONGITUDINALLY exactly when every newton of lateral force is needed to realign the velocity
	// vector ("stuck sliding sideways" + rearK 0.37 spike + auto-downshift in the telemetry).
	// For a short window after the handbrake releases, while the rear is still deeply sideways, cut
	// throttle-induced rear slip (ramping to a full cut by DriftCatchFullCutDeg) so the ellipse serves
	// realignment first — mirrors real drift-catch technique (wait for the catch before power).
	// Casual + Sport only; Sim stays the raw accepted feel. The 20° floor keeps deliberate
	// power-oversteer (jturn rotation, small-angle slides) untouched.
	const float DriftCatchWindowS = 0.5f;    // seconds after hb release the assist can act
	const float DriftCatchStartDeg = 20f;    // rear slip angle where the cut starts
	const float DriftCatchFullCutDeg = 35f;  // rear slip angle at/past which throttle is fully cut

	bool _wasHandbrake;
	TimeSince _sinceHandbrakeRelease = 999f;

	float DriftCatchFactor()
	{
		if ( Assists == AssistLevel.Sim || Handbrake )
			return 1f;
		if ( _sinceHandbrakeRelease > DriftCatchWindowS )
			return 1f;

		var rears = Wheels.Where( w => !w.IsSteering && w.IsGrounded ).ToList();
		if ( rears.Count == 0 )
			return 1f;

		float rearADeg = MathF.Abs( rears.Average( w => w.SlipAngle ) ).RadianToDegree();
		if ( rearADeg <= DriftCatchStartDeg )
			return 1f;

		return Math.Clamp( 1f - (rearADeg - DriftCatchStartDeg) / (DriftCatchFullCutDeg - DriftCatchStartDeg), 0f, 1f );
	}

	float ApplyTractionControl( float throttle, List<VehicleWheel> driven )
	{
		// drifting is throttle-steered — TC clamping wheelspin would kill the slide
		if ( Handbrake || Assists != AssistLevel.Casual || throttle <= 0f )
			return throttle;

		// proportional: hold driven-wheel slip near the grip PEAK, not past it. The longitudinal
		// tire-curve peaks sit at slip 0.09-0.14; the old 0.25 target parked the tire in the
		// post-peak slide — slower (less grip than the peak) AND permanently over the wheelspin
		// telemetry threshold (slip > 0.2), so every launch logged multi-second "wheelspin".
		// 0.14 targets the peak: more launch grip and slip under the counter. (tuning iteration 2a)
		const float TcSlipTarget = 0.14f;
		float worstSlip = driven.Where( w => w.IsGrounded ).Select( w => w.SlipRatio ).DefaultIfEmpty( 0f ).Max();
		if ( worstSlip <= TcSlipTarget )
			return throttle;

		return throttle * Math.Clamp( TcSlipTarget / worstSlip, 0.2f, 1f );
	}

	void ApplyStabilityAssist()
	{
		// the drift button asks for yaw — don't damp it away while held
		if ( Handbrake || Assists != AssistLevel.Casual )
			return;

		// spec 5.2.3: small corrective action when the rear steps out — damp yaw so slides
		// are catchable instead of divergent (the lift-off L-R flick spin)
		var rears = Wheels.Where( w => !w.IsSteering && w.IsGrounded ).ToList();
		if ( rears.Count == 0 )
			return;

		float rearAlpha = MathF.Abs( rears.Average( w => w.SlipAngle ) );
		if ( rearAlpha < 0.07f ) // ~4 degrees
			return;

		// scales up with speed: the flat 3f cap let a 115 km/h lift-off flick go full 360
		float speedScale = 3f + 3f * Math.Clamp( SpeedMs / 30f, 0f, 1f );
		float strength = Math.Clamp( (rearAlpha - 0.07f) * 8f, 0f, 1f ) * speedScale;
		var angular = _rigidbody.AngularVelocity;
		angular.z *= MathF.Max( 0f, 1f - strength * Time.Delta );
		_rigidbody.AngularVelocity = angular;
	}

	// ── wall-glance forgiveness assist (feel session 2026-07-12) ──
	// Detection surface = the chassis Rigidbody's collision callbacks (Component.ICollisionListener;
	// the ONLY runtime chassis-contact API — verified against Sandbox.Engine.xml, this also seeds the
	// destruction D0 impact survey: OnCollisionStart/Update/Stop carry Sandbox.Collision with
	// .Contact.Point/.Contact.Normal and .Other). The chassis box rests ABOVE the wheels' contact
	// zone, so it never touches flat ground — these fire only on real obstacle contact (wall, cone,
	// bottom-out). We latch ONLY near-horizontal normals (true walls); ground/ramps/banks have
	// near-vertical normals and are ignored.
	const float WallNormalZMax = 0.5f;   // |normal.z| < this ⇒ surface within ~30° of vertical = a wall
	// engage as soon as a wall contact is confirmed (a corner-wedge kills speed in a few frames, so a
	// multi-frame gate arrives after the dead-stop it's meant to prevent). Cone exclusion instead
	// rests on geometry: the shipping city has NO cones (only the slalom test station does), and every
	// battery slalom run records 0 cone strikes — so no [vp] wallglance line can appear in the battery
	// (a battery-run assertion). A stray ≤1-frame contact still can't engage: the streak must reach this AND
	// the car must be moving INTO the surface above the speed floor.
	const int WallEngageTicks = 1;
	const float WallGlanceMinSpeed = 3f; // m/s planar floor below which forgiveness is pointless

	Vector3 _wallNormalH;          // horizontal unit wall normal from the most recent contact
	TimeSince _sinceWallContact = 999f;
	int _wallStreak;
	TimeSince _wallGlanceLog = 999f;

	public void OnCollisionStart( Collision o ) => NoteWallContact( o );
	public void OnCollisionUpdate( Collision o ) => NoteWallContact( o );
	public void OnCollisionStop( CollisionStop o ) { }

	void NoteWallContact( Collision c )
	{
		if ( IsProxy )
			return;

		var n = c.Contact.Normal;
		// a wall = near-horizontal contact normal (surface within ~30° of vertical). Ground/ramps/
		// banks report near-vertical normals and never latch, so this can't fire driving over terrain.
		if ( MathF.Abs( n.z ) >= WallNormalZMax )
			return;

		var nH = n.WithZ( 0f );
		if ( nH.IsNearZeroLength )
			return;

		_wallNormalH = nH.Normal;
		_sinceWallContact = 0f;
	}

	/// <summary>
	/// Forgiveness for angled (esp. mid-drift) wall contact: instead of a dead stop, re-project
	/// velocity onto the wall tangent (keeping <see cref="CarDefinition.WallScrubFactor"/> of speed)
	/// and gently yaw the heading to run parallel — both scaled by incidence so HEAD-ON hits stay
	/// hard stops (the destruction program depends on honest frontal crashes). An assist: gated on
	/// <see cref="CarDefinition.WallGlanceAssist"/> and Assists != Sim (Sim = the accepted raw feel).
	/// Runs in OnFixedUpdate BEFORE the physics step, so the solver then resolves the redirected
	/// velocity without penetration — same write-then-step pattern as the brake/stability assists.
	/// </summary>
	void ApplyWallGlanceAssist()
	{
		bool contactNow = _sinceWallContact <= Time.Delta * 1.5f;
		if ( !contactNow )
		{
			_wallStreak = 0;
			return;
		}
		_wallStreak++;

		if ( Definition is null || !Definition.WallGlanceAssist || Assists == AssistLevel.Sim )
			return;
		if ( _wallStreak < WallEngageTicks )
			return;

		var vel = _rigidbody.Velocity;
		var planar = vel.WithZ( 0f );
		float planarSpeedMs = planar.Length * Units.UnitsToMeters;
		if ( planarSpeedMs < WallGlanceMinSpeed )
			return;

		var nH = _wallNormalH;
		float into = Vector3.Dot( planar.Normal, nH ); // <0 ⇒ moving INTO the wall
		if ( into >= -0.01f )
			return; // already sliding along / peeling away — nothing to catch

		// incidence between velocity and the wall PLANE: 0° = grazing, 90° = straight-on
		float incidenceDeg = MathF.Asin( Math.Clamp( MathF.Abs( into ), 0f, 1f ) ).RadianToDegree();

		// full assist below shallow, none at/above head-on (frontal crashes keep the hard stop)
		float scale = incidenceDeg >= Definition.WallGlanceHeadOnDeg ? 0f
			: incidenceDeg <= Definition.WallGlanceShallowDeg ? 1f
			: (Definition.WallGlanceHeadOnDeg - incidenceDeg)
				/ MathF.Max( 1e-3f, Definition.WallGlanceHeadOnDeg - Definition.WallGlanceShallowDeg );
		if ( scale <= 0f )
			return;

		// tangent = the velocity component that runs ALONG the wall (the slide direction)
		var vTangent = planar - Vector3.Dot( planar, nH ) * nH;
		if ( vTangent.IsNearZeroLength )
			return;
		var tangent = vTangent.Normal;

		// (a) re-project velocity onto the tangent: kill the into-wall component (which would wedge
		// the rigid chassis corner and dead-stop it) and carry the slide forward at
		// max(natural tangential speed, WallScrubFactor·total). This is NOT a per-frame ·0.75 scale —
		// that compounds into a fast decay; here, once the car is redirected along the wall the target
		// equals the current tangential speed, so it slides steadily instead of grinding to a halt.
		// Blended by incidence so a near head-on keeps the physics dead-stop.
		float totalSpeed = planar.Length;
		float targetSpeed = MathF.Min( totalSpeed,
			MathF.Max( vTangent.Length, totalSpeed * Definition.WallScrubFactor ) );
		var newPlanar = Vector3.Lerp( planar, tangent * targetSpeed, scale );
		_rigidbody.Velocity = newPlanar.WithZ( vel.z );

		// (b) gentle yaw torque aligning heading to the wall tangent (whichever way the car faces)
		var fwd = WorldRotation.Forward.WithZ( 0f ).Normal;
		var alignTo = Vector3.Dot( fwd, tangent ) < 0f ? -tangent : tangent;
		float cross = fwd.x * alignTo.y - fwd.y * alignTo.x; // +z ⇒ target is to the LEFT (CCW)
		float yawErrRad = MathF.Atan2( cross, Vector3.Dot( fwd, alignTo ) );
		var ang = _rigidbody.AngularVelocity;
		ang.z = MathX.Lerp( ang.z, yawErrRad * Definition.WallAlignStrength,
			Math.Clamp( Definition.WallAlignStrength * scale * Time.Delta, 0f, 1f ) );
		_rigidbody.AngularVelocity = ang;

		// throttled so a multi-tick slide doesn't flood the log; tagged for the battery assertion
		// (task 6: NO [vp] wallglance lines may appear in any battery maneuver's console output).
		if ( _wallGlanceLog > 0.2f )
		{
			_wallGlanceLog = 0f;
			Log.Info( $"[vp] wallglance inc {incidenceDeg:F0}deg scale {scale:F2} v {planarSpeedMs * 3.6f:F0}->{newPlanar.Length * Units.UnitsToMeters * 3.6f:F0}kmh" );
		}
	}

	public void Respawn()
	{
		WorldPosition = _spawnPosition + Vector3.Up * 20f;
		WorldRotation = _spawnRotation;
		_rigidbody.Velocity = Vector3.Zero;
		_rigidbody.AngularVelocity = Vector3.Zero;
	}
}

/// <summary>
/// Per-frame driver intent, the value-struct swap point at VehicleController's input seam
/// (<see cref="VehicleController.InputOverride"/>). Carries the raw intent the controller
/// samples from a device: the move axis (forward/back + steer) and the handbrake action. A
/// keyboard/gamepad/wheel source or the test <c>VehiclePilot</c> all produce one of these, so
/// the controller's gear/reverse/steer-ramp logic stays source-agnostic.
///
/// This was introduced for the pilot; the gamepad tier
/// plugs the gamepad in at this SAME three-field shape rather than growing new fields: the analog
/// trigger throttle/brake MAX-blend with the keyboard/stick axis into <see cref="MoveForward"/> and the bumper handbrake
/// ORs into <see cref="Handbrake"/> inside <c>SampleDeviceInputs</c> (device sampling stays
/// entirely inside that one method — see its doc for why triggers/curve landed there instead of
/// as new struct fields). A future shift/H-shifter wheel-tier field is still open, deferred to
/// a future wheel-tier pass.
/// </summary>
public struct DriveInputs
{
	/// <summary>-1..1 signed drive axis: +forward accelerates, -back brakes then engages reverse near a
	/// stop. Built in <c>SampleDeviceInputs</c> as (throttle − brake) where each channel is the MAX of
	/// the keyboard/stick component (<c>Input.AnalogMove.x</c>) and the ANALOG gamepad trigger pull
	/// (<c>Input.GetAnalog(InputAnalog.RightTrigger|LeftTrigger)</c>, 0..1) — so a partial trigger gives
	/// a partial pedal. The test pilot sets this float directly.</summary>
	public float MoveForward;

	/// <summary>-1..1, maps to <c>Input.AnalogMove.y</c> (gamepad path reshaped by the gamepad deadzone
	/// + response curve in <c>SampleDeviceInputs</c>). Note <c>Rotation.FromYaw(+)</c> is a
	/// LEFT/CCW turn (verified in-engine), so +Steer steers left.</summary>
	public float Steer;

	/// <summary>The handbrake / drift button: keyboard "Jump" (Space), or gamepad A (Jump's own
	/// GamepadCode) or the left bumper ("Handbrake" action).</summary>
	public bool Handbrake;

	/// <summary>Edge-triggered request to shift UP one gear (sequential MANUAL mode). Keyboard E /
	/// gamepad R1 while live; a scripted source (the test pilot) pulses it for one tick. The controller
	/// rising-edge-detects it, so a source that holds it across ticks still shifts exactly once.</summary>
	public bool ShiftUp;

	/// <summary>Edge-triggered request to shift DOWN one gear (sequential MANUAL mode). Keyboard Q /
	/// gamepad L1. Same one-shot rising-edge semantics as <see cref="ShiftUp"/>.</summary>
	public bool ShiftDown;

	/// <summary>Edge-triggered request to toggle the transmission mode AUTO↔MANUAL. Keyboard G /
	/// gamepad DpadNorth. Same one-shot rising-edge semantics as <see cref="ShiftUp"/>.</summary>
	public bool ShiftModeToggle;
}