Drivetrain.cs

Drivetrain component for a vehicle. It models engine, clutch, gearbox and differential state per simulation substep, computes engine RPM, torque output per driven wheel, auto and manual shifting, rev limiter and clutch slip behavior.

Native Interop
namespace FieldGuide.VehiclePhysics;

/// <summary>
/// Engine + clutch + gearbox + diff state machine. Plain class owned by
/// VehicleController, simulated per substep. RPM is its own integrated state coupled to
/// the wheels through an auto-clutch, so revving at standstill and shift flare exist.
/// </summary>
public class Drivetrain
{
	readonly CarDefinition _def;

	public float Rpm { get; private set; }
	public int Gear { get; private set; } = 1; // 1-based; 0 = neutral, -1 = reverse
	public bool IsShifting => _shiftTimer > 0f;

	/// <summary>Driver-selectable transmission mode. false (default) = the untuned automatic box
	/// (byte-identical existing behavior); true = sequential MANUAL — the auto-shift block in
	/// <see cref="Simulate"/> is suppressed and gear changes come from <see cref="ShiftUp"/> /
	/// <see cref="ShiftDown"/> driven off input. The rev limiter, shift timer/lockout, and clutch
	/// machinery are untouched by the mode (they model the shift event either way).</summary>
	public bool ManualMode { get; set; }

	/// <summary>Redline (rpm) for this car — read by the manual over-rev guard / UI.</summary>
	public float Redline => _def.RedlineRpm;

	float _shiftTimer;
	float _shiftLockout;
	float _freeRpm; // engine-side rpm when the clutch slips
	float _limiterHold; // seconds spent pinned near redline under power (limiter-camp escape)

	// Downshift arming (anti-hunt hysteresis): a gear entered by UPSHIFT starts unproven and may
	// not auto-downshift under power until groundRpm has first risen past ShiftDownRpm — an escape
	// shift can land below that threshold on purpose (recovery in progress), and a fixed-length
	// lockout can expire before the low-rpm clutch-slip zone climbs out (measured: 2nd entered at
	// ~1150 ground rpm climbs ~575 rpm/s, so 1.5 s ends at ~1980 < the 2200 downshift point —
	// the box bounced straight back into the wheelspin it had escaped). Lifting the throttle
	// re-arms the downshift immediately, so coasting to a stop still steps down normally.
	bool _gearProven = true;

	public Drivetrain( CarDefinition def )
	{
		_def = def;
		Rpm = def.IdleRpm;
		_freeRpm = def.IdleRpm;
	}

	/// <summary>Analytic torque curve: ~50% at idle, peak ~75% of the band, mild falloff at redline.</summary>
	public float EngineTorqueAt( float rpm )
	{
		float n = Math.Clamp( (rpm - _def.IdleRpm) / (_def.RedlineRpm - _def.IdleRpm), 0f, 1f );
		float shape = (0.5f + 1.5f * n - n * n) / 1.0625f;
		return _def.PeakTorque * shape;
	}

	float CurrentRatio => Gear switch
	{
		> 0 => _def.GearRatios[Gear - 1] * _def.FinalDrive,
		-1 => -_def.ReverseRatio * _def.FinalDrive,
		_ => 0f
	};

	/// <summary>Redline-equivalent driven-wheel angular speed (rad/s) for the CURRENT gear ratio;
	/// float.MaxValue in neutral (no drive coupling). Read fresh each substep by the controller
	/// (the ratio changes on a shift) and handed to the wheels as the per-substep drive-side omega
	/// cap: the rev limiter cuts torque only on the substep AFTER wheel-implied rpm crosses redline,
	/// and a light wheel at high PeakTorque can blow 6x past redline-equivalent within that one
	/// substep (measured: kart at 900 N-m hits 289-339 rad/s vs a 44 rad/s gear-1 redline
	/// equivalent). The cap makes the limiter effectively per-substep on the drive side.</summary>
	public float RedlineWheelSpeed
		=> CurrentRatio == 0f ? float.MaxValue : _def.RedlineRpm * MathF.Tau / 60f / MathF.Abs( CurrentRatio );

	/// <summary>
	/// One substep. avgDrivenWheelSpeed and groundWheelSpeed in rad/s. Returns torque per driven
	/// wheel (N·m). Clutch engagement is a continuous blend — a binary locked/slipping switch
	/// sits right at town-driving speeds and judders. Shift decisions use GROUND speed, not
	/// engine/wheel rpm: wheelspin inflates engine rpm, causing 1-2-1-2 shift hunting where
	/// every downshift torque-spikes the rear tires (spin-outs).
	/// </summary>
	public float Simulate( float dt, float throttle, float avgDrivenWheelSpeed, float groundWheelSpeed, int drivenWheelCount )
	{
		if ( _shiftTimer > 0f )
		{
			_shiftTimer -= dt;
			throttle = 0f; // torque cut during shift
		}

		float ratio = CurrentRatio;
		float wheelImpliedRpm = MathF.Abs( avgDrivenWheelSpeed * ratio ) * 60f / MathF.Tau;

		// continuous auto-clutch: 0 at stall rpm, fully locked a few hundred rpm above idle
		float stallRpm = _def.IdleRpm * 1.05f;
		float lockRpm = _def.IdleRpm * 1.7f;
		float engagement = Gear == 0 ? 0f : Math.Clamp( (wheelImpliedRpm - stallRpm) / (lockRpm - stallRpm), 0f, 1f );

		// engine-side rpm when slipping: revs toward the throttle target
		float freeTarget = _def.IdleRpm + throttle * (_def.RedlineRpm * 0.5f - _def.IdleRpm);
		_freeRpm = Math.Clamp( _freeRpm + (freeTarget - _freeRpm) * 5f * dt, _def.IdleRpm, _def.RedlineRpm );

		float lockedRpm = Math.Clamp( wheelImpliedRpm, _def.IdleRpm, _def.RedlineRpm );
		Rpm = MathX.Lerp( _freeRpm, lockedRpm, engagement );

		float engineTorque = EngineTorqueAt( Rpm ) * throttle;
		float engineBrake = _def.EngineBrakeTorque * (1f - throttle) * (Rpm / _def.RedlineRpm) * engagement;
		float slipTransmission = Math.Clamp( throttle * 1.2f, 0f, 1f ) * 0.85f;
		float clutchFactor = MathX.Lerp( slipTransmission, 1f, engagement );
		float torqueOut = (engineTorque * clutchFactor - engineBrake) * ratio;

		// rev limiter
		if ( Rpm >= _def.RedlineRpm && throttle > 0f )
			torqueOut = MathF.Min( torqueOut, 0f );

		// automatic shifting from ground-speed-implied rpm + post-shift lockout
		_shiftLockout -= dt;
		float groundRpm = MathF.Abs( groundWheelSpeed * ratio ) * 60f / MathF.Tau;

		// Limiter-camp escape: the upshift decision reads GROUND-speed rpm (anti-hunt — see the
		// header comment) but the engine + rev limiter run on WHEEL-implied rpm, and wheelspin
		// separates the two. With traction control off, a hard launch inflates engine rpm onto the
		// limiter while groundRpm is still below ShiftUpRpm — the box then bounces on the limiter
		// until ground speed catches up (measured on the hatch: ~3.4 s pinned 5945–6300 in 1st
		// while groundRpm crawled 560→5730). The limiter cut decelerates a spinning wheel within
		// substeps, so the bounce dips a few percent below redline many times a second — the
		// near-limiter window is therefore WIDE (0.94) and the hold DECAYS on dips instead of
		// resetting, or the oscillation zeroes the timer forever and the escape never fires.
		bool nearLimiter = Rpm >= _def.RedlineRpm * 0.94f && throttle > 0.5f;
		_limiterHold = nearLimiter ? _limiterHold + dt : MathF.Max( 0f, _limiterHold - dt * 0.5f );

		// Downshift arming: the current gear proves itself the moment ground rpm clears the
		// downshift threshold; from then on the normal downshift rule applies unchanged.
		if ( !_gearProven && groundRpm >= _def.ShiftDownRpm )
			_gearProven = true;

		if ( !ManualMode && Gear > 0 && !IsShifting && _shiftLockout <= 0f )
		{
			bool wantUp = groundRpm > _def.ShiftUpRpm;
			bool escape = false;
			if ( !wantUp && _limiterHold > 0.25f && Gear < _def.GearRatios.Length )
			{
				// Escape guard: the next gear only needs to be VIABLE, not already above the
				// downshift point — a spinning launch hooks up instantly on the taller gear and
				// recovers under the downshift-arming hysteresis (which holds the box in the new
				// gear through the sub-ShiftDownRpm climb no matter how long it takes).
				float nextRatio = _def.GearRatios[Gear] * _def.FinalDrive; // Gear is 1-based → [Gear] = next gear up
				float postShiftGroundRpm = MathF.Abs( groundWheelSpeed * nextRatio ) * 60f / MathF.Tau;
				wantUp = escape = postShiftGroundRpm >= _def.ShiftDownRpm * 0.5f;
			}

			if ( wantUp && Gear < _def.GearRatios.Length )
			{
				Gear++;
				_shiftTimer = 0.15f;
				// Escape shifts keep a longer settle hold; the arming hysteresis above is the real
				// anti-hunt guard (a fixed timer alone measurably expired mid-recovery).
				_shiftLockout = escape ? 1.5f : 0.8f;
				_limiterHold = 0f;
				_gearProven = false; // entered by upshift → must prove itself before downshifting under power
			}
			else if ( groundRpm < _def.ShiftDownRpm && Gear > 1 && (_gearProven || throttle < 0.5f) )
			{
				Gear--;
				_shiftTimer = 0.12f;
				_shiftLockout = 0.8f;
				_gearProven = true; // entered downward — ground rpm is higher here by ratio; nothing to prove
			}
		}

		// open diff v1: equal split across driven wheels
		return drivenWheelCount > 0 ? torqueOut / drivenWheelCount : 0f;
	}

	public void EngageReverse() { Gear = -1; _shiftTimer = 0f; _gearProven = true; }
	public void EngageForward() { if ( Gear <= 0 ) { Gear = 1; _shiftTimer = 0f; _gearProven = true; } }

	// ── sequential MANUAL shift ──
	// Gated on IsShifting only (the 0.15 s torque-cut window), NOT on _shiftLockout — the 0.8 s
	// lockout is the auto box's anti-hunt guard and would make a hand-shifted sequential box feel
	// sluggish; the player decides when to shift. Both timers are still set so the shift flare /
	// torque cut model exactly as the auto path, and so toggling back to AUTO inherits a clean state.

	/// <summary>Sequential manual up-shift: advance one forward gear. No-op in reverse/neutral, at the
	/// top gear, or mid-shift. Returns true if a gear change happened.</summary>
	public bool ShiftUp()
	{
		if ( IsShifting ) return false;
		if ( Gear <= 0 || Gear >= _def.GearRatios.Length ) return false;
		Gear++;
		_shiftTimer = 0.15f;
		_shiftLockout = 0.8f;
		_gearProven = true; // player-commanded — the auto arming rule never second-guesses manual shifts
		return true;
	}

	/// <summary>Sequential manual down-shift: drop one forward gear — BLOCKED if it would throw engine
	/// rpm past redline at the current ground speed (money-shift / over-rev guard). No-op below 1st, in
	/// reverse/neutral, or mid-shift. <paramref name="groundWheelSpeed"/> is the ground-implied wheel
	/// speed (rad/s), the same quantity the auto path shifts on. Returns true if a gear change happened.</summary>
	public bool ShiftDown( float groundWheelSpeed )
	{
		if ( IsShifting ) return false;
		if ( Gear <= 1 ) return false; // below 1st does nothing; reverse engages automatically (ReadInput)
		if ( PredictedDownshiftRpm( groundWheelSpeed ) > _def.RedlineRpm ) return false; // over-rev guard
		Gear--;
		_shiftTimer = 0.12f;
		_shiftLockout = 0.8f;
		_gearProven = true; // player-commanded — the auto arming rule never second-guesses manual shifts
		return true;
	}

	/// <summary>Engine rpm a one-gear downshift would produce at this ground wheel speed (rad/s), using
	/// the SAME ground-speed→rpm mapping as the auto-shift decision so the over-rev guard and the rev
	/// limiter agree. Returns the current <see cref="Rpm"/> when there is no lower forward gear.</summary>
	public float PredictedDownshiftRpm( float groundWheelSpeed )
	{
		if ( Gear <= 1 ) return Rpm;
		float lowerRatio = _def.GearRatios[Gear - 2] * _def.FinalDrive;
		return MathF.Abs( groundWheelSpeed * lowerRatio ) * 60f / MathF.Tau;
	}
}