Drivetrain class for a vehicle. It models engine, clutch, gearbox and differential behavior per simulation substep, computes RPM, torque per driven wheel, automatic and manual shifting, rev limiter and clutch engagement.
namespace VehicleProto;
/// <summary>
/// Engine + clutch + gearbox + diff state machine (spec §5.2.2). 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>
/// 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 (feature 2026-07-15) ──
// 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;
}
}