UI/TuningPanel.razor

A Blazor-style Sandbox.UI Razor panel for an in-game vehicle tuning UI. It renders tabs, adjustable parameter dials, suspension readouts, and supports saving/loading per-car tune presets, applying values directly to the live VehicleController and its wheels/rigidbody.

File AccessNetworking
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace VehicleProto

@* Lab / tuning (design board 1c) — toggled by T ("Debug"). Writes live onto the running car.
   Left: grouped dial panel. Right: live suspension rig (bound to VehicleWheel). *@

<root>
	@if ( UiState.TuningOpen && Car.IsValid() )
	{
		<div class="panel">
			<div class="phead">
				<div class="ptitle">Tuning — @CarName</div>
				<div class="pactions">
					<div class="qbtn" onclick=@ToggleTuneHelp>?</div>
					<div class="mono kbd">T</div>
				</div>
			</div>

			<div class="tabs">
				@foreach ( var g in Groups )
				{
					var gg = g;
					<div class="mono tab @(_tab == gg ? "on" : "")" onclick=@( () => SetTab( gg ) )>@gg</div>
				}
			</div>

			<div class="dials">
				@foreach ( var p in ActiveParams )
				{
					var pp = p;
					<div class="dial">
						<div class="drow">
							<div class="dname">@p.Name @if ( IsDirty( p ) ) { <span class="ddot"></span> }</div>
							<div class="mono dval">@p.Get().ToString( p.Fmt )@p.Suffix</div>
						</div>
						<div class="dctl">
							<div class="mono step" onclick=@( () => Adjust( pp, -1 ) )>−</div>
							<div class="track"
								onmousedown=@( e => TrackScrub( pp, e, true ) )
								onmousemove=@( e => TrackScrub( pp, e, false ) )>
								<div class="fill" style="width: @UiFmt.Pct( Frac( pp ) * 100f )%"></div>
							</div>
							<div class="mono step" onclick=@( () => Adjust( pp, +1 ) )>+</div>
						</div>
					</div>
				}
			</div>

			<div class="dirty @(DirtyCount > 0 ? "" : "clean")">
				<span class="ddot"></span>
				<span class="dtext">@DirtyText</span>
			</div>

			<div class="presets">
				<div class="prow phdr">
					<div class="mono plabel">Saved tunes · @CarName</div>
					<div class="btn primary psave" onclick=@SavePreset>Save current</div>
				</div>
				@if ( CarPresets.Count == 0 )
				{
					<div class="mono pempty">No saved tunes for this car yet — tweak the dials, then Save current.</div>
				}
				else
				{
					@* Scrollable list container (Issue 3): header/Save stay fixed above; only the ROWS
					   scroll, capped at ~3 rows so a long list can't scrunch the panel into the HUD. *@
					<div class="plist">
						@foreach ( var preset in CarPresets )
						{
							var pr = preset;
							<div class="prow">
								<div class="pname" onclick=@( () => LoadPreset( pr ) )>@pr.Name</div>
								<div class="mono pedit" onclick=@( () => BeginRename( pr ) )>Edit</div>
								<div class="mono pload" onclick=@( () => LoadPreset( pr ) )>Load</div>
								<div class="mono pdel" onclick=@( () => DeletePreset( pr ) )>Del</div>
							</div>
						}
					</div>
				}
			</div>

			<div class="foot">
				<div class="frow">
					<div class="btn ghost" onclick=@ResetAll>Reset all</div>
					<div class="btn primary" onclick=@( () => Car.Respawn() )>Respawn car · R</div>
				</div>
				<div class="mono fhint">changes apply instantly to the running car</div>
			</div>
		</div>

		<div class="suspension">
			<div class="shead">
				<div class="stitle">Suspension</div>
				<div class="mono scap">travel · load</div>
			</div>
			<div class="rig">
				@for ( int i = 0; i < Car.Wheels.Count && i < 4; i++ )
				{
					var w = Car.Wheels[i];
					var bottomed = IsBottomed( w );
					<div class="scol">
						<div class="sbar @(bottomed ? "bottom" : "")">
							<div class="sfill @(bottomed ? "bottom" : "")" style="height: @UiFmt.Pct( LoadFrac( w ) * 100f )%"></div>
						</div>
						<div class="mono slbl @(bottomed ? "bottom" : "")">@WheelLabel( i )@(bottomed ? " · BOTTOM" : "")</div>
						<div class="mono skg">@LoadKg( w ) kg</div>
					</div>
				}
			</div>
		</div>
	}
</root>

@code {
	// Car is set once at Mount and re-pointed on every live car swap by UiRig.Retarget. The panel
	// caches param closures over ONE definition instance (BuildParams captures `def`), so a swap must
	// invalidate that cache — otherwise the panel keeps displaying and EDITING the orphaned old def
	// while the player drives a car built from a fresh definition instance (tester bug: redline dial
	// had no effect after a Tab/menu car switch). The setter drops the cached params + tuning state so
	// the next access rebuilds against the new car's own (untuned) definition.
	VehicleController _car;
	public VehicleController Car
	{
		get => _car;
		set
		{
			if ( ReferenceEquals( _car, value ) )
				return;
			_car = value;
			OnCarChanged();
		}
	}

	void OnCarChanged()
	{
		_params = null;   // force a rebuild of the param closures against the new def
		_carPresets = null;  // presets are per-car — re-scope the list to the active car
		_gripScale = 1f;  // the new car is untuned — grip multiplier resets to 1×
		var def = _car?.Definition;
		if ( def is not null )
		{
			_baseLat = def.LateralCurve;
			_baseLong = def.LongitudinalCurve;
		}
		StateHasChanged();
	}

	static readonly string[] Groups = { "engine", "tires", "susp", "assists" };
	string _tab = "engine";

	class Param
	{
		public string Name;
		public string Group = "engine";
		public string Suffix = "";
		public string Fmt = "F0";
		public float Step, Min, Max;
		public float Baseline;
		public Func<float> Get;
		public Action<float> Set;
	}

	List<Param> _params;
	List<Param> Params => _params ??= BuildParams();
	IEnumerable<Param> ActiveParams => Params.Where( p => p.Group == _tab );

	string CarName => Car?.Definition?.Name ?? "Car";

	TireCurve _baseLat, _baseLong;
	float _gripScale = 1f;

	// Shift points are absolute per-car constants in the definition, but the Redline dial must carry
	// them so lowering redline below ShiftUpRpm doesn't limiter-stick the car (never upshifts). Capture
	// each shift point's fraction of redline when params are (re)built for the car, then scale on Set.
	float _shiftUpFrac = 0.92f, _shiftDownFrac = 0.35f;

	// ---- player tune presets (Save / Load / Delete over the current dial set) ----
	// Presets are per-car and persisted to FileSystem.Data (TunePresetStore), so they survive restart.
	// The list is cached and re-scoped to the active car in OnCarChanged; _presetsRev bumps the
	// BuildHash on any add/delete so the list re-renders even when the car is sitting still (the panel
	// only auto-rebuilds while suspension load changes). Auto-named ("<car> tune N"): a live text field
	// is impractical here because this panel rebuilds every frame off live load and would drop focus.
	IReadOnlyList<TunePreset> _carPresets;
	IReadOnlyList<TunePreset> CarPresets => _carPresets ??= TunePresetStore.ForCar( CarSwitcher.CurrentId( Car ) );
	int _presetsRev;

	void RefreshPresets()
	{
		_carPresets = null;
		_presetsRev++;
		StateHasChanged();
	}

	void SavePreset()
	{
		if ( !Car.IsValid() )
			return;

		var def = Car.Definition;
		string carId = CarSwitcher.CurrentId( Car );
		var preset = new TunePreset
		{
			CarId = carId,
			Name = TunePresetStore.NextName( carId ),

			PeakTorque = def.PeakTorque,
			FinalDrive = def.FinalDrive,
			LaunchBoost = def.LaunchBoost,
			EngineBrakeTorque = def.EngineBrakeTorque,
			RedlineRpm = def.RedlineRpm,
			ShiftUpRpm = def.ShiftUpRpm,     // coupled to redline — stored verbatim
			ShiftDownRpm = def.ShiftDownRpm, // coupled to redline — stored verbatim

			GripScale = _gripScale,          // the multiplier, not the raw curve points
			HandbrakeGripScale = def.HandbrakeGripScale,
			BrakeTorque = def.BrakeTorque,
			BrakeAssist = def.BrakeAssist,
			HandbrakeTorque = def.HandbrakeTorque,

			SpringRate = def.SpringRate,
			DamperRate = def.DamperRate,
			Mass = def.Mass,
			GravityScale = GravityScale(),   // scene property, captured for parity

			SteerRateScale = def.SteerRateScale,
			MaxSteerAngle = def.MaxSteerAngle,
			HighSpeedSteerAngle = def.HighSpeedSteerAngle,
			ReverseSpeedCap = def.ReverseSpeedCap,
			SpinRecoveryAssist = def.SpinRecoveryAssist,
		};

		TunePresetStore.Add( preset );
		RefreshPresets();
	}

	void LoadPreset( TunePreset preset )
	{
		if ( !Car.IsValid() || preset is null )
			return;

		// per-car guard: never apply a preset saved for a different car (defensive — the UI only ever
		// lists the active car's presets, but a stale click or future importable file could mismatch).
		if ( !string.Equals( preset.CarId, CarSwitcher.CurrentId( Car ), StringComparison.OrdinalIgnoreCase ) )
		{
			Log.Warning( $"[vp] tune preset '{preset.Name}' is for '{preset.CarId}', active car is "
				+ $"'{CarSwitcher.CurrentId( Car )}' — not applying" );
			return;
		}

		var def = Car.Definition;

		def.PeakTorque = preset.PeakTorque;
		def.FinalDrive = preset.FinalDrive;
		def.LaunchBoost = preset.LaunchBoost;
		def.EngineBrakeTorque = preset.EngineBrakeTorque;
		// Redline + its coupled shift points: restore all three verbatim (identical to save time), then
		// re-derive the fractions so a later Redline dial edit scales from THIS loaded baseline.
		def.RedlineRpm = preset.RedlineRpm;
		def.ShiftUpRpm = preset.ShiftUpRpm;
		def.ShiftDownRpm = preset.ShiftDownRpm;
		if ( preset.RedlineRpm > 0f )
		{
			_shiftUpFrac = preset.ShiftUpRpm / preset.RedlineRpm;
			_shiftDownFrac = preset.ShiftDownRpm / preset.RedlineRpm;
		}

		def.HandbrakeGripScale = preset.HandbrakeGripScale;
		def.BrakeTorque = preset.BrakeTorque;
		def.BrakeAssist = preset.BrakeAssist;
		def.HandbrakeTorque = preset.HandbrakeTorque;

		def.SpringRate = preset.SpringRate;
		def.DamperRate = preset.DamperRate;
		def.Mass = preset.Mass;

		def.SteerRateScale = preset.SteerRateScale;
		def.MaxSteerAngle = preset.MaxSteerAngle;
		def.HighSpeedSteerAngle = preset.HighSpeedSteerAngle;
		def.ReverseSpeedCap = preset.ReverseSpeedCap;
		def.SpinRecoveryAssist = preset.SpinRecoveryAssist;

		// Grip: re-apply the scale through SetGrip so the tire curves are re-derived from the PRISTINE
		// baseline (_baseLat/_baseLong), never from an already-scaled state. SetGrip also runs ApplyWheels,
		// which pushes the new SpringRate/DamperRate/curves onto the live wheels.
		SetGrip( preset.GripScale );
		// Mass onto the live rigidbody + per-wheel static loads.
		ApplyChassis();
		// Gravity is a scene property — re-apply for parity with the saved state.
		SetGravity( preset.GravityScale );

		// The loaded state is now the reference the dirty markers diff against.
		if ( _params is not null )
			foreach ( var p in _params )
				p.Baseline = p.Get();

		StateHasChanged();
	}

	void DeletePreset( TunePreset preset )
	{
		TunePresetStore.Delete( preset );
		RefreshPresets();
	}

	// Open the rename modal for this preset. The text field lives in a SEPARATE panel
	// (TuneRenameOverlay) precisely because THIS panel rebuilds every frame off live suspension load and
	// would drop a focused TextEntry each frame — see TuneRenameState for the full rationale. The commit
	// mutates the preset in place; our list re-renders via _presetsRev-style BuildHash folding of
	// TuneRenameState.Revision (below) so the new name shows even with the car standing still.
	void BeginRename( TunePreset preset ) => TuneRenameState.Begin( preset );

	void SetTab( string t ) { _tab = t; StateHasChanged(); }

	// "?" header button: open/close the dial-explainer overlay. The overlay is a SEPARATE PanelComponent
	// (TuneHelpOverlay) reading UiState.TuneHelpOpen, so this flag flip re-renders THAT panel on its own
	// constant BuildHash — nothing here rebuilds mid-view. Clicking "?" again toggles it closed.
	void ToggleTuneHelp() => UiState.TuneHelpOpen = !UiState.TuneHelpOpen;

	float Frac( Param p )
	{
		if ( p.Max <= p.Min ) return 0f;
		return Math.Clamp( (p.Get() - p.Min) / (p.Max - p.Min), 0f, 1f );
	}

	bool IsDirty( Param p ) => MathF.Abs( p.Get() - p.Baseline ) > p.Step * 0.25f;
	int DirtyCount => Params.Count( IsDirty );
	string DirtyText => DirtyCount == 0
		? "matches saved definition"
		: DirtyCount == 1 ? "1 dial differs from saved definition"
		: $"{DirtyCount} dials differ from saved definition";

	// ---- suspension rig bindings (live) ----
	static readonly string[] WheelLabels = { "FL", "FR", "RL", "RR" };
	string WheelLabel( int i ) => i < WheelLabels.Length ? WheelLabels[i] : $"W{i}";
	float LoadFrac( VehicleWheel w ) => w.StaticLoad > 0f ? Math.Clamp( w.Load / (w.StaticLoad * 2f), 0f, 1f ) : 0f;
	string LoadKg( VehicleWheel w ) => (w.Load / 9.81f).ToString( "F0" );
	bool IsBottomed( VehicleWheel w ) => w.IsGrounded && w.SuspensionLength <= w.SuspensionTravel * 0.03f;

	protected override void OnUpdate()
	{
		if ( Input.Pressed( "Debug" ) )
		{
			UiState.TuningOpen = !UiState.TuningOpen;
			Mouse.Visibility = UiState.TuningOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
			// The dial-help overlay only makes sense over an open panel — closing Tuning closes it too,
			// so the explainer can't linger on screen with no dials behind it.
			if ( !UiState.TuningOpen )
				UiState.TuneHelpOpen = false;
		}

		// something else (or the engine) may reset cursor state every frame
		if ( UiState.TuningOpen )
			Mouse.Visibility = MouseVisibility.Visible;
	}

	void Adjust( Param p, int clicks )
	{
		float v = Math.Clamp( p.Get() + clicks * p.Step, p.Min, p.Max );
		p.Set( v );
		StateHasChanged();
	}

	// Click-to-jump + drag-to-scrub on the dial track. Mirrors the engine's own SliderControl mechanic
	// (no native drag helper for a hand-rolled RenderFragment). `jump` is true on onmousedown (a bare
	// click lands as jump-to-position) and false on onmousemove (scrub only while the track is actively
	// pressed). Value = mouse LOCAL x over the track width, snapped to the param's Step, pushed through
	// the SAME p.Set path as Adjust so Redline's ScaleShiftPoints, Grip's SetGrip etc. still apply.
	// (KB gotcha g-ui-draggable-slider-tracks-click-jump-drag; verified drag pattern.)
	void TrackScrub( Param p, Sandbox.UI.PanelEvent ev, bool jump )
	{
		if ( ev is not Sandbox.UI.MousePanelEvent e ) return;

		var track = e.This;
		if ( track is null ) return;

		// onmousemove fires whether or not the button is held — only scrub while the track owns the press.
		if ( !jump && !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) ) return;

		float w = track.Box.Rect.Width;
		if ( w <= 0f ) return;

		float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
		float v = p.Min + frac * (p.Max - p.Min);
		if ( p.Step > 0f ) v = MathF.Round( v / p.Step ) * p.Step;   // snap to the param's step (values stay clean)
		v = Math.Clamp( v, p.Min, p.Max );
		p.Set( v );
		StateHasChanged();
	}

	void ResetAll()
	{
		// Reset to the CURRENT car's pristine preset, not always the hatch — otherwise resetting while
		// driving a coupe/kart/pickup wrote hatch physics onto it. Presets are `=> new()` so each access
		// is a fresh untouched instance (StationCarRegistry.ResolveCar via the canonical id table).
		var fresh = VehiclePilot.ResolveCar( CarSwitcher.CurrentId( Car ) );
		var def = Car.Definition;

		def.PeakTorque = fresh.PeakTorque;
		def.LaunchBoost = fresh.LaunchBoost;
		def.FinalDrive = fresh.FinalDrive;
		def.EngineBrakeTorque = fresh.EngineBrakeTorque;
		def.RedlineRpm = fresh.RedlineRpm;
		// Redline dial now carries the shift points (ScaleShiftPoints); restore them too, and re-capture
		// the pristine fractions so a subsequent Redline edit scales from the reset baseline.
		def.ShiftUpRpm = fresh.ShiftUpRpm;
		def.ShiftDownRpm = fresh.ShiftDownRpm;
		if ( fresh.RedlineRpm > 0f )
		{
			_shiftUpFrac = fresh.ShiftUpRpm / fresh.RedlineRpm;
			_shiftDownFrac = fresh.ShiftDownRpm / fresh.RedlineRpm;
		}
		def.SteerRateScale = fresh.SteerRateScale;
		def.MaxSteerAngle = fresh.MaxSteerAngle;
		def.HighSpeedSteerAngle = fresh.HighSpeedSteerAngle;
		def.ReverseSpeedCap = fresh.ReverseSpeedCap;
		def.SpinRecoveryAssist = fresh.SpinRecoveryAssist;
		def.BrakeTorque = fresh.BrakeTorque;
		def.BrakeAssist = fresh.BrakeAssist;
		def.HandbrakeGripScale = fresh.HandbrakeGripScale;
		def.HandbrakeTorque = fresh.HandbrakeTorque;
		def.SpringRate = fresh.SpringRate;
		def.DamperRate = fresh.DamperRate;
		def.Mass = fresh.Mass;
		def.LateralCurve = fresh.LateralCurve;
		def.LongitudinalCurve = fresh.LongitudinalCurve;
		_gripScale = 1f;
		_baseLat = fresh.LateralCurve;
		_baseLong = fresh.LongitudinalCurve;

		ApplyChassis();
		ApplyWheels();
		SetGravity( 1.1f );

		if ( _params is not null )
			foreach ( var p in _params )
				p.Baseline = p.Get();

		StateHasChanged();
	}

	// push def values onto the live rigidbody + wheels (factory copies them at spawn)
	void ApplyChassis()
	{
		var def = Car.Definition;
		var body = Car.Components.Get<Rigidbody>();
		if ( body.IsValid() )
			body.MassOverride = def.Mass;

		foreach ( var w in Car.Wheels )
			w.StaticLoad = def.Mass * 9.81f / 4f;
	}

	void ApplyWheels()
	{
		var def = Car.Definition;
		foreach ( var w in Car.Wheels )
		{
			w.SpringRate = def.SpringRate;
			w.DamperRate = def.DamperRate;
			w.LateralCurve = def.LateralCurve;
			w.LongitudinalCurve = def.LongitudinalCurve;
		}
	}

	static TireCurve Scaled( TireCurve c, float k ) =>
		new( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );

	void SetGrip( float k )
	{
		_gripScale = k;
		var def = Car.Definition;
		def.LateralCurve = Scaled( _baseLat, k );
		def.LongitudinalCurve = Scaled( _baseLong, k );
		ApplyWheels();
	}

	float GravityScale() => Scene.PhysicsWorld.Gravity.Length / (9.81f * Units.MetersToUnits);
	void SetGravity( float k ) => Scene.PhysicsWorld.Gravity = Vector3.Down * 9.81f * k * Units.MetersToUnits;

	// Preserve each shift point's original fraction of redline, and never let ShiftUpRpm reach the
	// limiter (clamp to 95% of redline) or the car can't upshift out of the rev ceiling.
	void ScaleShiftPoints( CarDefinition def, float redline )
	{
		def.ShiftUpRpm = MathF.Min( redline * _shiftUpFrac, redline * 0.95f );
		def.ShiftDownRpm = redline * _shiftDownFrac;
	}

	List<Param> BuildParams()
	{
		var def = Car.Definition;
		_baseLat = def.LateralCurve;
		_baseLong = def.LongitudinalCurve;

		// capture the pristine shift-point fractions for THIS car (def is untouched at build time)
		if ( def.RedlineRpm > 0f )
		{
			_shiftUpFrac = def.ShiftUpRpm / def.RedlineRpm;
			_shiftDownFrac = def.ShiftDownRpm / def.RedlineRpm;
		}

		var list = new List<Param>
		{
			// --- engine ---
			new() { Group = "engine", Name = "Engine torque", Suffix = " Nm", Step = 20f, Min = 100f, Max = 900f,
				Get = () => def.PeakTorque, Set = v => def.PeakTorque = v },
			new() { Group = "engine", Name = "Final drive", Fmt = "F1", Step = 0.1f, Min = 2.5f, Max = 6.5f,
				Get = () => def.FinalDrive, Set = v => def.FinalDrive = v },
			new() { Group = "engine", Name = "Launch boost", Fmt = "F1", Suffix = "×", Step = 0.1f, Min = 1f, Max = 4f,
				Get = () => def.LaunchBoost, Set = v => def.LaunchBoost = v },
			new() { Group = "engine", Name = "Engine brake", Suffix = " Nm", Step = 5f, Min = 0f, Max = 60f,
				Get = () => def.EngineBrakeTorque, Set = v => def.EngineBrakeTorque = v },
			new() { Group = "engine", Name = "Redline", Suffix = " rpm", Step = 100f, Min = 5000f, Max = 9500f,
				Get = () => def.RedlineRpm, Set = v => { def.RedlineRpm = v; ScaleShiftPoints( def, v ); } },

			// --- tires / brakes ---
			new() { Group = "tires", Name = "Grip", Fmt = "F2", Suffix = "×", Step = 0.05f, Min = 0.6f, Max = 2.2f,
				Get = () => _gripScale, Set = SetGrip },
			new() { Group = "tires", Name = "Drift grip (handbrake)", Fmt = "F2", Suffix = "×", Step = 0.05f, Min = 0.15f, Max = 1f,
				Get = () => def.HandbrakeGripScale, Set = v => def.HandbrakeGripScale = v },
			new() { Group = "tires", Name = "Brake torque", Suffix = " Nm", Step = 200f, Min = 1500f, Max = 8000f,
				Get = () => def.BrakeTorque, Set = v => def.BrakeTorque = v },
			new() { Group = "tires", Name = "Brake assist", Fmt = "F1", Suffix = " m/s²", Step = 0.5f, Min = 0f, Max = 12f,
				Get = () => def.BrakeAssist, Set = v => def.BrakeAssist = v },
			new() { Group = "tires", Name = "Handbrake torque", Suffix = " Nm", Step = 250f, Min = 1000f, Max = 9000f,
				Get = () => def.HandbrakeTorque, Set = v => def.HandbrakeTorque = v },

			// --- suspension ---
			new() { Group = "susp", Name = "Spring rate", Suffix = " N/m", Step = 2000f, Min = 15000f, Max = 60000f,
				Get = () => def.SpringRate, Set = v => { def.SpringRate = v; ApplyWheels(); } },
			new() { Group = "susp", Name = "Damper rate", Step = 200f, Min = 800f, Max = 6000f,
				Get = () => def.DamperRate, Set = v => { def.DamperRate = v; ApplyWheels(); } },
			new() { Group = "susp", Name = "Mass", Suffix = " kg", Step = 50f, Min = 400f, Max = 2500f,
				Get = () => def.Mass, Set = v => { def.Mass = v; ApplyChassis(); } },
			new() { Group = "susp", Name = "Gravity", Fmt = "F2", Suffix = " g", Step = 0.05f, Min = 0.6f, Max = 2.2f,
				Get = GravityScale, Set = SetGravity },

			// --- steering / assists ---
			new() { Group = "assists", Name = "Steer speed", Fmt = "F1", Suffix = "×", Step = 0.1f, Min = 0.5f, Max = 3f,
				Get = () => def.SteerRateScale, Set = v => def.SteerRateScale = v },
			new() { Group = "assists", Name = "Steer lock (low speed)", Suffix = "°", Step = 1f, Min = 20f, Max = 45f,
				Get = () => def.MaxSteerAngle, Set = v => def.MaxSteerAngle = v },
			new() { Group = "assists", Name = "Steer lock (high speed)", Suffix = "°", Step = 1f, Min = 6f, Max = 20f,
				Get = () => def.HighSpeedSteerAngle, Set = v => def.HighSpeedSteerAngle = v },
			new() { Group = "assists", Name = "Reverse speed cap", Fmt = "F1", Suffix = " m/s", Step = 0.5f, Min = 3f, Max = 15f,
				Get = () => def.ReverseSpeedCap, Set = v => def.ReverseSpeedCap = v },
			new() { Group = "assists", Name = "Spin recovery", Fmt = "F1", Suffix = " m/s²", Step = 0.5f, Min = 0f, Max = 12f,
				Get = () => def.SpinRecoveryAssist, Set = v => def.SpinRecoveryAssist = v },
		};

		foreach ( var p in list )
			p.Baseline = p.Get();

		return list;
	}

	protected override int BuildHash()
	{
		if ( !UiState.TuningOpen )
			return 0;

		var h = new HashCode();
		h.Add( _tab );
		h.Add( DirtyCount );
		h.Add( _presetsRev ); // re-render the presets list on save/delete even when the car is still
		h.Add( TuneRenameState.Revision ); // ...and on a committed rename (the preset's Name changed in place)
		if ( _params is not null )
			foreach ( var p in _params )
				h.Add( (int)(p.Get() * 100f) );
		if ( Car.IsValid() )
			foreach ( var w in Car.Wheels )
			{
				h.Add( (int)(w.Load * 0.1f) );
				h.Add( (int)(w.SuspensionLength * 1000f) );
			}
		return h.ToHashCode();
	}
}