{"TotalCount":32,"Files":[{"Ident":"fieldguide.vehiclephysics","Path":"Code/Assembly.cs","FileName":"Assembly.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"// Global usings for the Vehicle Physics Kit assembly only. These do NOT export the kit\u0027s\r\n// FieldGuide.VehiclePhysics namespace anywhere \u2014 a consumer references the kit types through\r\n// their real namespace, never through a leaked global using.\r\nglobal using Sandbox;\r\nglobal using System;\r\nglobal using System.Collections.Generic;\r\nglobal using System.Linq;\r\nglobal using System.Threading.Tasks;\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/CarDefinition.cs","FileName":"CarDefinition.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\npublic enum DriveLayout\r\n{\r\n\tFWD,\r\n\tRWD,\r\n\tAWD\r\n}\r\n\r\npublic enum BodyStyle\r\n{\r\n\tBox,  // sedan/hatch blockout: body \u002B cabin\r\n\tKart  // flat deck \u002B seat back \u002B citizen driver\r\n}\r\n\r\npublic enum AssistLevel\r\n{\r\n\tCasual, // full traction control \u002B yaw-stability damping \u002B ABS\r\n\tSport,  // ABS \u002B optional REDUCED-authority TC / yaw-stability (per-car opt-in; drift feel preserved)\r\n\tSim     // nothing\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// Complete tuning spec for one car. Plain class, instantiated from \u003Csee cref=\u0022CarDefinitions\u0022/\u003E;\r\n/// promote to a [GameResource] .car asset once an editor workflow is in play so designers can tune\r\n/// without recompiling. All values SI: meters, kg, N, N-m, N/m.\r\n/// \u003C/summary\u003E\r\npublic class CarDefinition\r\n{\r\n\tpublic string Name { get; set; } = \u0022Car\u0022;\r\n\tpublic BodyStyle Style { get; set; } = BodyStyle.Box;\r\n\tpublic bool HasDriver { get; set; }\r\n\r\n\t/// \u003Csummary\u003EOptional opaque body-manifest path for a consumer-supplied custom body builder\r\n\t/// (see \u003Csee cref=\u0022VehicleFactory.CustomBodyBuilder\u0022/\u003E). The kit itself never reads it \u2014 with no\r\n\t/// custom builder plugged in, the factory builds a primitive blockout body and this value is\r\n\t/// inert. It exists so a consumer\u0027s builder (e.g. a part-kit assembler) can look up which body to\r\n\t/// assemble for this car. null keeps the blockout path byte-for-byte unchanged.\u003C/summary\u003E\r\n\tpublic string BodyManifest { get; set; }\r\n\r\n\t// Chassis\r\n\tpublic float Mass { get; set; } = 1200f;\r\n\tpublic Vector3 BodySize { get; set; } = new( 4.0f, 1.8f, 1.3f ); // length, width, height (m)\r\n\tpublic float Wheelbase { get; set; } = 2.55f;\r\n\tpublic float TrackWidth { get; set; } = 1.55f;\r\n\tpublic float RideHeight { get; set; } = 0.35f; // chassis center above wheel attach plane\r\n\tpublic float GroundClearance { get; set; } = 0.14f; // collider bottom above ground at rest\r\n\tpublic float CenterOfMassDrop { get; set; } = 0.20f; // CoM below chassis center; must stay above wheel plane\r\n\r\n\t// Wheels \u0026 suspension (per wheel)\r\n\tpublic float WheelRadius { get; set; } = 0.31f;\r\n\tpublic float WheelInertia { get; set; } = 1.2f; // kg m^2\r\n\tpublic float SuspensionTravel { get; set; } = 0.20f;\r\n\tpublic float SpringRate { get; set; } = 38000f;\r\n\tpublic float DamperRate { get; set; } = 2800f;\r\n\r\n\t// Tires\r\n\tpublic TireCurve LongitudinalCurve { get; set; } = TireCurve.Street;\r\n\tpublic TireCurve LateralCurve { get; set; } = new( 0.14f, 1.00f, 0.55f, 0.80f ); // radians: peak ~8 deg\r\n\tpublic float LoadSensitivity { get; set; } = 0.06f;\r\n\r\n\t// Drivetrain\r\n\tpublic DriveLayout Layout { get; set; } = DriveLayout.FWD;\r\n\t// \u002B20% speed pass 2026-07-21: baseline PeakTorque 150-\u003E180 and RedlineRpm 6500-\u003E7800 so a bare\r\n\t// kit consumer starts from the new faster baseline (the demo roster below overrides both per car).\r\n\tpublic float PeakTorque { get; set; } = 180f;\r\n\tpublic float IdleRpm { get; set; } = 900f;\r\n\tpublic float RedlineRpm { get; set; } = 7800f;\r\n\tpublic float EngineInertia { get; set; } = 0.25f; // kg m^2 at crank\r\n\tpublic float EngineBrakeTorque { get; set; } = 40f;\r\n\tpublic float[] GearRatios { get; set; } = { 3.6f, 2.1f, 1.4f, 1.05f, 0.85f };\r\n\tpublic float ReverseRatio { get; set; } = 3.4f;\r\n\tpublic float FinalDrive { get; set; } = 3.9f;\r\n\tpublic float ShiftUpRpm { get; set; } = 5800f;\r\n\tpublic float ShiftDownRpm { get; set; } = 2200f;\r\n\r\n\t// Engine audio (read by EngineAudio). Per-class dials so a small buzzy kart and a deep\r\n\t// sedan/truck share one code path:\r\n\t//   EngineSoundEvent     \u2014 the LOW-RPM loop this class plays (idle/low, the deep layer).\r\n\t//   EngineSoundEventHigh \u2014 the HIGH-RPM loop, crossfaded in as revs climb. When null/empty the\r\n\t//     class runs the legacy SINGLE-loop model (one handle, wide pitch sweep) \u2014 the kart keeps\r\n\t//     this so its single synth purr is unchanged. When set, EngineAudio blends the two recorded\r\n\t//     layers by RPM, and each layer only pitch-shifts a narrow band around its recorded pitch\r\n\t//     (no far stretch = no screech), which is the point of the layered model.\r\n\t//   EnginePitchBase      \u2014 multiplies the pitch of whichever model runs, so a class reads higher\r\n\t//     or lower overall (kart \u003E1 stays buzzy, truck \u003C1 sits deep).\r\n\tpublic string EngineSoundEvent { get; set; } = \u0022sounds/engine/engine_real_low.sound\u0022;\r\n\tpublic string EngineSoundEventHigh { get; set; }\r\n\tpublic float EnginePitchBase { get; set; } = 1f;\r\n\r\n\t// Brakes\r\n\tpublic float BrakeTorque { get; set; } = 2400f; // total, split by bias\r\n\tpublic float BrakeBias { get; set; } = 0.62f;   // fraction to front\r\n\tpublic float HandbrakeTorque { get; set; } = 3000f; // rear wheels only\r\n\r\n\t// ABS dials (per-car). 0.3/0.55 because telemetry showed the ABS duty cycle, not tire grip, was\r\n\t// the brake limiter, and per-car ABS modulation is a legitimate class trait: the locking kart\r\n\t// needs a different release than the truck. Active in Casual \u002B Sport.\r\n\tpublic float AbsSlipThreshold { get; set; } = 0.25f; // release when SlipRatio \u003C -this under braking\r\n\tpublic float AbsReleaseFactor { get; set; } = 0.70f; // brake torque multiplier while released\r\n\r\n\t// Drift-exit soft-lock: cap the handbrake-INDUCED rear slip ratio. When a cap is active and a rear\r\n\t// wheel is already sliding PAST it, its handbrake torque is withheld that substep (ABS-style duty\r\n\t// cycle) so the rear spins back up toward the cap and keeps some rotation \u2014 hence some lateral bite\r\n\t// \u2014 mid-slide. Default -1.0 = NO effective cap (byte-identical full-lock behavior).\r\n\tpublic float HandbrakeSlipCap { get; set; } = -1.0f;\r\n\r\n\t// Steering\r\n\tpublic float MaxSteerAngle { get; set; } = 32f; // degrees at standstill\r\n\tpublic float HighSpeedSteerAngle { get; set; } = 8f; // degrees at/above 30 m/s\r\n\r\n\t// Arcade feel dials (defaults = the original sim-leaning behavior)\r\n\tpublic float SteerRateScale { get; set; } = 1f;    // multiplies how fast steering ramps\r\n\t// Reverse speed pass 2026-07-21 (owner feel): raised 4.5 -\u003E 20.0 m/s (~10 -\u003E ~45 mph). This is the\r\n\t// ACTUAL reverse cap mechanism (VehicleController cuts reverse throttle above this speed), and the\r\n\t// game roster inherits it (only the pickup used to override it). Each car is still additionally\r\n\t// limited by its reverse-gear redline-equivalent wheel speed (RedlineRpm/ReverseRatio/FinalDrive/\r\n\t// WheelRadius via Drivetrain.RedlineWheelSpeed), so torquey-but-tall reverse gears self-limit below\r\n\t// this cap (kart ~8 m/s, pickup ~12 m/s) while the cars reach the high-30s/40s mph.\r\n\tpublic float ReverseSpeedCap { get; set; } = 20.0f; // m/s before reverse throttle cuts\r\n\tpublic float LaunchBoost { get; set; } = 1f;       // torque multiplier at standstill, fades out by ~54 km/h\r\n\tpublic float BrakeAssist { get; set; } = 0f;       // extra chassis-level decel while braking (m/s\u00B2)\r\n\t// Spin-recovery assist: after a handbrake spin the car keeps rolling BACKWARDS (old travel\r\n\t// direction) while the player holds throttle the new way \u2014 BrakeAssist can\u0027t help there (forward\r\n\t// throttle sets Brake=0), so nothing arcade-level arrests the stale velocity. This is extra\r\n\t// chassis-level decel along -velocity, applied ONLY when input throttle opposes the ground velocity\r\n\t// along the car\u0027s facing, fading out as the car rotates to face its motion\r\n\t// (VehicleController.ApplySpinRecoveryAssist). Same m/s\u00B2 unit \u002B never-reverse-within-a-step cap as\r\n\t// BrakeAssist; gated Assists != Sim. 0 disables.\r\n\tpublic float SpinRecoveryAssist { get; set; } = 0f; // extra chassis decel killing stale opposing velocity (m/s\u00B2)\r\n\tpublic float HandbrakeGripScale { get; set; } = 1f; // rear grip multiplier while handbrake held (\u003C1 = drift button)\r\n\r\n\t// Wall-glance forgiveness assist: a sustained near-horizontal chassis contact while moving\r\n\t// re-projects velocity along the wall tangent and gently yaws the heading to match, scaled by\r\n\t// incidence (VehicleController.ApplyWallGlanceAssist). Active only when Assists != Sim. Head-on hits\r\n\t// (incidence \u003E= WallGlanceHeadOnDeg) get NO assist \u2014 frontal crashes stay hard stops.\r\n\tpublic bool WallGlanceAssist { get; set; } = true;\r\n\tpublic float WallScrubFactor { get; set; } = 0.75f;    // fraction of speed kept along the wall tangent on a shallow graze\r\n\tpublic float WallGlanceShallowDeg { get; set; } = 35f; // at/below this velocity-vs-wall incidence: full assist\r\n\tpublic float WallGlanceHeadOnDeg { get; set; } = 60f;  // at/above this incidence: no assist (hard stop preserved)\r\n\tpublic float WallAlignStrength { get; set; } = 6f;     // yaw-align rate toward the wall tangent (per second)\r\n\r\n\t// \u2500\u2500 Sport-mode stability posture (owner call 2026-07-21) \u2500\u2500\r\n\t// Sport historically ran with NO traction control and NO yaw-stability damping (both Casual-only),\r\n\t// so a full-throttle RWD car spun the rears to redline and the counter-steer pendulum went divergent\r\n\t// \u2014 uncatchable spin-outs. These give Sport a REDUCED-authority version of each assist, opt-in per\r\n\t// car. Both default to 0 (disabled), so any car that doesn\u0027t set them keeps the raw \u0022ABS only\u0022 Sport\r\n\t// and \u2014 critically \u2014 Casual and Sim stay byte-identical (the controller multiplies Casual authority\r\n\t// by exactly 1 and returns early for Sim, unchanged).\r\n\r\n\t// Sport traction control. When \u003E0, Sport runs the same proportional TC as Casual\r\n\t// (VehicleController.ApplyTractionControl) but holds driven-wheel slip near THIS ratio instead of the\r\n\t// Casual 0.14 grip-peak target. Set it LOOSER than the peak (e.g. 0.30-0.45) so the rears still break\r\n\t// into a throttle-steerable slide (drift stays alive) while the redline free-spin that torches rear\r\n\t// lateral grip is capped. 0 = no Sport TC (raw wheelspin, the old behavior).\r\n\tpublic float SportTcSlipTarget { get; set; } = 0f;\r\n\r\n\t// Sport yaw-stability. When \u003E0, Sport runs the yaw-rate damper (VehicleController.ApplyStabilityAssist)\r\n\t// at THIS fraction of the Casual authority (0-1). It bleeds the stored yaw rate that snaps a\r\n\t// counter-steered slide the OTHER way (the pendulum spin-out) so slides stay CATCHABLE, without\r\n\t// killing deliberate rotation the way full Casual authority would. 0 = no Sport yaw damping.\r\n\tpublic float SportStabilityScale { get; set; } = 0f;\r\n\r\n\t// Driver seated pose (citizen animgraph; sit enum: 0 none, 1-3 chair poses, 4-5 ground poses).\r\n\t// Defaults = the original upright chair pose. Made per-car so a recumbent kart driver \u2014 legs\r\n\t// extended forward to the pedals \u2014 can be authored without disturbing any upright-seated car.\r\n\tpublic int DriverSit { get; set; } = 1;\r\n\tpublic float DriverSitOffsetHeight { get; set; } = 4f;\r\n\r\n\t// Defaults\r\n\tpublic AssistLevel DefaultAssists { get; set; } = AssistLevel.Casual;\r\n\tpublic Color Tint { get; set; } = new( 0.85f, 0.55f, 0.35f );\r\n}\r\n\r\n/// \u003Csummary\u003ECar roster. Hatch is the default/first car; Kart and Coupe round out the roster.\r\n/// These are blockout-body demo definitions \u2014 physics is identical whether a car renders as a\r\n/// primitive blockout or a consumer-supplied custom body.\u003C/summary\u003E\r\npublic static class CarDefinitions\r\n{\r\n\t/// \u003Csummary\u003EThe default/first roster car \u2014 an ORANGE hot hatch. FWD, mid-power, street tires.\u003C/summary\u003E\r\n\tpublic static CarDefinition Hatch =\u003E new()\r\n\t{\r\n\t\tName = \u0022Compact Hatch\u0022,\r\n\t\tMass = 1150f,\r\n\t\tBodySize = new Vector3( 3.9f, 1.75f, 1.45f ),\r\n\t\tWheelbase = 2.55f,\r\n\t\tTrackWidth = 1.50f,\r\n\t\tWheelRadius = 0.30f,\r\n\t\tLayout = DriveLayout.FWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 162-\u003E194.4, RedlineRpm 6300-\u003E7560 (top-gear top speed \u002B20%).\r\n\t\tPeakTorque = 194.4f,\r\n\t\tRedlineRpm = 7560f,\r\n\t\t// Real recorded car set: deep muscle idle crossfaded up to a revving high layer.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_real_low.sound\u0022,\r\n\t\tEngineSoundEventHigh = \u0022sounds/engine/engine_real_high.sound\u0022,\r\n\t\tLongitudinalCurve = new TireCurve( 0.10f, 1.35f, 0.45f, 1.08f ),\r\n\t\tBrakeTorque = 4300f,\r\n\t\tLateralCurve = new TireCurve( 0.14f, 1.30f, 0.55f, 1.04f ),\r\n\t\tHandbrakeGripScale = 0.55f, // the drift button \u2014 rear grip cut while handbrake held; also the FWD J-turn lever\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tHighSpeedSteerAngle = 9.5f,\r\n\t\tSpringRate = 34000f,\r\n\t\tDamperRate = 2500f,\r\n\t\tTint = new Color( 0.93f, 0.42f, 0.03f ), // orange\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EFull-size pickup. Heaviest in roster, RWD, torquey low-rev engine, longer-travel\r\n\t/// softer suspension, offroad-leaning tires, high ride. Signature strength = hill grade.\u003C/summary\u003E\r\n\tpublic static CarDefinition Pickup =\u003E new()\r\n\t{\r\n\t\tName = \u0022Utility Pickup\u0022,\r\n\t\tMass = 1900f,\r\n\t\tBodySize = new Vector3( 5.2f, 1.95f, 1.55f ),\r\n\t\tWheelbase = 3.40f,\r\n\t\tTrackWidth = 1.70f,\r\n\t\tRideHeight = 0.44f,          // high ride: the class signature (hatch 0.35)\r\n\t\tGroundClearance = 0.24f,\r\n\t\tCenterOfMassDrop = 0.15f,    // higher CoM than the cars = truck-like roll; track 1.70 keeps rollover margin\r\n\t\tWheelRadius = 0.35f,\r\n\t\tWheelInertia = 2.4f,\r\n\t\tSuspensionTravel = 0.26f,    // longest in roster \u2014 washboard/offroad strength\r\n\t\tSpringRate = 42000f,\r\n\t\tDamperRate = 3400f,\r\n\t\tLongitudinalCurve = new TireCurve( 0.14f, 1.25f, 0.60f, 1.05f ),\r\n\t\tLateralCurve = new TireCurve( 0.15f, 1.22f, 0.60f, 1.06f ),\r\n\t\tLoadSensitivity = 0.07f,\r\n\t\tLayout = DriveLayout.RWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 320-\u003E384, RedlineRpm 3900-\u003E4680 (top-gear top speed \u002B20%).\r\n\t\tPeakTorque = 384f,           // torquey: \u003E2\u00D7 hatch; strong hills\r\n\t\tIdleRpm = 650f,\r\n\t\tRedlineRpm = 4680f,          // lowest redline in roster; low-rev truck character (was 3900, \u002B20% pass)\r\n\t\tEngineInertia = 0.5f,\r\n\t\tEngineBrakeTorque = 90f,     // strong engine braking downhill\r\n\t\t// Real recorded truck set: deeper truck idle \u002B revving high layer; base pitch dropped.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_real_truck_low.sound\u0022,\r\n\t\tEngineSoundEventHigh = \u0022sounds/engine/engine_real_truck_high.sound\u0022,\r\n\t\tEnginePitchBase = 0.85f,\r\n\t\tGearRatios = new[] { 3.8f, 2.3f, 1.5f, 1.1f, 0.85f },\r\n\t\tReverseRatio = 3.8f,\r\n\t\tFinalDrive = 3.9f,\r\n\t\tShiftUpRpm = 3500f,\r\n\t\tShiftDownRpm = 1700f,\r\n\t\tBrakeTorque = 7000f,\r\n\t\tBrakeBias = 0.65f,           // unladen bed = light rear axle\r\n\t\tHandbrakeTorque = 5000f,\r\n\t\tMaxSteerAngle = 27f,         // slow truck steering; long wheelbase stabilizes\r\n\t\tHighSpeedSteerAngle = 8f,\r\n\t\tSteerRateScale = 0.9f,\r\n\t\t// Reverse cap override removed 2026-07-21: inherits the new 20 m/s default; the pickup\u0027s tall reverse\r\n\t\t// gear \u002B low redline self-limit it to ~12 m/s (~26 mph) reverse, so it stays the roster\u0027s slowest\r\n\t\t// reverse by gearing character rather than an artificial ~9 mph clamp.\r\n\t\tHandbrakeGripScale = 0.45f,  // deepest rear cut in the roster \u2014 1900 kg \u002B 3.4 m wheelbase needs real help\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tSportTcSlipTarget = 0.30f,   // Sport keeps the torquey RWD truck from lighting the rears to redline\r\n\t\tSportStabilityScale = 0.5f,  // half-authority yaw damp so a Sport slide stays catchable\r\n\t\tDefaultAssists = AssistLevel.Casual,\r\n\t\tTint = new Color( 0.55f, 0.13f, 0.11f ), // dark brick red\r\n\t};\r\n\r\n\tpublic static CarDefinition Kart =\u003E new()\r\n\t{\r\n\t\tName = \u0022Go-Kart\u0022,\r\n\t\tStyle = BodyStyle.Kart,\r\n\t\tHasDriver = true,\r\n\t\tMass = 260f,\r\n\t\tBodySize = new Vector3( 1.9f, 1.15f, 0.30f ),\r\n\t\tWheelbase = 1.55f,\r\n\t\tTrackWidth = 1.14f,\r\n\t\tRideHeight = 0.17f,\r\n\t\tGroundClearance = 0.08f,\r\n\t\tCenterOfMassDrop = 0.02f, // tiny chassis: a deep drop puts CoM below the wheels\r\n\t\tWheelRadius = 0.16f,\r\n\t\tWheelInertia = 0.18f,\r\n\t\tSuspensionTravel = 0.14f,\r\n\t\tSpringRate = 24000f,\r\n\t\tDamperRate = 1600f,\r\n\t\tLayout = DriveLayout.RWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 52-\u003E62.4, RedlineRpm 9000-\u003E10800 (top-gear top speed \u002B20%).\r\n\t\tPeakTorque = 62.4f, // punchy launch, still shifts out of wheelspin quickly\r\n\t\tIdleRpm = 1400f,\r\n\t\tRedlineRpm = 10800f,\r\n\t\tEngineInertia = 0.05f,\r\n\t\tEngineBrakeTorque = 8f,\r\n\t\t// The kart keeps the buzzier single loop; base pitch \u003E1 preserves its whine, while the narrowed\r\n\t\t// band tames the redline chipmunk.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_b_sport_purr.sound\u0022,\r\n\t\tEnginePitchBase = 1.2f,\r\n\t\tGearRatios = new[] { 3.4f, 2.4f, 1.8f, 1.4f, 1.1f },\r\n\t\tReverseRatio = 3.4f,\r\n\t\tFinalDrive = 6.3f,\r\n\t\tShiftUpRpm = 8000f,\r\n\t\tShiftDownRpm = 4000f,\r\n\t\tBrakeTorque = 560f,\r\n\t\tHandbrakeTorque = 900f,\r\n\t\tMaxSteerAngle = 31f,\r\n\t\tHighSpeedSteerAngle = 9f,\r\n\t\t// sticky kart slicks\r\n\t\tLongitudinalCurve = new TireCurve( 0.09f, 1.55f, 0.40f, 1.24f ),\r\n\t\tLateralCurve = new TireCurve( 0.12f, 1.66f, 0.45f, 1.32f ),\r\n\t\tAbsSlipThreshold = 0.20f, // earlier release for the lockup-prone kart\r\n\t\tHandbrakeGripScale = 0.70f, // mild rear cut \u2014 the light kart already rotates\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tSportTcSlipTarget = 0.40f,  // loosest Sport TC \u2014 the light kart is meant to be playful\r\n\t\tSportStabilityScale = 0.45f, // gentle yaw damp; the kart rotates easily so keep it light\r\n\t\tHandbrakeSlipCap = -0.7f, // keep the rears rotating mid-slide for a cleaner drift exit\r\n\t\t// Recumbent kart driver pose: reclines with legs extended forward to the pedals.\r\n\t\tDriverSit = 4,\r\n\t\tDriverSitOffsetHeight = 0f,\r\n\t\tDefaultAssists = AssistLevel.Casual, // default fun car: keep it catchable\r\n\t\tTint = new Color( 0.58f, 0.83f, 0.07f ), // acid green\r\n\t};\r\n\r\n\tpublic static CarDefinition Coupe =\u003E new()\r\n\t{\r\n\t\tName = \u0022Sports Coupe\u0022,\r\n\t\tMass = 1420f,\r\n\t\tBodySize = new Vector3( 4.4f, 1.85f, 1.25f ),\r\n\t\tWheelbase = 2.7f,\r\n\t\tTrackWidth = 1.60f,\r\n\t\tLayout = DriveLayout.RWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 340-\u003E408, RedlineRpm 7200-\u003E8640 (top-gear top speed \u002B20%).\r\n\t\t// ShiftUpRpm left at 6600 (unchanged) so the shift ladder holds in ground-speed terms; only the\r\n\t\t// top gear extends to the new redline.\r\n\t\tPeakTorque = 408f,\r\n\t\tRedlineRpm = 8640f,\r\n\t\tShiftUpRpm = 6600f,\r\n\t\t// Real recorded car set (shared with the hatch): deep muscle idle crossfaded up to a\r\n\t\t// revving high layer; base pitch neutral so it sweeps the band cleanly.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_real_low.sound\u0022,\r\n\t\tEngineSoundEventHigh = \u0022sounds/engine/engine_real_high.sound\u0022,\r\n\t\tEnginePitchBase = 1.0f,\r\n\t\tLongitudinalCurve = new TireCurve( 0.09f, 1.50f, 0.40f, 1.20f ),\r\n\t\tLateralCurve = new TireCurve( 0.13f, 1.69f, 0.50f, 1.36f ),\r\n\t\tHandbrakeGripScale = 0.55f, // rears break loose under handbrake (J-turn initiation)\r\n\t\tHandbrakeSlipCap = -0.7f,\r\n\t\tSpringRate = 46000f,\r\n\t\tDamperRate = 3600f,\r\n\t\tBrakeTorque = 6200f,\r\n\t\tWheelRadius = 0.33f,\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tSportTcSlipTarget = 0.35f,   // Sport TC: rears still slide on throttle but can\u0027t free-spin to redline\r\n\t\tSportStabilityScale = 0.5f,  // half-authority yaw damp: the counter-steer pendulum stays catchable\r\n\t\tMaxSteerAngle = 30f,\r\n\t\tHighSpeedSteerAngle = 10f, // sportiest car gets the most high-speed turn-in\r\n\t\tTint = new Color( 0.80f, 0.05f, 0.07f ), // bright signal red\r\n\t};\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/VehicleWheel.cs","FileName":"VehicleWheel.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// One wheel: shapecast ground detection \u002B spring/damper suspension (skeleton pattern from\r\n/// Facepunch/sbox-libwheel, MIT) with the friction model replaced by the spec \u00A75.2.1 slip\r\n/// physics \u2014 wheel angular velocity as integrated state, slip ratio/angle into peaked curves,\r\n/// friction ellipse, load sensitivity, low-speed blend.\r\n///\r\n/// Driven by \u003Csee cref=\u0022VehicleController\u0022/\u003E in substeps; forces are accumulated across\r\n/// substeps and applied once per fixed update (ApplyForceAt applies for the whole step).\r\n/// All internal math in SI.\r\n/// \u003C/summary\u003E\r\npublic sealed class VehicleWheel : Component\r\n{\r\n\t// Configured by VehicleFactory from CarDefinition\r\n\tpublic float Radius { get; set; } = 0.31f;\r\n\tpublic float Inertia { get; set; } = 1.2f;\r\n\tpublic float SuspensionTravel { get; set; } = 0.18f;\r\n\tpublic float SpringRate { get; set; } = 38000f;\r\n\tpublic float DamperRate { get; set; } = 2800f;\r\n\tpublic TireCurve LongitudinalCurve { get; set; }\r\n\tpublic TireCurve LateralCurve { get; set; }\r\n\tpublic float LoadSensitivity { get; set; } = 0.06f;\r\n\tpublic float StaticLoad { get; set; } = 3000f; // N, set by factory: mass\u00B7g/4\r\n\tpublic bool IsSteering { get; set; }\r\n\tpublic bool IsDriven { get; set; }\r\n\tpublic bool HasHandbrake { get; set; }\r\n\tpublic float GripScale { get; set; } = 1f; // live multiplier, e.g. handbrake drift cuts rear grip\r\n\tpublic float ParkBrakeScale { get; set; } = 1f; // anti-jitter stiction strength; controller fades it with throttle\r\n\r\n\t/// \u003Csummary\u003EPer-substep DRIVE-side angular-velocity cap (rad/s), set by the controller each\r\n\t/// substep to the drivetrain\u0027s redline-equivalent wheel speed for the current gear (see\r\n\t/// \u003Csee cref=\u0022Drivetrain.RedlineWheelSpeed\u0022/\u003E). Drive torque can never push the wheel PAST this\r\n\t/// within a substep; the ground (tire reaction) still can, and a pre-existing overspeed is never\r\n\t/// yanked down (no phantom braking). float.MaxValue = no cap (undriven wheels, neutral).\u003C/summary\u003E\r\n\tpublic float DriveOmegaCap { get; set; } = float.MaxValue;\r\n\r\n\t// State\r\n\tpublic float AngularVelocity { get; private set; } // rad/s, \u002Bforward\r\n\tpublic float SteerAngle { get; set; } // degrees, set by controller\r\n\tpublic float SlipRatio { get; private set; }\r\n\tpublic float SlipAngle { get; private set; } // radians\r\n\tpublic float Load { get; private set; } // N\r\n\tpublic bool IsGrounded =\u003E _trace.Hit;\r\n\tpublic float GroundSpeed { get; private set; } // m/s along wheel forward\r\n\tpublic float SuspensionLength { get; private set; } // m, attach point to wheel center, for visuals\r\n\r\n\tpublic string DebugTrace =\u003E !_trace.Hit\r\n\t\t? \u0022miss\u0022\r\n\t\t: $\u0022{_trace.GameObject?.Name ?? \u0022?\u0022}{(_trace.StartedSolid ? \u0022!SOLID\u0022 : \u0022\u0022)} d{_trace.Distance:F1}u n{_trace.Normal.z:F2} sf{(_trace.Surface?.Friction ?? -1f):F2}\u0022;\r\n\r\n\tconst float TraceSphereRadius = 1f * Units.UnitsToMeters; // shapecast sphere, see DoTrace\r\n\r\n\tRigidbody _rigidbody;\r\n\tSceneTraceResult _trace;\r\n\tVector3 _accumulatedForce; // N, world space\r\n\tVector3 _forcePosition;\r\n\tint _substeps;\r\n\tfloat _smoothedSlipAngle;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t_rigidbody = Components.GetInAncestorsOrSelf\u003CRigidbody\u003E();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETrace and reset accumulators. Call once per fixed update, before substeps.\u003C/summary\u003E\r\n\tpublic void BeginStep()\r\n\t{\r\n\t\tDoTrace();\r\n\t\t_accumulatedForce = Vector3.Zero;\r\n\t\t_substeps = 0;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// One physics substep. driveTorque/brakeTorque in N\u00B7m. Accumulates chassis force,\r\n\t/// integrates wheel spin.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void Substep( float dt, float driveTorque, float brakeTorque )\r\n\t{\r\n\t\t_substeps\u002B\u002B;\r\n\r\n\t\tvar up = _rigidbody.WorldRotation.Up;\r\n\r\n\t\t// a grazing contact (car tipped over, wheel scraping a wall) is not suspension:\r\n\t\t// generating spring force there self-propels a flipped car along the ground\r\n\t\tbool validContact = IsGrounded \u0026\u0026 Vector3.Dot( _trace.Normal, up ) \u003E 0.4f;\r\n\r\n\t\tif ( !validContact )\r\n\t\t{\r\n\t\t\tLoad = 0f;\r\n\t\t\tSlipRatio = 0f;\r\n\t\t\tSlipAngle = 0f;\r\n\t\t\tSuspensionLength = SuspensionTravel;\r\n\t\t\t// AIRBORNE DRIVE GATE (ramp lip-cluster fix 2026-07-21, LIVE-UNVERIFIED): a driven wheel\r\n\t\t\t// with no valid contact gets ZERO drive torque - it freewheels (brakes still act). With\r\n\t\t\t// drive torque passed through, an unloaded driven wheel flared to redline-equivalent\r\n\t\t\t// within ~3 ticks of leaving a kicker lip, the engine pinned at 94-98% redline for the\r\n\t\t\t// whole flight (live capture: rpm 6008-6010 at the lip), the drivetrain\u0027s limiter-camp\r\n\t\t\t// escape upshifted MID-AIR ~0.27 s into every full-throttle flight at 20-30 m/s, and the\r\n\t\t\t// car touched down at the flared surface speed: slip \u002B0.4..\u002B0.5, a 10-12 kN (~1 g) tire\r\n\t\t\t// spike for ~2 ticks, the skid chirp, and a landing one gear too tall (post-landing drive\r\n\t\t\t// force -26..-37% vs pre-lip) - the felt \u0022hitch going off the ramps\u0022 at any speed\r\n\t\t\t// (offline quantification: tools/ramp_lip_drivetrain_port.py). Zeroing drive here starves\r\n\t\t\t// that whole cascade: omega holds ~rolling speed, rpm stays steady, the escape shift\r\n\t\t\t// never arms in air, and touchdown slip is ~0. Flat-ground behavior is byte-identical BY\r\n\t\t\t// CONSTRUCTION (this branch never runs with valid ground contact; grounded-tick A/B in\r\n\t\t\t// the port asserts bit-identical trajectories). Registered prediction at commit time: the\r\n\t\t\t// owner\u0027s Sim-mode discriminator will NOT kill the hitch (the cascade is\r\n\t\t\t// assist-independent); result to be recorded next to this comment either way.\r\n\t\t\tIntegrateWheelSpin( dt, 0f, brakeTorque, 0f );\r\n\t\t\t// Airborne wind-down is bearing drag only: 2%/s. The previous 0.5f here was 50%/s\r\n\t\t\t// (documented in round 4 as \u00220.5%/s\u0022, a 100x misread of its own constant): after a\r\n\t\t\t// 1.2 s flight the wheels arrived at ~55% of road speed and braked the car while\r\n\t\t\t// spinning back up (flight recorder 2026-07-21: ~2 m/s of the touchdown loss plus\r\n\t\t\t// the landing skid chirp came from exactly this). Real free wheels barely slow.\r\n\t\t\tAngularVelocity *= 1f - 0.02f * dt;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// --- suspension ---\r\n\t\t// Force acts along the CONTACT NORMAL, not body-up: body-up creates a feedback\r\n\t\t// loop where a tilted car pushes itself sideways, slides, and flips.\r\n\t\t// Compression is clamped to physical travel: bottoming out is a rigid contact\r\n\t\t// for the body collider (bump stop), not unbounded spring force.\r\n\t\tvar normal = _trace.Normal;\r\n\t\tfloat restLength = SuspensionTravel \u002B Radius;\r\n\t\tfloat hitLength = _trace.Distance * Units.UnitsToMeters \u002B TraceSphereRadius; // sphere stops one radius short\r\n\t\tfloat compression = Math.Clamp( restLength - hitLength, 0f, SuspensionTravel );\r\n\t\tSuspensionLength = Math.Clamp( hitLength - Radius, 0f, SuspensionTravel );\r\n\r\n\t\tvar velAtWheel = _rigidbody.GetVelocityAtPoint( WorldPosition ) * Units.UnitsToMeters;\r\n\t\tfloat compressionSpeed = -Vector3.Dot( velAtWheel, normal );\r\n\r\n\t\tfloat springForce = SpringRate * compression \u002B DamperRate * compressionSpeed;\r\n\t\tLoad = Math.Clamp( springForce, 0f, StaticLoad * 4f );\r\n\r\n\t\t_accumulatedForce \u002B= normal * Load;\r\n\t\t_forcePosition = _trace.EndPosition;\r\n\r\n\t\t// --- tire frame on the contact plane ---\r\n\t\tvar steerRot = _rigidbody.WorldRotation * Rotation.FromYaw( SteerAngle );\r\n\t\tvar forward = (steerRot.Forward - normal * Vector3.Dot( steerRot.Forward, normal )).Normal;\r\n\t\tvar side = Vector3.Cross( normal, forward );\r\n\r\n\t\tvar contactVel = _rigidbody.GetVelocityAtPoint( _trace.EndPosition ) * Units.UnitsToMeters;\r\n\t\tfloat vLong = Vector3.Dot( contactVel, forward );\r\n\t\tfloat vLat = Vector3.Dot( contactVel, side );\r\n\t\tGroundSpeed = vLong;\r\n\r\n\t\t// --- slip ---\r\n\t\t// longitudinal uses RAW slip \u002B a one-substep force clamp below (smoothing it added\r\n\t\t// ~100 ms of feedback lag that turned wheel spin into a rhythmic surge);\r\n\t\t// lateral keeps light relaxation, its loop is chassis-side and much slower\r\n\t\tfloat slipVelocity = AngularVelocity * Radius - vLong; // m/s at the contact patch\r\n\t\tSlipRatio = slipVelocity / MathF.Max( MathF.Abs( vLong ), 2.0f );\r\n\r\n\t\tfloat rawSlipAngle = MathF.Atan2( vLat, MathF.Abs( vLong ) \u002B 0.7f );\r\n\t\tfloat relax = Math.Clamp( (MathF.Abs( vLong ) \u002B 1f) * dt / 0.2f, 0.1f, 1f );\r\n\t\t_smoothedSlipAngle \u002B= (rawSlipAngle - _smoothedSlipAngle) * relax;\r\n\t\tSlipAngle = _smoothedSlipAngle;\r\n\r\n\t\t// --- forces from curves, load sensitivity, surface grip ---\r\n\t\tfloat surfaceGrip = _trace.Surface?.Friction ?? 1f;\r\n\t\tfloat loadFactor = 1f - LoadSensitivity * MathF.Max( 0f, Load / StaticLoad - 1f );\r\n\t\tfloat maxForce = Load * loadFactor * surfaceGrip * GripScale;\r\n\r\n\t\tfloat fx = LongitudinalCurve.Evaluate( SlipRatio ) * MathF.Sign( SlipRatio ) * maxForce;\r\n\t\tfloat fy = -LateralCurve.Evaluate( SlipAngle ) * MathF.Sign( SlipAngle ) * maxForce;\r\n\r\n\t\t// one-substep stability clamp: never push the wheel past ground-speed match within\r\n\t\t// a single substep (gain*dt/inertia \u003E 2 here, the raw loop oscillates without this)\r\n\t\tfloat fxStable = MathF.Abs( slipVelocity ) * Inertia / (Radius * Radius * dt);\r\n\t\tfx = Math.Clamp( fx, -fxStable, fxStable );\r\n\r\n\t\t// --- friction ellipse (spec \u00A75.2.1.4) ---\r\n\t\tfloat combined = MathF.Sqrt( fx * fx \u002B fy * fy );\r\n\t\tif ( combined \u003E maxForce \u0026\u0026 combined \u003E 0.001f )\r\n\t\t{\r\n\t\t\tfloat scale = maxForce / combined;\r\n\t\t\tfx *= scale;\r\n\t\t\tfy *= scale;\r\n\t\t}\r\n\r\n\t\t// --- low-speed parking blend: kill standstill jitter ---\r\n\t\t// force is capped at what stops this wheel\u0027s mass share within one fixed frame\r\n\t\t// (spec 5.2.1.6) \u2014 uncapped, it overshoots and becomes a self-sustaining oscillator\r\n\t\tvar planarVel = forward * vLong \u002B side * vLat;\r\n\t\tfloat planarSpeed = planarVel.Length;\r\n\t\tfloat parkOmegaBlend = 0f;\r\n\t\tif ( planarSpeed \u003C 1.5f \u0026\u0026 MathF.Abs( AngularVelocity * Radius ) \u003C 1.5f \u0026\u0026 planarSpeed \u003E 0.001f )\r\n\t\t{\r\n\t\t\t// ParkBrakeScale: throttle dissolves the stiction \u2014 steered fronts were\r\n\t\t\t// \u0022parking\u0022 against full-lock standstill launches (2 km/h crawls, tele 07-07)\r\n\t\t\tfloat blend = (1f - planarSpeed / 1.5f) * ParkBrakeScale;\r\n\t\t\tfloat massShare = StaticLoad / 9.81f; // kg carried by this wheel\r\n\t\t\tfloat frameDt = dt * VehicleController.Substeps;\r\n\t\t\tfloat stopForce = massShare * planarSpeed / frameDt * 0.8f;\r\n\t\t\tfloat parkMag = MathF.Min( MathF.Min( planarSpeed * Load * 1.5f, stopForce ), maxForce );\r\n\r\n\t\t\tvar park = -planarVel / planarSpeed * parkMag;\r\n\t\t\tfx = fx * (1f - blend) \u002B Vector3.Dot( park, forward ) * blend;\r\n\t\t\tfy = fy * (1f - blend) \u002B Vector3.Dot( park, side ) * blend;\r\n\t\t\tparkOmegaBlend = blend;\r\n\t\t}\r\n\r\n\t\t_accumulatedForce \u002B= forward * fx \u002B side * fy;\r\n\r\n\t\tIntegrateWheelSpin( dt, driveTorque, brakeTorque, fx );\r\n\r\n\t\t// Park the wheel\u0027s SPIN, not just the chassis. The parking blend above brakes the chassis, but\r\n\t\t// its reaction torque (-fx*Radius, fed into IntegrateWheelSpin) spins this wheel up and, with\r\n\t\t// nothing anchoring omega at rest, it settles at a nonzero spin that keeps pushing the car: a\r\n\t\t// permanent slow creep with the tyre visibly rotating and the slip ratio pinging the skid\r\n\t\t// threshold (community report: \u0022unless you\u0027ve perfectly stopped, it infinitely skids and rotates\r\n\t\t// the wheels in place\u0022). Pull omega toward the ground-rolling speed vLong/Radius (= 0 at a true\r\n\t\t// standstill) by the SAME blend, so a parked, unbraked wheel settles to zero spin and zero slip.\r\n\t\t// Offline sim: a 0.2 m/s creep that never stopped and skidded 50-100% of frames now settles to\r\n\t\t// under 1 mm/s with zero skid, while cruise (over 1.5 m/s, blend inert) is byte-identical. Fades\r\n\t\t// out with throttle (blend carries ParkBrakeScale) so standstill launches are unaffected.\r\n\t\tif ( parkOmegaBlend \u003E 0f )\r\n\t\t\tAngularVelocity = MathX.Lerp( AngularVelocity, vLong / Radius, parkOmegaBlend );\r\n\t}\r\n\r\n\t// Cap-aware drive-torque rolloff onset (kart cap-camping fix 2026-07-18): drive torque begins\r\n\t// fading toward zero once the wheel\u0027s own spin reaches this fraction of DriveOmegaCap; below it\r\n\t// the rolloff is inert so below-cap behavior is byte-identical.\r\n\tconst float DriveRolloffOnset = 0.90f;\r\n\r\n\tvoid IntegrateWheelSpin( float dt, float driveTorque, float brakeTorque, float tireForce )\r\n\t{\r\n\t\tfloat preOmega = AngularVelocity;\r\n\t\tbool driving = driveTorque != 0f;\r\n\t\tfloat driveDir = driving ? MathF.Sign( driveTorque ) : 0f;\r\n\r\n\t\t// Cap-aware drive-torque rolloff (kart \u0022stuck turning\u0022 fix 2026-07-18). The per-substep\r\n\t\t// clamp below is a hard backstop, but with the clamp ALONE a driven wheel under sustained\r\n\t\t// full torque CAMPS exactly at the cap; when forward speed then collapses in a corner the\r\n\t\t// slip ratio blows far past the grip peak (live: 7\u002B) and the longitudinal tail force eats the\r\n\t\t// friction ellipse, killing rear lateral grip so the yaw holds against countersteer. Fade\r\n\t\t// drive torque to zero as the wheel approaches the cap so it settles OFF the cap instead of\r\n\t\t// camping on it. Smoothstep, not a hard corner, to avoid a torque-fade limit cycle. Inert\r\n\t\t// below the onset (approach \u003C= DriveRolloffOnset) so below-cap behavior is byte-identical.\r\n\t\tif ( driving \u0026\u0026 DriveOmegaCap \u003C float.MaxValue )\r\n\t\t{\r\n\t\t\tfloat approach = driveDir * preOmega / DriveOmegaCap;\r\n\t\t\tif ( approach \u003E DriveRolloffOnset )\r\n\t\t\t{\r\n\t\t\t\tfloat f = Math.Clamp( (1f - approach) / (1f - DriveRolloffOnset), 0f, 1f );\r\n\t\t\t\tdriveTorque *= f * f * (3f - 2f * f);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// drive \u002B tire reaction\r\n\t\tfloat torque = driveTorque - tireForce * Radius;\r\n\t\tAngularVelocity \u002B= torque / Inertia * dt;\r\n\r\n\t\t// Per-substep drive-side overshoot clamp (kart high-PeakTorque wobble hunt 2026-07-18).\r\n\t\t// The rev limiter zeroes torque only on the substep AFTER wheel-implied rpm crosses redline,\r\n\t\t// so one substep of unlimited drive torque on a light wheel overshoots redline-equivalent\r\n\t\t// omega by up to 6-8x (measured live: kart at 900 N-m spikes to 289-339 rad/s vs the 44 rad/s\r\n\t\t// gear-1 redline equivalent; even stock 52 N-m reaches 141). The spike is what lets an\r\n\t\t// unloaded rear diverge violently from its loaded twin over any perturbation (the felt\r\n\t\t// \u0022individual tires have different traction\u0022 left-right wobble). Clamp: DRIVE torque may\r\n\t\t// never push omega past the cap within a substep. Signed by drive direction so reverse works;\r\n\t\t// cap floors at the pre-integration omega so ground-driven overspeed (downhill coast) is\r\n\t\t// never yanked down, and the ground reaction path is untouched. Guarded on the ORIGINAL drive\r\n\t\t// intent so a rolloff that faded torque to zero still cannot let the wheel blow past the cap.\r\n\t\tif ( driving \u0026\u0026 DriveOmegaCap \u003C float.MaxValue )\r\n\t\t{\r\n\t\t\tfloat cap = MathF.Max( DriveOmegaCap, driveDir * preOmega );\r\n\t\t\tAngularVelocity = driveDir \u003E 0f\r\n\t\t\t\t? MathF.Min( AngularVelocity, cap )\r\n\t\t\t\t: MathF.Max( AngularVelocity, -cap );\r\n\t\t}\r\n\r\n\t\t// brakes can stop the wheel but never reverse it within a step\r\n\t\tif ( brakeTorque \u003E 0f )\r\n\t\t{\r\n\t\t\tfloat brakeDelta = brakeTorque / Inertia * dt;\r\n\t\t\tAngularVelocity = MathF.Abs( AngularVelocity ) \u003C= brakeDelta\r\n\t\t\t\t? 0f\r\n\t\t\t\t: AngularVelocity - MathF.Sign( AngularVelocity ) * brakeDelta;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EApply the substep-averaged force to the chassis. Call once per fixed update.\u003C/summary\u003E\r\n\tpublic void EndStep()\r\n\t{\r\n\t\tif ( _substeps == 0 || _accumulatedForce.IsNearZeroLength )\r\n\t\t\treturn;\r\n\r\n\t\tvar averaged = _accumulatedForce / _substeps;\r\n\t\t_rigidbody.ApplyForceAt( _forcePosition, averaged * Units.MetersToUnits );\r\n\t}\r\n\r\n\tvoid DoTrace()\r\n\t{\r\n\t\tvar down = _rigidbody.WorldRotation.Down;\r\n\t\tfloat lengthUnits = (SuspensionTravel \u002B Radius) * Units.MetersToUnits;\r\n\r\n\t\t_trace = Scene.Trace\r\n\t\t\t.Radius( 1f )\r\n\t\t\t.IgnoreGameObjectHierarchy( _rigidbody.GameObject )\r\n\t\t\t.WithoutTags( \u0022car\u0022 )\r\n\t\t\t.FromTo( WorldPosition, WorldPosition \u002B down * lengthUnits )\r\n\t\t\t.Run();\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"DriveInputs.cs","FileName":"DriveInputs.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Per-frame driver intent, the value-struct swap point at VehicleController\u0027s input seam\r\n/// (\u003Csee cref=\u0022VehicleController.InputOverride\u0022/\u003E). Carries the raw intent the controller samples\r\n/// from a device: the move axis (forward/back \u002B steer), the handbrake action, and the sequential\r\n/// shift requests. A keyboard/gamepad/wheel source or a scripted source (a test pilot, an AI) all\r\n/// produce one of these, so the controller\u0027s gear/reverse/steer-ramp logic stays source-agnostic.\r\n///\r\n/// The live-device sampler \u003Csee cref=\u0022SampleDeviceInputs\u0022/\u003E and its gamepad shaping helpers live\r\n/// here (lifted out of the controller): the controller either consumes an injected override or calls\r\n/// this sampler, and nothing else in the class touches device input.\r\n/// \u003C/summary\u003E\r\npublic struct DriveInputs\r\n{\r\n\t/// \u003Csummary\u003E-1..1 signed drive axis: \u002Bforward accelerates, -back brakes then engages reverse near a\r\n\t/// stop. Built in \u003Csee cref=\u0022SampleDeviceInputs\u0022/\u003E as (throttle \u2212 brake) where each channel is the\r\n\t/// MAX of the keyboard/stick component (\u003Cc\u003EInput.AnalogMove.x\u003C/c\u003E) and the ANALOG gamepad trigger\r\n\t/// pull (\u003Cc\u003EInput.GetAnalog(InputAnalog.RightTrigger|LeftTrigger)\u003C/c\u003E, 0..1) \u2014 so a partial trigger\r\n\t/// gives a partial pedal. A scripted source sets this float directly.\u003C/summary\u003E\r\n\tpublic float MoveForward;\r\n\r\n\t/// \u003Csummary\u003E-1..1, maps to \u003Cc\u003EInput.AnalogMove.y\u003C/c\u003E (gamepad path reshaped by the gamepad deadzone\r\n\t/// \u002B response curve). Note \u003Cc\u003ERotation.FromYaw(\u002B)\u003C/c\u003E is a LEFT/CCW turn, so \u002BSteer steers left.\u003C/summary\u003E\r\n\tpublic float Steer;\r\n\r\n\t/// \u003Csummary\u003EThe handbrake / drift button: keyboard \u0022Jump\u0022 (Space), or gamepad A (Jump\u0027s own\r\n\t/// GamepadCode) or the left bumper (\u0022Handbrake\u0022 action).\u003C/summary\u003E\r\n\tpublic bool Handbrake;\r\n\r\n\t/// \u003Csummary\u003EEdge-triggered request to shift UP one gear (sequential MANUAL mode). Keyboard E /\r\n\t/// gamepad R1 while live; a scripted source pulses it for one tick. The controller rising-edge-\r\n\t/// detects it, so a source that holds it across ticks still shifts exactly once.\u003C/summary\u003E\r\n\tpublic bool ShiftUp;\r\n\r\n\t/// \u003Csummary\u003EEdge-triggered request to shift DOWN one gear (sequential MANUAL mode). Keyboard Q /\r\n\t/// gamepad L1. Same one-shot rising-edge semantics as \u003Csee cref=\u0022ShiftUp\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool ShiftDown;\r\n\r\n\t/// \u003Csummary\u003EEdge-triggered request to toggle the transmission mode AUTO\u2194MANUAL. Keyboard G /\r\n\t/// gamepad D-pad down. Same one-shot rising-edge semantics as \u003Csee cref=\u0022ShiftUp\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool ShiftModeToggle;\r\n\r\n\t// Gamepad tier: deadzone \u002B response curve for the analog steer axis.\r\n\tconst float GamepadSteerDeadzone = 0.12f;\r\n\tconst float GamepadSteerCurvePower = 1.6f; // \u003E1 softens the center for fine control, still reaches full lock\r\n\r\n\t// Analog throttle/brake tier: a small trigger deadzone so resting triggers can\u0027t creep the pedals.\r\n\t// The engine ALSO applies its own 12.5% deadzone one layer down (Controller.SetAxis zeroes any\r\n\t// |axis| \u003C= 0.125 before Input.GetAnalog sees it), so values arrive as 0 or \u003E~0.125 and this floor\r\n\t// is a belt-and-suspenders guard that also rescales so full pull still reaches 1.0.\r\n\tconst float GamepadTriggerDeadzone = 0.05f;\r\n\r\n\t/// \u003Csummary\u003ESample the live input devices into a DriveInputs value (this is the keyboard/gamepad\r\n\t/// source; other sources produce the same struct and set \u003Csee cref=\u0022VehicleController.InputOverride\u0022/\u003E).\r\n\t///\r\n\t/// Steering rides \u003Cc\u003EInput.AnalogMove.y\u003C/c\u003E straight off the left stick; keyboard emits exact\r\n\t/// -1/0/1 through the same path and passes through \u003Csee cref=\u0022ApplyGamepadSteerCurve\u0022/\u003E unchanged.\r\n\t///\r\n\t/// Throttle/brake are VARIABLE per device: keyboard W/S ride \u003Cc\u003EInput.AnalogMove.x\u003C/c\u003E as an exact\r\n\t/// \u00B11 digital forward/back; the gamepad triggers are read as a true ANALOG 0..1 pull via\r\n\t/// \u003Cc\u003EInput.GetAnalog(InputAnalog.RightTrigger|LeftTrigger)\u003C/c\u003E (right = gas, left = brake). The two\r\n\t/// devices combine per channel by MAX \u2014 so either device works and neither fights the other \u2014 then\r\n\t/// the net (throttle \u2212 brake) folds into the single signed \u003Csee cref=\u0022MoveForward\u0022/\u003E scalar. On\r\n\t/// keyboard \u003Cc\u003EInput.GetAnalog\u003C/c\u003E returns 0, so keyboard-only players are byte-identical.\u003C/summary\u003E\r\n\tpublic static DriveInputs SampleDeviceInputs()\r\n\t{\r\n\t\tvar move = Input.AnalogMove;\r\n\r\n\t\t// keyboard/stick forward\u002Bback split off the shared move axis (W = \u002Bx, S = -x)\r\n\t\tfloat keyThrottle = MathF.Max( 0f, move.x );\r\n\t\tfloat keyBrake = MathF.Max( 0f, -move.x );\r\n\r\n\t\t// gamepad triggers, true analog 0..1 (right = gas, left = brake)\r\n\t\tfloat triggerThrottle = ReadTrigger( InputAnalog.RightTrigger );\r\n\t\tfloat triggerBrake = ReadTrigger( InputAnalog.LeftTrigger );\r\n\r\n\t\t// MAX blend per channel so either device drives the pedal, neither overrides the other\r\n\t\tfloat throttle = MathF.Max( keyThrottle, triggerThrottle );\r\n\t\tfloat brake = MathF.Max( keyBrake, triggerBrake );\r\n\t\tfloat moveForward = Math.Clamp( throttle - brake, -1f, 1f );\r\n\r\n\t\treturn new DriveInputs\r\n\t\t{\r\n\t\t\tMoveForward = moveForward,\r\n\t\t\tSteer = ApplyGamepadSteerCurve( move.y ),\r\n\t\t\tHandbrake = Input.Down( \u0022Jump\u0022 ) || Input.Down( \u0022Handbrake\u0022 ),\r\n\t\t};\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERead a gamepad trigger as a linear 0..1 pull with a small deadzone floor (rescaled so\r\n\t/// full pull still reaches 1.0). \u003Cc\u003EInput.GetAnalog\u003C/c\u003E already returns 0 for a trigger on keyboard,\r\n\t/// so this only shapes the gamepad path.\u003C/summary\u003E\r\n\tstatic float ReadTrigger( InputAnalog trigger )\r\n\t{\r\n\t\tfloat v = Math.Clamp( Input.GetAnalog( trigger ), 0f, 1f );\r\n\t\tif ( v \u003C GamepadTriggerDeadzone )\r\n\t\t\treturn 0f;\r\n\t\treturn (v - GamepadTriggerDeadzone) / (1f - GamepadTriggerDeadzone);\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EDeadzone \u002B power curve for the analog steer axis. Values under the deadzone snap to 0;\r\n\t/// the remaining range is rescaled so full stick deflection still reaches \u00B11 (no lost lock), then\r\n\t/// raised to \u003Csee cref=\u0022GamepadSteerCurvePower\u0022/\u003E for a softer center. Keyboard\u0027s exact -1/0/1\r\n\t/// passes through unaffected (0 is inside the deadzone; 1 rescales to 1 and 1^n == 1).\u003C/summary\u003E\r\n\tstatic float ApplyGamepadSteerCurve( float raw )\r\n\t{\r\n\t\tfloat mag = MathF.Abs( raw );\r\n\t\tif ( mag \u003C GamepadSteerDeadzone )\r\n\t\t\treturn 0f;\r\n\r\n\t\tfloat t = Math.Clamp( (mag - GamepadSteerDeadzone) / (1f - GamepadSteerDeadzone), 0f, 1f );\r\n\t\treturn MathF.Sign( raw ) * MathF.Pow( t, GamepadSteerCurvePower );\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/Demo/DemoTuningPanel.razor","FileName":"DemoTuningPanel.razor","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"@namespace FieldGuide.VehiclePhysics\r\n@inherits PanelComponent\r\n\r\n@* Kit-native live tuning lab (demo layer). Toggled by the Tune action, bound to the car the chase\r\n   camera is following. Writes tuning changes straight onto the running car through the same paths a\r\n   consumer would use: it mutates the active CarDefinition (read live by the drivetrain and the brake\r\n   model) and pushes suspension/tire values onto the live wheels. This is a demo-scale lab, not the\r\n   full game panel: it exposes the highest-feel dials so the demo reads as a physics lab in the first\r\n   minute. Replace it with your own UI; it lives only in the demo scene. *@\r\n\r\n\u003Croot\u003E\r\n\t@if ( Car.IsValid() )\r\n\t{\r\n\t\t@* Key legend, top-right: how to hop between the demo cars. Always visible; the tuning\r\n\t\t   chip/panel top-left covers the T binding. *@\r\n\t\t\u003Cdiv class=\u0022legend\u0022\u003E\r\n\t\t\t\u003Cdiv class=\u0022mono kbd\u0022\u003E[ ]\u003C/div\u003E\r\n\t\t\t\u003Cdiv class=\u0022title\u0022\u003Eswitch car\u003C/div\u003E\r\n\t\t\u003C/div\u003E\r\n\t}\r\n\t@if ( !IsOpen \u0026\u0026 Car.IsValid() )\r\n\t{\r\n\t\t@* Collapsed legend chip: sits exactly where the expanded panel\u0027s top-left corner lands, so\r\n\t\t   pressing T reads as the chip expanding into the lab. Mouse-look stays with the camera. *@\r\n\t\t\u003Cdiv class=\u0022panel chip\u0022 onclick=@ToggleOpen\u003E\r\n\t\t\t\u003Cdiv class=\u0022head\u0022\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022mono kbd\u0022\u003E@ToggleKeyLabel\u003C/div\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022title\u0022\u003Etuning\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\t\t\u003C/div\u003E\r\n\t}\r\n\t@if ( IsOpen \u0026\u0026 Car.IsValid() )\r\n\t{\r\n\t\t\u003Cdiv class=\u0022panel\u0022\u003E\r\n\t\t\t\u003Cdiv class=\u0022head\u0022\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022title\u0022\u003ETuning lab \u00B7 @CarName\u003C/div\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022mono kbd\u0022\u003E@ToggleKeyLabel\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\r\n\t\t\t\u003Cdiv class=\u0022hint\u0022\u003ETuning the car you are driving. Changes apply live.\u003C/div\u003E\r\n\r\n\t\t\t\u003Cdiv class=\u0022dials\u0022\u003E\r\n\t\t\t\t@foreach ( var d in SliderDials )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar dd = d;\r\n\t\t\t\t\t\u003Cdiv class=\u0022dial\u0022\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022drow\u0022\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022dname\u0022\u003E@d.Name\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022mono dval\u0022\u003E@d.Display()\u003C/div\u003E\r\n\t\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022dctl\u0022\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022mono step\u0022 onclick=@( () =\u003E Step( dd, -1 ) )\u003E-\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022track\u0022\r\n\t\t\t\t\t\t\t\tonmousedown=@( e =\u003E Scrub( dd, e, true ) )\r\n\t\t\t\t\t\t\t\tonmousemove=@( e =\u003E Scrub( dd, e, false ) )\u003E\r\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022fill\u0022 style=\u0022width: @WidthPct( dd )%\u0022\u003E\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022mono step\u0022 onclick=@( () =\u003E Step( dd, \u002B1 ) )\u003E\u002B\u003C/div\u003E\r\n\t\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t}\r\n\r\n\t\t\t\t\u003Cdiv class=\u0022dial\u0022\u003E\r\n\t\t\t\t\t\u003Cdiv class=\u0022drow\u0022\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022dname\u0022\u003EAssists\u003C/div\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022mono cycle\u0022 onclick=@CycleAssists\u003E@AssistLabel\u003C/div\u003E\r\n\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\u003C/div\u003E\r\n\r\n\t\t\t\t\u003Cdiv class=\u0022dial\u0022\u003E\r\n\t\t\t\t\t\u003Cdiv class=\u0022drow\u0022\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022dname\u0022\u003ETires\u003C/div\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022mono cycle\u0022 onclick=@CycleTires\u003E@TireLabel\u003C/div\u003E\r\n\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\r\n\t\t\t\u003Cdiv class=\u0022foot\u0022\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022btn\u0022 onclick=@ResetToStock\u003EReset to stock\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\t\t\u003C/div\u003E\r\n\t}\r\n\u003C/root\u003E\r\n\r\n@code {\r\n\t/// \u003Csummary\u003EThe car this panel tunes: the one the chase camera follows. Set by DemoBootstrap. On\r\n\t/// change the panel snapshots the car\u0027s pristine (authored) values so Reset-to-stock can restore\r\n\t/// them and the grip/torque multipliers rebase to the new car.\u003C/summary\u003E\r\n\tVehicleController _car;\r\n\tpublic VehicleController Car\r\n\t{\r\n\t\tget =\u003E _car;\r\n\t\tset\r\n\t\t{\r\n\t\t\tif ( ReferenceEquals( _car, value ) )\r\n\t\t\t\treturn;\r\n\t\t\t_car = value;\r\n\t\t\tSnapshot();\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EOpen state. Static so the kit\u0027s chase camera can read it through the\r\n\t/// \u003Csee cref=\u0022VehicleCamera.CursorModalOpen\u0022/\u003E seam (DemoBootstrap wires that) without holding a\r\n\t/// reference to this panel. The demo runs exactly one panel, so a single flag is enough.\u003C/summary\u003E\r\n\tpublic static bool IsOpen;\r\n\r\n\t// Toggle input action. Documented in the README input table; the host ProjectSettings/Input.config\r\n\t// ships it. Never Escape or an F-key.\r\n\tconst string ToggleAction = \u0022Tune\u0022;\r\n\tstring ToggleKeyLabel =\u003E \u0022T\u0022;\r\n\r\n\tstring CarName =\u003E Car?.Definition?.Name ?? \u0022Car\u0022;\r\n\r\n\t// Live multipliers over the pristine base. Grip scales the (preset-selected) tire curves; torque\r\n\t// scales the authored peak engine torque.\r\n\tfloat _gripScale = 1f;\r\n\tfloat _torqueScale = 1f;\r\n\tint _tirePreset; // 0 Stock, 1 Street, 2 Sport, 3 Offroad\r\n\r\n\t// Pristine snapshot, captured value-by-value when the car is bound (its definition is untouched at\r\n\t// that point). Value types only (floats, TireCurve struct, enum), so later live tuning of the\r\n\t// definition can never corrupt these; Reset restores from here.\r\n\tbool _snapped;\r\n\tfloat _stockPeakTorque, _stockSpring, _stockDamper, _stockTravel, _stockBrake;\r\n\tTireCurve _stockLat, _stockLong;\r\n\tAssistLevel _stockAssists;\r\n\r\n\t// Current UNSCALED tire base (Stock or a named preset). Grip multiplies these to get the live curves.\r\n\tTireCurve _baseLat, _baseLong;\r\n\r\n\tvoid Snapshot()\r\n\t{\r\n\t\tvar def = _car?.Definition;\r\n\t\t_dials = null;\r\n\t\tif ( def is null )\r\n\t\t{\r\n\t\t\t_snapped = false;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_stockPeakTorque = def.PeakTorque;\r\n\t\t_stockSpring = def.SpringRate;\r\n\t\t_stockDamper = def.DamperRate;\r\n\t\t_stockTravel = def.SuspensionTravel;\r\n\t\t_stockBrake = def.BrakeTorque;\r\n\t\t_stockLat = def.LateralCurve;\r\n\t\t_stockLong = def.LongitudinalCurve;\r\n\t\t// Stock assist = the authored definition default. Read it off the definition, not the\r\n\t\t// controller: the controller adopts DefaultAssists in its own OnStart, which may run a frame\r\n\t\t// or two after this bind, so its live Assists is not reliable yet.\r\n\t\t_stockAssists = def.DefaultAssists;\r\n\r\n\t\t_baseLat = _stockLat;\r\n\t\t_baseLong = _stockLong;\r\n\t\t_gripScale = 1f;\r\n\t\t_torqueScale = 1f;\r\n\t\t_tirePreset = 0;\r\n\t\t_snapped = true;\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\t// ---- dial model ----\r\n\tclass Dial\r\n\t{\r\n\t\tpublic string Name;\r\n\t\tpublic float Min, Max, Step;\r\n\t\tpublic Func\u003Cfloat\u003E Get;\r\n\t\tpublic Action\u003Cfloat\u003E Set;\r\n\t\tpublic Func\u003Cstring\u003E Fmt;\r\n\t\tpublic string Display() =\u003E Fmt();\r\n\t}\r\n\r\n\tList\u003CDial\u003E _dials;\r\n\tList\u003CDial\u003E SliderDials =\u003E _dials ??= BuildDials();\r\n\r\n\tList\u003CDial\u003E BuildDials()\r\n\t{\r\n\t\tif ( !Car.IsValid() )\r\n\t\t\treturn new List\u003CDial\u003E();\r\n\r\n\t\tvar def = Car.Definition;\r\n\t\treturn new List\u003CDial\u003E\r\n\t\t{\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Grip\u0022, Min = 0.6f, Max = 2.2f, Step = 0.05f,\r\n\t\t\t\tGet = () =\u003E _gripScale, Set = SetGrip,\r\n\t\t\t\tFmt = () =\u003E _gripScale.ToString( \u00220.00\u0022 ) \u002B \u0022x\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Drive torque\u0022, Min = 0.5f, Max = 2.0f, Step = 0.05f,\r\n\t\t\t\tGet = () =\u003E _torqueScale, Set = SetTorqueScale,\r\n\t\t\t\tFmt = () =\u003E _torqueScale.ToString( \u00220.00\u0022 ) \u002B \u0022x\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Suspension stiffness\u0022, Min = 15000f, Max = 60000f, Step = 2000f,\r\n\t\t\t\tGet = () =\u003E def.SpringRate, Set = v =\u003E { def.SpringRate = v; ApplyWheels(); },\r\n\t\t\t\tFmt = () =\u003E def.SpringRate.ToString( \u00220\u0022 ) \u002B \u0022 N/m\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Suspension damping\u0022, Min = 800f, Max = 6000f, Step = 200f,\r\n\t\t\t\tGet = () =\u003E def.DamperRate, Set = v =\u003E { def.DamperRate = v; ApplyWheels(); },\r\n\t\t\t\tFmt = () =\u003E def.DamperRate.ToString( \u00220\u0022 ) \u002B \u0022 Ns/m\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Suspension travel\u0022, Min = 0.10f, Max = 0.35f, Step = 0.01f,\r\n\t\t\t\tGet = () =\u003E def.SuspensionTravel, Set = v =\u003E { def.SuspensionTravel = v; ApplyWheels(); },\r\n\t\t\t\tFmt = () =\u003E (def.SuspensionTravel * 100f).ToString( \u00220\u0022 ) \u002B \u0022 cm\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Brake force\u0022, Min = 1500f, Max = 8000f, Step = 200f,\r\n\t\t\t\tGet = () =\u003E def.BrakeTorque, Set = v =\u003E def.BrakeTorque = v,\r\n\t\t\t\tFmt = () =\u003E def.BrakeTorque.ToString( \u00220\u0022 ) \u002B \u0022 Nm\u0022,\r\n\t\t\t},\r\n\t\t};\r\n\t}\r\n\r\n\tfloat Frac( Dial d )\r\n\t{\r\n\t\tif ( d.Max \u003C= d.Min )\r\n\t\t\treturn 0f;\r\n\t\treturn Math.Clamp( (d.Get() - d.Min) / (d.Max - d.Min), 0f, 1f );\r\n\t}\r\n\r\n\tstring WidthPct( Dial d ) =\u003E\r\n\t\t(Frac( d ) * 100f).ToString( \u00220.#\u0022, System.Globalization.CultureInfo.InvariantCulture );\r\n\r\n\tvoid Step( Dial d, int clicks )\r\n\t{\r\n\t\tfloat v = Math.Clamp( d.Get() \u002B clicks * d.Step, d.Min, d.Max );\r\n\t\td.Set( v );\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\t// Click-to-jump \u002B drag-to-scrub on the dial track (mirrors the game panel\u0027s proven pattern). jump\r\n\t// is true on mousedown (a bare click sets the value at the cursor) and false on mousemove (scrub\r\n\t// only while the track owns the press). Value = mouse local x over track width, snapped to the\r\n\t// dial\u0027s step, pushed through the SAME Set path as the \u002B/- steps.\r\n\tvoid Scrub( Dial d, Sandbox.UI.PanelEvent ev, bool jump )\r\n\t{\r\n\t\tif ( ev is not Sandbox.UI.MousePanelEvent e )\r\n\t\t\treturn;\r\n\r\n\t\tvar track = e.This;\r\n\t\tif ( track is null )\r\n\t\t\treturn;\r\n\r\n\t\t// mousemove fires whether or not the button is held; only scrub while the track owns the press.\r\n\t\tif ( !jump \u0026\u0026 !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) )\r\n\t\t\treturn;\r\n\r\n\t\tfloat w = track.Box.Rect.Width;\r\n\t\tif ( w \u003C= 0f )\r\n\t\t\treturn;\r\n\r\n\t\tfloat frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );\r\n\t\tfloat v = d.Min \u002B frac * (d.Max - d.Min);\r\n\t\tif ( d.Step \u003E 0f )\r\n\t\t\tv = MathF.Round( v / d.Step ) * d.Step;\r\n\t\tv = Math.Clamp( v, d.Min, d.Max );\r\n\t\td.Set( v );\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\t// ---- apply paths (same seams a consumer would use) ----\r\n\tstatic TireCurve Scaled( TireCurve c, float k ) =\u003E\r\n\t\tnew( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );\r\n\r\n\t// Push the definition\u0027s suspension \u002B tire values onto the live wheels. The factory copies these at\r\n\t// spawn; the wheel re-reads them every substep, so writing them here is the live-apply path.\r\n\tvoid ApplyWheels()\r\n\t{\r\n\t\tvar def = Car.Definition;\r\n\t\tforeach ( var w in Car.Wheels )\r\n\t\t{\r\n\t\t\tw.SpringRate = def.SpringRate;\r\n\t\t\tw.DamperRate = def.DamperRate;\r\n\t\t\tw.SuspensionTravel = def.SuspensionTravel;\r\n\t\t\tw.LateralCurve = def.LateralCurve;\r\n\t\t\tw.LongitudinalCurve = def.LongitudinalCurve;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid SetGrip( float k )\r\n\t{\r\n\t\t_gripScale = k;\r\n\t\tvar def = Car.Definition;\r\n\t\tdef.LateralCurve = Scaled( _baseLat, k );\r\n\t\tdef.LongitudinalCurve = Scaled( _baseLong, k );\r\n\t\tApplyWheels();\r\n\t}\r\n\r\n\t// Drive torque scale multiplies the authored peak torque. Drivetrain reads def.PeakTorque live\r\n\t// (it holds the same definition instance), so no drivetrain touch is needed.\r\n\tvoid SetTorqueScale( float k )\r\n\t{\r\n\t\t_torqueScale = k;\r\n\t\tCar.Definition.PeakTorque = _stockPeakTorque * k;\r\n\t}\r\n\r\n\tvoid CycleAssists()\r\n\t{\r\n\t\tCar.Assists = Car.Assists switch\r\n\t\t{\r\n\t\t\tAssistLevel.Casual =\u003E AssistLevel.Sport,\r\n\t\t\tAssistLevel.Sport =\u003E AssistLevel.Sim,\r\n\t\t\t_ =\u003E AssistLevel.Casual,\r\n\t\t};\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tstring AssistLabel =\u003E Car.IsValid() ? Car.Assists.ToString() : \u0022\u0022;\r\n\r\n\t// Tire preset swaps the UNSCALED base curves, then re-applies the current grip multiplier so the\r\n\t// grip dial and the preset compose. Stock restores the car\u0027s own authored curves.\r\n\tvoid CycleTires()\r\n\t{\r\n\t\t_tirePreset = (_tirePreset \u002B 1) % 4;\r\n\t\t(_baseLat, _baseLong) = _tirePreset switch\r\n\t\t{\r\n\t\t\t1 =\u003E (TireCurve.Street, TireCurve.Street),\r\n\t\t\t2 =\u003E (TireCurve.Sport, TireCurve.Sport),\r\n\t\t\t3 =\u003E (TireCurve.Offroad, TireCurve.Offroad),\r\n\t\t\t_ =\u003E (_stockLat, _stockLong),\r\n\t\t};\r\n\t\tSetGrip( _gripScale );\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tstring TireLabel =\u003E _tirePreset switch\r\n\t{\r\n\t\t1 =\u003E \u0022Street\u0022,\r\n\t\t2 =\u003E \u0022Sport\u0022,\r\n\t\t3 =\u003E \u0022Offroad\u0022,\r\n\t\t_ =\u003E \u0022Stock\u0022,\r\n\t};\r\n\r\n\t// Re-apply the car\u0027s pristine authored values (captured at bind). Definitions in the demo roster\r\n\t// are fresh per car, so \u0022stock\u0022 is unambiguous: the values this car spawned with.\r\n\tvoid ResetToStock()\r\n\t{\r\n\t\tif ( !Car.IsValid() || !_snapped )\r\n\t\t\treturn;\r\n\r\n\t\tvar def = Car.Definition;\r\n\t\tdef.PeakTorque = _stockPeakTorque;\r\n\t\tdef.SpringRate = _stockSpring;\r\n\t\tdef.DamperRate = _stockDamper;\r\n\t\tdef.SuspensionTravel = _stockTravel;\r\n\t\tdef.BrakeTorque = _stockBrake;\r\n\t\tdef.LateralCurve = _stockLat;\r\n\t\tdef.LongitudinalCurve = _stockLong;\r\n\r\n\t\t_gripScale = 1f;\r\n\t\t_torqueScale = 1f;\r\n\t\t_tirePreset = 0;\r\n\t\t_baseLat = _stockLat;\r\n\t\t_baseLong = _stockLong;\r\n\r\n\t\tCar.Assists = _stockAssists;\r\n\t\tApplyWheels();\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t// Start COLLAPSED: the chip legend keeps the keybind discoverable while mouse-look stays\r\n\t\t// with the camera (an open lab on spawn captured the cursor before players ever drove,\r\n\t\t// owner call 2026-07-19). Static flag, so reset it here each session.\r\n\t\tIsOpen = false;\r\n\t}\r\n\r\n\tvoid ToggleOpen()\r\n\t{\r\n\t\tIsOpen = !IsOpen;\r\n\t\tMouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Input.Pressed( ToggleAction ) )\r\n\t\t{\r\n\t\t\tIsOpen = !IsOpen;\r\n\t\t\tMouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;\r\n\t\t\tStateHasChanged();\r\n\t\t}\r\n\r\n\t\t// The chase camera re-hides the cursor every frame while it owns it; hold it visible while open.\r\n\t\tif ( IsOpen )\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible;\r\n\t}\r\n\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\t// Closed still renders the chip legend, and the chip waits on the car binding, so the\r\n\t\t// hash must move when the car arrives or the first build would stick on the empty tree.\r\n\t\tif ( !IsOpen )\r\n\t\t\treturn Car.IsValid() ? 1 : 0;\r\n\r\n\t\tvar h = new HashCode();\r\n\t\th.Add( _gripScale );\r\n\t\th.Add( _torqueScale );\r\n\t\th.Add( _tirePreset );\r\n\t\tif ( Car.IsValid() )\r\n\t\t{\r\n\t\t\tvar def = Car.Definition;\r\n\t\t\th.Add( def.SpringRate );\r\n\t\t\th.Add( def.DamperRate );\r\n\t\t\th.Add( def.SuspensionTravel );\r\n\t\t\th.Add( def.BrakeTorque );\r\n\t\t\th.Add( (int)Car.Assists );\r\n\t\t}\r\n\t\treturn h.ToHashCode();\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"VehicleController.cs","FileName":"VehicleController.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Drives one car: input \u2192 assists \u2192 drivetrain \u2192 wheel substeps.\r\n/// Runs 4 internal substeps per fixed update. PRECISION NOTE: this is drivetrain/wheel-STATE\r\n/// substepping, not a full 200 Hz contact simulation \u2014 the ground trace happens once per fixed\r\n/// update, the rigidbody does not advance between substeps (velocity-at-contact is re-sampled\r\n/// against the same body state), and the accumulated tire force is applied once, averaged. What the\r\n/// substeps genuinely refine: wheel angular velocity integration (slip-ratio stability at 4\u00D7 the\r\n/// rate), clutch/RPM coupling, and the TC feedback loop. Contact/chassis transients (washboard, curb\r\n/// strikes, landings) resolve at 50 Hz. Owner-simulated; proxies early-out.\r\n/// \u003C/summary\u003E\r\npublic sealed class VehicleController : Component, Component.ICollisionListener\r\n{\r\n\tpublic const int Substeps = 4;\r\n\r\n\tpublic CarDefinition Definition { get; set; }\r\n\tpublic List\u003CVehicleWheel\u003E Wheels { get; } = new();\r\n\tpublic Drivetrain Drivetrain { get; private set; }\r\n\r\n\t[Property] public AssistLevel Assists { get; set; } = AssistLevel.Casual;\r\n\r\n\t/// \u003Csummary\u003EOptional assist level a spawn path wants this car to adopt on start, instead of the\r\n\t/// definition default. Set by a spawn path that carries a chosen assist level across a respawn or\r\n\t/// car swap. It exists because \u003Csee cref=\u0022OnStart\u0022/\u003E runs a frame or two AFTER \u003Cc\u003EComponents.Create\u003C/c\u003E\r\n\t/// \u2014 i.e. after the spawning code has already run \u2014 so a plain post-spawn \u003Cc\u003EAssists = x\u003C/c\u003E is\r\n\t/// overwritten by the default-init in \u003Csee cref=\u0022OnStart\u0022/\u003E. When non-null, OnStart adopts THIS\r\n\t/// value instead; null keeps the definition default. Harmless regardless of the exact create/start\r\n\t/// ordering: if OnStart happened to run first, the spawn path\u0027s direct \u003Cc\u003EAssists\u003C/c\u003E set still wins.\u003C/summary\u003E\r\n\tpublic AssistLevel? InitialAssists { get; set; }\r\n\r\n\tpublic float Throttle { get; private set; }\r\n\tpublic float Brake { get; private set; }\r\n\tpublic float Steer { get; private set; } // -1..1\r\n\tpublic bool Handbrake { get; private set; }\r\n\tpublic float SpeedMs =\u003E _rigidbody.IsValid() ? (_rigidbody.Velocity * Units.UnitsToMeters).Length : 0f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Signed longitudinal speed (m/s) for a HUD speedo: the magnitude equals \u003Csee cref=\u0022SpeedMs\u0022/\u003E\r\n\t/// (forward reads exactly as before), and the sign is the car\u0027s travel direction along its own\r\n\t/// facing \u2014 NEGATIVE while rolling backwards. The sign source is the SAME forward-axis projection\r\n\t/// \u003Csee cref=\u0022ApplySpinRecoveryAssist\u0022/\u003E and gear-engage already use\r\n\t/// (velocity \u00B7 \u003Csee cref=\u0022Sandbox.GameObject.WorldRotation\u0022/\u003E.Forward). DISPLAY read only \u2014 no\r\n\t/// physics or telemetry consumes it.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float SignedSpeedMs\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( !_rigidbody.IsValid() )\r\n\t\t\t\treturn 0f;\r\n\r\n\t\t\tfloat forward = Vector3.Dot( _rigidbody.Velocity, WorldRotation.Forward );\r\n\t\t\treturn forward \u003C 0f ? -SpeedMs : SpeedMs;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Input-source seam (the \u003Csee cref=\u0022DriveInputs\u0022/\u003E pluggable-source abstraction \u2014\r\n\t/// keyboard/gamepad/wheel/scripted pilot as peers at ONE seam). When non-null this value-struct is\r\n\t/// consumed by \u003Csee cref=\u0022ReadInput\u0022/\u003E INSTEAD of sampling live keyboard/gamepad, so whatever set\r\n\t/// it drives through the exact same input \u2192 assists \u2192 drivetrain path a human uses \u2014 it never\r\n\t/// applies forces itself (an intent-injection pattern). Null = normal keyboard/gamepad. A scripted\r\n\t/// source sets it each tick while it drives and clears it when done. This is the ONLY input\r\n\t/// addition to VehicleController (a deliberately narrow seam); future device sources plug in here\r\n\t/// without touching this class again.\r\n\t/// \u003C/summary\u003E\r\n\tpublic DriveInputs? InputOverride { get; set; }\r\n\r\n\tRigidbody _rigidbody;\r\n\tVector3 _spawnPosition;\r\n\tRotation _spawnRotation;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_rigidbody = Components.Get\u003CRigidbody\u003E();\r\n\t\tDefinition ??= CarDefinitions.Hatch;\r\n\t\tDrivetrain = new Drivetrain( Definition );\r\n\t\t// Adopt a carried session mode if a spawn path staged one (car swap); otherwise the\r\n\t\t// definition default. Because this runs a frame or two after the car is created, a spawn\r\n\t\t// path can\u0027t rely on a plain post-spawn Assists set surviving \u2014 it stages InitialAssists.\r\n\t\tAssists = InitialAssists ?? Definition.DefaultAssists;\r\n\t\t_spawnPosition = WorldPosition;\r\n\t\t_spawnRotation = WorldRotation;\r\n\r\n\t\t// suspension needs continuous simulation \u2014 a sleeping body ignores our forces\r\n\t\tif ( _rigidbody.PhysicsBody is not null )\r\n\t\t\t_rigidbody.PhysicsBody.AutoSleep = false;\r\n\r\n\t\t// brief freeze so the car initializes perfectly level and still\r\n\t\t_rigidbody.MotionEnabled = false;\r\n\t\t_settleFreeze = 0.4f;\r\n\t}\r\n\r\n\tTimeSince _telemetry;\r\n\tTimeSince _sinceSpawn;\r\n\tfloat _settleFreeze;\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( IsProxy || !_rigidbody.IsValid() || Drivetrain is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( _settleFreeze \u003E 0f )\r\n\t\t{\r\n\t\t\t_settleFreeze -= Time.Delta;\r\n\t\t\tif ( _settleFreeze \u003C= 0f )\r\n\t\t\t{\r\n\t\t\t\t_rigidbody.MotionEnabled = true;\r\n\t\t\t\t_sinceSpawn = 0;\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tReadInput();\r\n\t\tApplySteering();\r\n\r\n\t\t// handbrake = drift button: rears instantly lose lateral bite, snap back on release\r\n\t\tforeach ( var wheel in Wheels )\r\n\t\t{\r\n\t\t\tif ( !wheel.IsSteering )\r\n\t\t\t\twheel.GripScale = Handbrake ? Definition.HandbrakeGripScale : 1f;\r\n\r\n\t\t\t// throttle dissolves low-speed parking stiction on ALL wheels so full-lock\r\n\t\t\t// launches actually launch (undriven steered fronts were the drag)\r\n\t\t\twheel.ParkBrakeScale = 1f - Throttle;\r\n\t\t}\r\n\r\n\t\tforeach ( var wheel in Wheels )\r\n\t\t\twheel.BeginStep();\r\n\r\n\t\tvar driven = Wheels.Where( w =\u003E w.IsDriven ).ToList();\r\n\t\tfloat dt = Time.Delta / Substeps;\r\n\r\n\t\t// ground-truth wheel speed for shifting: actual forward speed over the tire radius\r\n\t\tfloat forwardSpeed = Vector3.Dot( _rigidbody.Velocity * Units.UnitsToMeters, WorldRotation.Forward );\r\n\t\tfloat groundWheelSpeed = forwardSpeed / Definition.WheelRadius;\r\n\r\n\t\t// arcade launch boost: extra shove off the line, fully faded by 15 m/s\r\n\t\tfloat launchBoost = MathX.Lerp( Definition.LaunchBoost, 1f, Math.Clamp( SpeedMs / 15f, 0f, 1f ) );\r\n\r\n\t\t// drift-catch assist: arm on the handbrake RELEASE edge, compute this tick\u0027s throttle factor.\r\n\t\tif ( _wasHandbrake \u0026\u0026 !Handbrake )\r\n\t\t\t_sinceHandbrakeRelease = 0f;\r\n\t\t_wasHandbrake = Handbrake;\r\n\t\tfloat driftCatch = DriftCatchFactor();\r\n\r\n\t\tfor ( int step = 0; step \u003C Substeps; step\u002B\u002B )\r\n\t\t{\r\n\t\t\tfloat avgDrivenSpeed = driven.Count \u003E 0 ? driven.Average( w =\u003E w.AngularVelocity ) : 0f;\r\n\t\t\tfloat throttle = ApplyTractionControl( Throttle * driftCatch, driven );\r\n\t\t\tfloat torquePerWheel = Drivetrain.Simulate( dt, throttle, avgDrivenSpeed, groundWheelSpeed, driven.Count ) * launchBoost;\r\n\r\n\t\t\t// Drive-side omega cap, read fresh AFTER Simulate (a shift changes the ratio mid-loop):\r\n\t\t\t// drive torque may never push a driven wheel past redline-equivalent within a substep\r\n\t\t\t// (the limiter-one-substep-late overshoot defect; see VehicleWheel.IntegrateWheelSpin).\r\n\t\t\tfloat omegaCap = Drivetrain.RedlineWheelSpeed;\r\n\r\n\t\t\tforeach ( var wheel in Wheels )\r\n\t\t\t{\r\n\t\t\t\tfloat drive = wheel.IsDriven ? torquePerWheel : 0f;\r\n\t\t\t\tfloat brake = BrakeTorqueFor( wheel );\r\n\t\t\t\tif ( wheel.IsDriven )\r\n\t\t\t\t\twheel.DriveOmegaCap = omegaCap;\r\n\t\t\t\twheel.Substep( dt, drive, brake );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tforeach ( var wheel in Wheels )\r\n\t\t\twheel.EndStep();\r\n\r\n\t\tApplyBrakeAssist();\r\n\t\tApplySpinRecoveryAssist();\r\n\t\tApplyStabilityAssist();\r\n\t\tApplyWallGlanceAssist();\r\n\t\tApplyAirAttitudeAssist();\r\n\r\n\t\t// dense driving telemetry: 2 Hz while moving or on input \u2014 parseable for analysis\r\n\t\tif ( _telemetry \u003E 0.5f \u0026\u0026 (SpeedMs \u003E 0.5f || Throttle \u003E 0f || Brake \u003E 0f) )\r\n\t\t{\r\n\t\t\t_telemetry = 0;\r\n\t\t\tfloat yawRate = _rigidbody.AngularVelocity.z.RadianToDegree();\r\n\t\t\tvar rears = Wheels.Where( w =\u003E !w.IsSteering ).ToList();\r\n\t\t\tvar fronts = Wheels.Where( w =\u003E w.IsSteering ).ToList();\r\n\t\t\tLog.Info( $\u0022[vp] tele car={Definition?.Name ?? \u0022?\u0022} 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 =\u003E w.SlipRatio ):F2} frontA {fronts.Average( w =\u003E w.SlipAngle.RadianToDegree() ):F1} rearA {rears.Average( w =\u003E w.SlipAngle.RadianToDegree() ):F1}\u0022 );\r\n\r\n\t\t\t// per-wheel trace hits whenever contact is abnormal \u2014 names what we\u0027re driving\r\n\t\t\t// on (or falling through); this diagnostic has caught three bugs, keep it\r\n\t\t\tint grounded = Wheels.Count( w =\u003E w.IsGrounded );\r\n\t\t\tif ( grounded \u003C 4 || _sinceSpawn \u003C 6f )\r\n\t\t\t{\r\n\t\t\t\tvar detail = string.Join( \u0022 | \u0022, Wheels.Select( w =\u003E\r\n\t\t\t\t\t$\u0022{w.GameObject.Name[^2..]} {w.DebugTrace} Fz {w.Load:F0}\u0022 ) );\r\n\t\t\t\tLog.Info( $\u0022[vp] wheels z {WorldPosition.z * Units.UnitsToMeters:F1}m | {detail}\u0022 );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( IsProxy )\r\n\t\t\treturn;\r\n\r\n\t\tif ( Input.Pressed( \u0022Reload\u0022 ) )\r\n\t\t\tRespawn();\r\n\r\n\t\t// Drive-mode (assist level) cycle \u2014 Casual -\u003E Sport -\u003E Sim -\u003E Casual. A plain property set,\r\n\t\t// so (like Reload) it reads Input.Pressed straight in OnUpdate; no fixed-step edge-latch needed.\r\n\t\t// A plain Assists set survives respawns/car swaps via InitialAssists and never gets clobbered\r\n\t\t// after OnStart.\r\n\t\tif ( Input.Pressed( \u0022DriveMode\u0022 ) )\r\n\t\t\tCycleDriveMode();\r\n\r\n\t\t// Sequential-shift edges are latched HERE (per-frame) \u2014 Input.Pressed is frame-scoped and\r\n\t\t// unreliable read from OnFixedUpdate, which may run zero or several times a frame. ReadInput\r\n\t\t// (fixed step) consumes \u002B clears these. A frame with no fixed tick still keeps the latch until\r\n\t\t// the next one, so no press is lost at any framerate.\r\n\t\tif ( Input.Pressed( \u0022ShiftUp\u0022 ) ) _liveShiftUp = true;\r\n\t\tif ( Input.Pressed( \u0022ShiftDown\u0022 ) ) _liveShiftDown = true;\r\n\t\tif ( Input.Pressed( \u0022ShiftMode\u0022 ) ) _liveShiftMode = true;\r\n\t}\r\n\r\n\tvoid ReadInput()\r\n\t{\r\n\t\t// one seam: live devices by default, or whatever an input source (a scripted pilot, an input\r\n\t\t// device) staged in InputOverride this tick. The struct carries the SAME raw intent the\r\n\t\t// keyboard sampled, so all the gear/reverse/steer-ramp logic below is source-agnostic.\r\n\t\tvar inputs = InputOverride ?? DriveInputs.SampleDeviceInputs();\r\n\r\n\t\t// forward stick/W = accelerate, back = brake \u2014 unless (near-)stopped, then back = reverse\r\n\t\tfloat forwardInput = inputs.MoveForward;\r\n\t\tfloat forwardSpeed = Vector3.Dot( _rigidbody.Velocity * Units.UnitsToMeters, WorldRotation.Forward );\r\n\r\n\t\t// direction changes engage while still rolling ~1 m/s the wrong way \u2014 waiting\r\n\t\t// for a dead stop is what made K-turns feel like a driving test\r\n\t\tif ( Drivetrain.Gear \u003E= 0 )\r\n\t\t{\r\n\t\t\tThrottle = MathF.Max( 0f, forwardInput );\r\n\t\t\tBrake = MathF.Max( 0f, -forwardInput );\r\n\r\n\t\t\tif ( forwardInput \u003C -0.15f \u0026\u0026 forwardSpeed \u003C 1.0f )\r\n\t\t\t\tDrivetrain.EngageReverse();\r\n\t\t}\r\n\t\telse // reverse gear: back = reverse throttle, forward = brake\r\n\t\t{\r\n\t\t\tThrottle = MathF.Max( 0f, -forwardInput );\r\n\t\t\tBrake = MathF.Max( 0f, forwardInput );\r\n\r\n\t\t\tif ( forwardInput \u003E 0.15f \u0026\u0026 forwardSpeed \u003E -1.0f )\r\n\t\t\t\tDrivetrain.EngageForward();\r\n\t\t}\r\n\r\n\t\tHandbrake = inputs.Handbrake;\r\n\r\n\t\t// reverse is for maneuvering, not backward land-speed records\r\n\t\tif ( Drivetrain.Gear == -1 \u0026\u0026 SpeedMs \u003E Definition.ReverseSpeedCap )\r\n\t\t\tThrottle = 0f;\r\n\r\n\t\t// steering: keyboard ramps, analog is direct; both rate and lock reduce with\r\n\t\t// speed \u2014 fast full-lock flicks at 70\u002B km/h were driver-induced weave\r\n\t\tfloat speedT = Math.Clamp( SpeedMs / 22f, 0f, 1f );\r\n\t\tfloat steerTarget = inputs.Steer;\r\n\t\tfloat rate = (MathF.Abs( steerTarget ) \u003E 0.01f\r\n\t\t\t? MathX.Lerp( 4.5f, 1.8f, speedT )\r\n\t\t\t: MathX.Lerp( 6f, 3f, speedT )) * Definition.SteerRateScale;\r\n\t\tSteer = MathX.Lerp( Steer, steerTarget, Math.Clamp( rate * Time.Delta, 0f, 1f ) );\r\n\r\n\t\t// \u2500\u2500 sequential MANUAL shift requests \u2500\u2500\r\n\t\t// Two sources at ONE seam, exactly like MoveForward/Steer/Handbrake: a scripted InputOverride\r\n\t\t// carries the shift bits in the struct; live play uses the per-frame edges latched in OnUpdate.\r\n\t\t// Live latches are consumed either way so a stray key pressed during a scripted run can\u0027t leak\r\n\t\t// into a live shift when the override clears.\r\n\t\tbool reqUp, reqDown, reqMode;\r\n\t\tif ( InputOverride is DriveInputs ov )\r\n\t\t{\r\n\t\t\treqUp = ov.ShiftUp;\r\n\t\t\treqDown = ov.ShiftDown;\r\n\t\t\treqMode = ov.ShiftModeToggle;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\treqUp = _liveShiftUp;\r\n\t\t\treqDown = _liveShiftDown;\r\n\t\t\treqMode = _liveShiftMode;\r\n\t\t}\r\n\t\t_liveShiftUp = _liveShiftDown = _liveShiftMode = false;\r\n\r\n\t\tApplyShiftRequests( reqUp, reqDown, reqMode, forwardSpeed / Definition.WheelRadius );\r\n\t}\r\n\r\n\t// \u2500\u2500 sequential MANUAL shift state \u2500\u2500\r\n\tbool _liveShiftUp, _liveShiftDown, _liveShiftMode;   // per-frame edges latched in OnUpdate\r\n\tbool _prevReqUp, _prevReqDown, _prevReqMode;          // rising-edge memory across fixed ticks\r\n\r\n\t/// \u003Csummary\u003ERising-edge-detect this tick\u0027s shift requests and drive the drivetrain. The live path\u0027s\r\n\t/// requests are already one-tick pulses (latched Input.Pressed edges), and this ALSO edge-gates the\r\n\t/// override/pilot path so a source that holds a bit across ticks shifts exactly once (the\r\n\t/// edge-through-InputOverride trap). \u003Cparamref name=\u0022groundWheelSpeed\u0022/\u003E (rad/s) feeds the\r\n\t/// down-shift over-rev guard.\u003C/summary\u003E\r\n\tvoid ApplyShiftRequests( bool up, bool down, bool mode, float groundWheelSpeed )\r\n\t{\r\n\t\tif ( mode \u0026\u0026 !_prevReqMode )\r\n\t\t\tToggleShiftMode();\r\n\t\tif ( up \u0026\u0026 !_prevReqUp )\r\n\t\t\tTryShiftUp();\r\n\t\tif ( down \u0026\u0026 !_prevReqDown )\r\n\t\t\tTryShiftDown( groundWheelSpeed );\r\n\r\n\t\t_prevReqUp = up;\r\n\t\t_prevReqDown = down;\r\n\t\t_prevReqMode = mode;\r\n\t}\r\n\r\n\tvoid ToggleShiftMode()\r\n\t{\r\n\t\tDrivetrain.ManualMode = !Drivetrain.ManualMode;\r\n\t\tLog.Info( $\u0022[vp] shiftmode {(Drivetrain.ManualMode ? \u0022MANUAL\u0022 : \u0022AUTO\u0022)} gear {Drivetrain.Gear}\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECycle the drive mode (assist level) Casual -\u003E Sport -\u003E Sim -\u003E Casual. A HUD assist chip\r\n\t/// can read \u003Csee cref=\u0022Assists\u0022/\u003E and flash on change, so the swap is visible on press.\u003C/summary\u003E\r\n\tvoid CycleDriveMode()\r\n\t{\r\n\t\tAssists = Assists switch\r\n\t\t{\r\n\t\t\tAssistLevel.Casual =\u003E AssistLevel.Sport,\r\n\t\t\tAssistLevel.Sport =\u003E AssistLevel.Sim,\r\n\t\t\t_ =\u003E AssistLevel.Casual,\r\n\t\t};\r\n\t\tLog.Info( $\u0022[vp] drivemode {Assists.ToString().ToUpper()}\u0022 );\r\n\t}\r\n\r\n\tvoid TryShiftUp()\r\n\t{\r\n\t\t// In AUTO a manual shift request is a no-op (the box shifts itself; mode changes only via G).\r\n\t\tif ( !Drivetrain.ManualMode )\r\n\t\t\treturn;\r\n\t\tif ( Drivetrain.ShiftUp() )\r\n\t\t\tLog.Info( $\u0022[vp] shift UP -\u003E gear {Drivetrain.Gear}\u0022 );\r\n\t}\r\n\r\n\tvoid TryShiftDown( float groundWheelSpeed )\r\n\t{\r\n\t\tif ( !Drivetrain.ManualMode )\r\n\t\t\treturn;\r\n\t\tif ( Drivetrain.ShiftDown( groundWheelSpeed ) )\r\n\t\t\tLog.Info( $\u0022[vp] shift DOWN -\u003E gear {Drivetrain.Gear}\u0022 );\r\n\t\telse\r\n\t\t\tLog.Info( $\u0022[vp] shift DOWN denied gear {Drivetrain.Gear} \u0022 \u002B\r\n\t\t\t\t$\u0022(predicted {Drivetrain.PredictedDownshiftRpm( groundWheelSpeed ):F0} / redline {Drivetrain.Redline:F0})\u0022 );\r\n\t}\r\n\r\n\tvoid ApplySteering()\r\n\t{\r\n\t\tfloat speedFactor = Math.Clamp( SpeedMs / 22f, 0f, 1f );\r\n\t\tfloat maxAngle = MathX.Lerp( Definition.MaxSteerAngle, Definition.HighSpeedSteerAngle, speedFactor );\r\n\t\tfloat angle = Steer * maxAngle;\r\n\r\n\t\tforeach ( var wheel in Wheels.Where( w =\u003E w.IsSteering ) )\r\n\t\t{\r\n\t\t\twheel.SteerAngle = angle;\r\n\t\t\twheel.LocalRotation = Rotation.FromYaw( angle );\r\n\t\t}\r\n\t}\r\n\r\n\tfloat BrakeTorqueFor( VehicleWheel wheel )\r\n\t{\r\n\t\tbool isFront = wheel.IsSteering;\r\n\t\tfloat bias = isFront ? Definition.BrakeBias : 1f - Definition.BrakeBias;\r\n\t\tfloat torque = Brake * Definition.BrakeTorque * bias * 0.5f; // per wheel (2 per axle)\r\n\r\n\t\tif ( Handbrake \u0026\u0026 wheel.HasHandbrake )\r\n\t\t{\r\n\t\t\t// Drift-exit soft-lock: when a slip cap is active and this rear is already sliding PAST it\r\n\t\t\t// (SlipRatio more negative than the cap), withhold the handbrake torque this substep so the\r\n\t\t\t// wheel spins back up toward the cap \u2014 the rears keep rotating and retain lateral authority\r\n\t\t\t// instead of dead-locking into 60\u00B0\u002B slip angles. Same one-substep-lagged SlipRatio the ABS\r\n\t\t\t// branch below reads. Default cap -1.0 leaves capActive false, so full lock is byte-identical.\r\n\t\t\tbool capActive = Definition.HandbrakeSlipCap \u003E -1f;\r\n\t\t\tif ( !capActive || wheel.SlipRatio \u003E Definition.HandbrakeSlipCap )\r\n\t\t\t\ttorque \u002B= Definition.HandbrakeTorque;\r\n\t\t}\r\n\r\n\t\t// ABS: release when the wheel locks under braking (Casual \u002B Sport). Thresholds are\r\n\t\t// per-car dials (see CarDefinition.AbsSlipThreshold).\r\n\t\tif ( Assists != AssistLevel.Sim \u0026\u0026 torque \u003E 0f \u0026\u0026 wheel.IsGrounded\r\n\t\t\t\u0026\u0026 wheel.SlipRatio \u003C -Definition.AbsSlipThreshold )\r\n\t\t\ttorque *= Definition.AbsReleaseFactor;\r\n\r\n\t\treturn torque;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Arcade brake assist: extra chassis-level deceleration while braking. The tire\r\n\t/// model alone stops at sim rates, which reads as \u0022slow\u0022 against arcade expectations.\r\n\t/// Capped so it can never reverse the car within a step.\r\n\t/// \u003C/summary\u003E\r\n\tvoid ApplyBrakeAssist()\r\n\t{\r\n\t\tif ( Definition.BrakeAssist \u003C= 0f || Brake \u003C 0.1f || SpeedMs \u003C 0.5f )\r\n\t\t\treturn;\r\n\r\n\t\tif ( Wheels.Count( w =\u003E w.IsGrounded ) \u003C 2 )\r\n\t\t\treturn;\r\n\r\n\t\tvar flat = _rigidbody.Velocity.WithZ( 0f );\r\n\t\tif ( flat.IsNearZeroLength )\r\n\t\t\treturn;\r\n\r\n\t\tfloat decel = Definition.BrakeAssist * Brake; // m/s\u00B2\r\n\t\tfloat stopCap = flat.Length * Units.UnitsToMeters / Time.Delta;\r\n\t\tfloat applied = MathF.Min( decel, stopCap );\r\n\r\n\t\t_rigidbody.ApplyForce( -flat.Normal * applied * Definition.Mass * Units.MetersToUnits );\r\n\t}\r\n\r\n\tTimeSince _recoverLog = 999f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Arcade SPIN-RECOVERY assist. After a handbrake flick spins the car ~180\u00B0, the player holds full\r\n\t/// FORWARD throttle but the car keeps rolling BACKWARDS (its old travel direction) for too long\r\n\t/// before the tires pick up and drive it the new way. BrakeAssist can\u0027t cover this: with a forward\r\n\t/// gear \u002B W held, ReadInput sets Throttle=1, Brake=0 \u2014 so the only thing arresting the backward\r\n\t/// slide is deep-slip tire tail grip, further scaled down by the friction ellipse sharing with\r\n\t/// lateral demand. This adds chassis-level deceleration along -flat-velocity WHENEVER the input\r\n\t/// throttle commands the gear\u0027s direction while ground velocity along the car\u0027s facing opposes it\r\n\t/// (the quadrant BrakeAssist\u0027s opposing-input\u2192Brake mapping never reaches), fading out via an\r\n\t/// opposition ramp as the car rotates to face its motion. Capped by the same never-reverse-within-\r\n\t/// a-step stopCap BrakeAssist uses. Sim keeps the raw accepted feel.\r\n\t///\r\n\t/// Interaction with drift-catch (DriftCatchFactor): drift-catch cuts DRIVETRAIN throttle for \u22640.5 s\r\n\t/// after handbrake release while the rear is deeply SIDEWAYS; this reads INPUT throttle and applies\r\n\t/// a CHASSIS force. They serve different states \u2014 sideways (realign) vs backwards (kill stale\r\n\t/// velocity) \u2014 and act together in a spin-recovery WITHOUT being merged.\r\n\t/// \u003C/summary\u003E\r\n\tvoid ApplySpinRecoveryAssist()\r\n\t{\r\n\t\tif ( Definition.SpinRecoveryAssist \u003C= 0f || Assists == AssistLevel.Sim )\r\n\t\t\treturn;\r\n\r\n\t\t// Throttle is the gear-direction INPUT throttle magnitude set in ReadInput (before the\r\n\t\t// drift-catch / TC drivetrain governors scale it) \u2014 exactly the raw request this assist wants.\r\n\t\tif ( Throttle \u003C= 0.1f )\r\n\t\t\treturn;\r\n\r\n\t\tif ( Wheels.Count( w =\u003E w.IsGrounded ) \u003C 2 )\r\n\t\t\treturn;\r\n\r\n\t\tvar flat = _rigidbody.Velocity.WithZ( 0f );\r\n\t\tfloat planarSpeed = flat.Length * Units.UnitsToMeters;\r\n\t\tif ( planarSpeed \u003C 0.5f )\r\n\t\t\treturn;\r\n\r\n\t\t// velocity along the car\u0027s facing. commandedDir folds forward/reverse gear into one sign: in a\r\n\t\t// forward gear the throttle commands \u002Bforward, so a NEGATIVE forwardSpeed (still sliding\r\n\t\t// backwards under forward throttle) is the uncovered case; in reverse gear it commands\r\n\t\t// -forward, so a POSITIVE forwardSpeed (reverse throttle while still rolling forward) mirrors it.\r\n\t\tfloat forwardSpeed = Vector3.Dot( _rigidbody.Velocity * Units.UnitsToMeters, WorldRotation.Forward );\r\n\t\tfloat commandedDir = Drivetrain.Gear \u003E= 0 ? 1f : -1f;\r\n\t\tfloat alongCommanded = forwardSpeed * commandedDir; // \u003C0 \u21D2 velocity opposes the throttle direction\r\n\t\tif ( alongCommanded \u003E -0.5f )\r\n\t\t\treturn; // already moving the commanded way (or ~stopped) \u2014 nothing to recover\r\n\r\n\t\t// opposition ramp: fraction of speed pointing the wrong way \u2014 1 when velocity fully opposes\r\n\t\t// facing, fading to 0 as the car rotates to face its motion (so the assist bows out on its own).\r\n\t\tfloat oppositionRamp = Math.Clamp( -alongCommanded / planarSpeed, 0f, 1f );\r\n\r\n\t\tfloat decel = Definition.SpinRecoveryAssist * oppositionRamp; // m/s\u00B2\r\n\t\tfloat stopCap = planarSpeed / Time.Delta; // never reverse the flat velocity within a step\r\n\t\tfloat applied = MathF.Min( decel, stopCap );\r\n\r\n\t\t_rigidbody.ApplyForce( -flat.Normal * applied * Definition.Mass * Units.MetersToUnits );\r\n\r\n\t\t// throttled ~2 Hz while active \u2014 parseable for free-drive sessions.\r\n\t\tif ( _recoverLog \u003E 0.5f )\r\n\t\t{\r\n\t\t\t_recoverLog = 0f;\r\n\t\t\tLog.Info( $\u0022[vp] recover v {planarSpeed * 3.6f:F0}kmh along {alongCommanded:F1}m/s ramp {oppositionRamp:F2} decel {applied:F1}m/s2\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 drift-catch assist \u2500\u2500\r\n\t// The measured drift-exit anatomy: on handbrake release the driver goes to full throttle while\r\n\t// the rear slip angle is still 60\u00B0\u002B, so the drive torque spends the rear tires\u0027 friction ellipse\r\n\t// LONGITUDINALLY exactly when every newton of lateral force is needed to realign the velocity\r\n\t// vector (\u0022stuck sliding sideways\u0022 \u002B rearK spike \u002B auto-downshift in the telemetry).\r\n\t// For a short window after the handbrake releases, while the rear is still deeply sideways, cut\r\n\t// throttle-induced rear slip (ramping to a full cut by DriftCatchFullCutDeg) so the ellipse serves\r\n\t// realignment first \u2014 mirrors real drift-catch technique (wait for the catch before power).\r\n\t// Casual \u002B Sport only; Sim stays the raw accepted feel. The 20\u00B0 floor keeps deliberate\r\n\t// power-oversteer (jturn rotation, small-angle slides) untouched.\r\n\tconst float DriftCatchWindowS = 0.5f;    // seconds after hb release the assist can act\r\n\tconst float DriftCatchStartDeg = 20f;    // rear slip angle where the cut starts\r\n\tconst float DriftCatchFullCutDeg = 35f;  // rear slip angle at/past which throttle is fully cut\r\n\r\n\tbool _wasHandbrake;\r\n\tTimeSince _sinceHandbrakeRelease = 999f;\r\n\r\n\tfloat DriftCatchFactor()\r\n\t{\r\n\t\tif ( Assists == AssistLevel.Sim || Handbrake )\r\n\t\t\treturn 1f;\r\n\t\tif ( _sinceHandbrakeRelease \u003E DriftCatchWindowS )\r\n\t\t\treturn 1f;\r\n\r\n\t\tvar rears = Wheels.Where( w =\u003E !w.IsSteering \u0026\u0026 w.IsGrounded ).ToList();\r\n\t\tif ( rears.Count == 0 )\r\n\t\t\treturn 1f;\r\n\r\n\t\tfloat rearADeg = MathF.Abs( rears.Average( w =\u003E w.SlipAngle ) ).RadianToDegree();\r\n\t\tif ( rearADeg \u003C= DriftCatchStartDeg )\r\n\t\t\treturn 1f;\r\n\r\n\t\treturn Math.Clamp( 1f - (rearADeg - DriftCatchStartDeg) / (DriftCatchFullCutDeg - DriftCatchStartDeg), 0f, 1f );\r\n\t}\r\n\r\n\tfloat ApplyTractionControl( float throttle, List\u003CVehicleWheel\u003E driven )\r\n\t{\r\n\t\t// drifting is throttle-steered \u2014 TC clamping wheelspin would kill the slide\r\n\t\tif ( Handbrake || Assists == AssistLevel.Sim || throttle \u003C= 0f )\r\n\t\t\treturn throttle;\r\n\r\n\t\t// proportional: hold driven-wheel slip near a target. Casual holds the grip PEAK; Sport (when the\r\n\t\t// car opts in via SportTcSlipTarget) holds a LOOSER target well past the peak so the rears still\r\n\t\t// break into a throttle-steerable slide, but bounded so they can\u0027t free-spin to redline and torch\r\n\t\t// rear lateral grip (the Sport spin-out, owner 2026-07-21). The longitudinal tire-curve peaks sit\r\n\t\t// at slip 0.09-0.14; a 0.25 target parks the tire in the post-peak slide \u2014 slower (less grip than\r\n\t\t// the peak) AND permanently over the wheelspin threshold. Casual 0.14 targets the peak: more launch\r\n\t\t// grip and slip under the counter. Sport, not opted in (target 0), returns raw throttle \u2014 the old\r\n\t\t// \u0022ABS only\u0022 behavior, byte-identical.\r\n\t\tfloat slipTarget;\r\n\t\tif ( Assists == AssistLevel.Casual )\r\n\t\t\tslipTarget = 0.14f;\r\n\t\telse if ( Definition.SportTcSlipTarget \u003E 0f )\r\n\t\t\tslipTarget = Definition.SportTcSlipTarget;\r\n\t\telse\r\n\t\t\treturn throttle;\r\n\r\n\t\tfloat worstSlip = driven.Where( w =\u003E w.IsGrounded ).Select( w =\u003E w.SlipRatio ).DefaultIfEmpty( 0f ).Max();\r\n\t\tif ( worstSlip \u003C= slipTarget )\r\n\t\t\treturn throttle;\r\n\r\n\t\t// TC floor relaxation (kart cap-camping fix 2026-07-18): the flat 0.2 throttle floor still fed\r\n\t\t// enough torque to sustain a spinning rear on a light car (260 kg kart), so TC could not\r\n\t\t// arrest the wheelspin that pins a collapsing-corner rear far past the grip peak (the \u0022stuck\r\n\t\t// turning\u0022 bug, offline: this is the decisive lever, cutting sustained rear slip 3.2 -\u003E 0.4).\r\n\t\t// Once slip is deep past the tail (the longitudinal curve reaches its tail by slip 0.40;\r\n\t\t// TcFloorRelaxStart 1.0 is well beyond it), fade the floor toward 0 so the proportional\r\n\t\t// response can cut throttle to near-zero. Below TcFloorRelaxStart the floor stays 0.2 so all\r\n\t\t// below-threshold behavior is byte-identical.\r\n\t\tconst float TcFloorRelaxStart = 1.0f;\r\n\t\tconst float TcFloorRelaxEnd = 2.5f;\r\n\t\tfloat floor = 0.2f;\r\n\t\tif ( worstSlip \u003E TcFloorRelaxStart )\r\n\t\t\tfloor *= Math.Clamp( (TcFloorRelaxEnd - worstSlip) / (TcFloorRelaxEnd - TcFloorRelaxStart), 0f, 1f );\r\n\r\n\t\treturn throttle * Math.Clamp( slipTarget / worstSlip, floor, 1f );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Airborne pitch-rate damping time constant, seconds. Leaving a ramp lip pivots the car over\r\n\t/// its rear axle, so every launch departs rotating nose-down (19-46 deg/s measured); with no\r\n\t/// air management the car rotates through the whole flight and lands nose-first, digging in.\r\n\t/// Flight-recorder capture (2026-07-21, Lad2 at 35.5 m/s): launch attitude 8.6 deg nose-UP,\r\n\t/// landing attitude 17.8 deg nose-DOWN, touchdown 35.5 to 31.7 m/s in 40 ms (a ~5 g spike)\r\n\t/// then pitch slammed level in 100 ms - the owner\u0027s \u0022hitch going off the ramps\u0022, reported\r\n\t/// identically at every speed because the lip pivot exists at every speed. Damping the\r\n\t/// car-local pitch rate while fully airborne holds the launch attitude so the car lands\r\n\t/// wheels-matched (slightly tail-first). 0 or negative disables. LIVE-UNVERIFIED.\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public float AirPitchDampTau { get; set; } = 0.30f;\r\n\r\n\tvoid ApplyAirAttitudeAssist()\r\n\t{\r\n\t\tif ( AirPitchDampTau \u003C= 0f )\r\n\t\t\treturn;\r\n\r\n\t\t// fully airborne only: any grounded wheel means suspension owns attitude and this\r\n\t\t// assist is inert, so grounded driving is byte-identical by construction\r\n\t\tforeach ( var w in Wheels )\r\n\t\t\tif ( w.IsGrounded )\r\n\t\t\t\treturn;\r\n\t\tif ( Wheels.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\t// the drift button doubles as \u0022let me rotate\u0022 in the air: deliberate flips stay possible\r\n\t\tif ( Handbrake )\r\n\t\t\treturn;\r\n\r\n\t\tvar local = WorldRotation.Inverse * _rigidbody.AngularVelocity;\r\n\t\tlocal.y *= MathF.Exp( -Time.Delta / AirPitchDampTau );\r\n\t\t_rigidbody.AngularVelocity = WorldRotation * local;\r\n\t}\r\n\r\n\tvoid ApplyStabilityAssist()\r\n\t{\r\n\t\t// the drift button asks for yaw \u2014 don\u0027t damp it away while held\r\n\t\tif ( Handbrake )\r\n\t\t\treturn;\r\n\r\n\t\t// Casual damps at full authority; Sport (when the car opts in via SportStabilityScale) damps at a\r\n\t\t// FRACTION so the counter-steer pendulum snap (rear regains grip and flings the car the other way,\r\n\t\t// uncatchable \u2014 owner 2026-07-21) is bled off while deliberate rotation survives. Sim, and Sport\r\n\t\t// with no opt-in, get nothing. Casual multiplies by exactly 1f so its behavior is byte-identical.\r\n\t\tfloat authority;\r\n\t\tif ( Assists == AssistLevel.Casual )\r\n\t\t\tauthority = 1f;\r\n\t\telse if ( Assists == AssistLevel.Sport \u0026\u0026 Definition.SportStabilityScale \u003E 0f )\r\n\t\t\tauthority = Definition.SportStabilityScale;\r\n\t\telse\r\n\t\t\treturn;\r\n\r\n\t\t// small corrective action when the rear steps out \u2014 damp yaw so slides are catchable\r\n\t\t// instead of divergent (the lift-off L-R flick spin)\r\n\t\tvar rears = Wheels.Where( w =\u003E !w.IsSteering \u0026\u0026 w.IsGrounded ).ToList();\r\n\t\tif ( rears.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tfloat rearAlpha = MathF.Abs( rears.Average( w =\u003E w.SlipAngle ) );\r\n\t\tif ( rearAlpha \u003C 0.07f ) // ~4 degrees\r\n\t\t\treturn;\r\n\r\n\t\t// scales up with speed: the flat 3f cap let a 115 km/h lift-off flick go full 360\r\n\t\tfloat speedScale = 3f \u002B 3f * Math.Clamp( SpeedMs / 30f, 0f, 1f );\r\n\t\tfloat strength = Math.Clamp( (rearAlpha - 0.07f) * 8f, 0f, 1f ) * speedScale * authority;\r\n\t\tvar angular = _rigidbody.AngularVelocity;\r\n\t\tangular.z *= MathF.Max( 0f, 1f - strength * Time.Delta );\r\n\t\t_rigidbody.AngularVelocity = angular;\r\n\t}\r\n\r\n\t// \u2500\u2500 wall-glance forgiveness assist \u2500\u2500\r\n\t// Detection surface = the chassis Rigidbody\u0027s collision callbacks (Component.ICollisionListener;\r\n\t// the ONLY runtime chassis-contact API: OnCollisionStart/Update/Stop carry Sandbox.Collision with\r\n\t// .Contact.Point/.Contact.Normal and .Other). The chassis box rests ABOVE the wheels\u0027 contact\r\n\t// zone, so it never touches flat ground \u2014 these fire only on real obstacle contact (wall, cone,\r\n\t// bottom-out). We latch ONLY near-horizontal normals (true walls); ground/ramps/banks have\r\n\t// near-vertical normals and are ignored.\r\n\tconst float WallNormalZMax = 0.5f;   // |normal.z| \u003C this \u21D2 surface within ~30\u00B0 of vertical = a wall\r\n\t// engage as soon as a wall contact is confirmed (a corner-wedge kills speed in a few frames, so a\r\n\t// multi-frame gate arrives after the dead-stop it\u0027s meant to prevent). A stray \u22641-frame contact\r\n\t// still can\u0027t engage: the streak must reach this AND the car must be moving INTO the surface above\r\n\t// the speed floor.\r\n\tconst int WallEngageTicks = 1;\r\n\tconst float WallGlanceMinSpeed = 3f; // m/s planar floor below which forgiveness is pointless\r\n\r\n\tVector3 _wallNormalH;          // horizontal unit wall normal from the most recent contact\r\n\tTimeSince _sinceWallContact = 999f;\r\n\tint _wallStreak;\r\n\tTimeSince _wallGlanceLog = 999f;\r\n\r\n\tpublic void OnCollisionStart( Collision o ) =\u003E NoteWallContact( o );\r\n\tpublic void OnCollisionUpdate( Collision o ) =\u003E NoteWallContact( o );\r\n\tpublic void OnCollisionStop( CollisionStop o ) { }\r\n\r\n\tvoid NoteWallContact( Collision c )\r\n\t{\r\n\t\tif ( IsProxy )\r\n\t\t\treturn;\r\n\r\n\t\tvar n = c.Contact.Normal;\r\n\t\t// a wall = near-horizontal contact normal (surface within ~30\u00B0 of vertical). Ground/ramps/\r\n\t\t// banks report near-vertical normals and never latch, so this can\u0027t fire driving over terrain.\r\n\t\tif ( MathF.Abs( n.z ) \u003E= WallNormalZMax )\r\n\t\t\treturn;\r\n\r\n\t\tvar nH = n.WithZ( 0f );\r\n\t\tif ( nH.IsNearZeroLength )\r\n\t\t\treturn;\r\n\r\n\t\t_wallNormalH = nH.Normal;\r\n\t\t_sinceWallContact = 0f;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Forgiveness for angled (esp. mid-drift) wall contact: instead of a dead stop, re-project\r\n\t/// velocity onto the wall tangent (keeping \u003Csee cref=\u0022CarDefinition.WallScrubFactor\u0022/\u003E of speed)\r\n\t/// and gently yaw the heading to run parallel \u2014 both scaled by incidence so HEAD-ON hits stay\r\n\t/// hard stops. An assist: gated on \u003Csee cref=\u0022CarDefinition.WallGlanceAssist\u0022/\u003E and Assists != Sim\r\n\t/// (Sim = the accepted raw feel). Runs in OnFixedUpdate BEFORE the physics step, so the solver then\r\n\t/// resolves the redirected velocity without penetration \u2014 same write-then-step pattern as the\r\n\t/// brake/stability assists.\r\n\t/// \u003C/summary\u003E\r\n\tvoid ApplyWallGlanceAssist()\r\n\t{\r\n\t\tbool contactNow = _sinceWallContact \u003C= Time.Delta * 1.5f;\r\n\t\tif ( !contactNow )\r\n\t\t{\r\n\t\t\t_wallStreak = 0;\r\n\t\t\treturn;\r\n\t\t}\r\n\t\t_wallStreak\u002B\u002B;\r\n\r\n\t\tif ( Definition is null || !Definition.WallGlanceAssist || Assists == AssistLevel.Sim )\r\n\t\t\treturn;\r\n\t\tif ( _wallStreak \u003C WallEngageTicks )\r\n\t\t\treturn;\r\n\r\n\t\tvar vel = _rigidbody.Velocity;\r\n\t\tvar planar = vel.WithZ( 0f );\r\n\t\tfloat planarSpeedMs = planar.Length * Units.UnitsToMeters;\r\n\t\tif ( planarSpeedMs \u003C WallGlanceMinSpeed )\r\n\t\t\treturn;\r\n\r\n\t\tvar nH = _wallNormalH;\r\n\t\tfloat into = Vector3.Dot( planar.Normal, nH ); // \u003C0 \u21D2 moving INTO the wall\r\n\t\tif ( into \u003E= -0.01f )\r\n\t\t\treturn; // already sliding along / peeling away \u2014 nothing to catch\r\n\r\n\t\t// incidence between velocity and the wall PLANE: 0\u00B0 = grazing, 90\u00B0 = straight-on\r\n\t\tfloat incidenceDeg = MathF.Asin( Math.Clamp( MathF.Abs( into ), 0f, 1f ) ).RadianToDegree();\r\n\r\n\t\t// full assist below shallow, none at/above head-on (frontal crashes keep the hard stop)\r\n\t\tfloat scale = incidenceDeg \u003E= Definition.WallGlanceHeadOnDeg ? 0f\r\n\t\t\t: incidenceDeg \u003C= Definition.WallGlanceShallowDeg ? 1f\r\n\t\t\t: (Definition.WallGlanceHeadOnDeg - incidenceDeg)\r\n\t\t\t\t/ MathF.Max( 1e-3f, Definition.WallGlanceHeadOnDeg - Definition.WallGlanceShallowDeg );\r\n\t\tif ( scale \u003C= 0f )\r\n\t\t\treturn;\r\n\r\n\t\t// tangent = the velocity component that runs ALONG the wall (the slide direction)\r\n\t\tvar vTangent = planar - Vector3.Dot( planar, nH ) * nH;\r\n\t\tif ( vTangent.IsNearZeroLength )\r\n\t\t\treturn;\r\n\t\tvar tangent = vTangent.Normal;\r\n\r\n\t\t// (a) re-project velocity onto the tangent: kill the into-wall component (which would wedge\r\n\t\t// the rigid chassis corner and dead-stop it) and carry the slide forward at\r\n\t\t// max(natural tangential speed, WallScrubFactor\u00B7total). Once the car is redirected along the\r\n\t\t// wall the target equals the current tangential speed, so it slides steadily instead of\r\n\t\t// grinding to a halt. Blended by incidence so a near head-on keeps the physics dead-stop.\r\n\t\tfloat totalSpeed = planar.Length;\r\n\t\tfloat targetSpeed = MathF.Min( totalSpeed,\r\n\t\t\tMathF.Max( vTangent.Length, totalSpeed * Definition.WallScrubFactor ) );\r\n\t\tvar newPlanar = Vector3.Lerp( planar, tangent * targetSpeed, scale );\r\n\t\t_rigidbody.Velocity = newPlanar.WithZ( vel.z );\r\n\r\n\t\t// (b) gentle yaw torque aligning heading to the wall tangent (whichever way the car faces)\r\n\t\tvar fwd = WorldRotation.Forward.WithZ( 0f ).Normal;\r\n\t\tvar alignTo = Vector3.Dot( fwd, tangent ) \u003C 0f ? -tangent : tangent;\r\n\t\tfloat cross = fwd.x * alignTo.y - fwd.y * alignTo.x; // \u002Bz \u21D2 target is to the LEFT (CCW)\r\n\t\tfloat yawErrRad = MathF.Atan2( cross, Vector3.Dot( fwd, alignTo ) );\r\n\t\tvar ang = _rigidbody.AngularVelocity;\r\n\t\tang.z = MathX.Lerp( ang.z, yawErrRad * Definition.WallAlignStrength,\r\n\t\t\tMath.Clamp( Definition.WallAlignStrength * scale * Time.Delta, 0f, 1f ) );\r\n\t\t_rigidbody.AngularVelocity = ang;\r\n\r\n\t\t// throttled so a multi-tick slide doesn\u0027t flood the log\r\n\t\tif ( _wallGlanceLog \u003E 0.2f )\r\n\t\t{\r\n\t\t\t_wallGlanceLog = 0f;\r\n\t\t\tLog.Info( $\u0022[vp] wallglance inc {incidenceDeg:F0}deg scale {scale:F2} v {planarSpeedMs * 3.6f:F0}-\u003E{newPlanar.Length * Units.UnitsToMeters * 3.6f:F0}kmh\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Respawn()\r\n\t{\r\n\t\tWorldPosition = _spawnPosition \u002B Vector3.Up * 20f;\r\n\t\tWorldRotation = _spawnRotation;\r\n\t\t_rigidbody.Velocity = Vector3.Zero;\r\n\t\t_rigidbody.AngularVelocity = Vector3.Zero;\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":".obj/__compiler_extra.cs","FileName":"__compiler_extra.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonTitle\u0022, \u0022Vehicle Physics Kit\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonIdent\u0022, \u0022vehiclephysics\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022OrgIdent\u0022, \u0022fieldguide\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022Ident\u0022, \u0022fieldguide.vehiclephysics\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineVersion\u0022, \u002228\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineMinorVersion\u0022, \u00221\u0022 )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \u0022.NETCoreApp,Version=v9.0\u0022, FrameworkDisplayName = \u0022.NET 9.0\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022CompileTime\u0022, \u00222026-07-22T03:31:24.7233296Z\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\u00220.0.120.0\u0022)]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\u00220.0.120.0\u0022)]"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/TireCurve.cs","FileName":"TireCurve.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Parametric peaked tire curve (Pacejka-shaped, authored by peak/tail points).\r\n/// Rises smoothly to a grip peak, then falls off to a sliding asymptote.\r\n/// Input is |slip| \u2014 slip ratio (unitless) for longitudinal, slip angle (radians) for lateral.\r\n/// Output is a grip coefficient multiplied by tire load to get force.\r\n/// \u003C/summary\u003E\r\npublic struct TireCurve\r\n{\r\n\t/// \u003Csummary\u003ESlip value where grip peaks (\u03BA \u2248 0.08\u20130.12, \u03B1 \u2248 6\u20139\u00B0 in radians).\u003C/summary\u003E\r\n\tpublic float PeakSlip { get; set; }\r\n\r\n\t/// \u003Csummary\u003EGrip coefficient at the peak.\u003C/summary\u003E\r\n\tpublic float PeakGrip { get; set; }\r\n\r\n\t/// \u003Csummary\u003ESlip value where the curve has fully fallen to the tail.\u003C/summary\u003E\r\n\tpublic float TailSlip { get; set; }\r\n\r\n\t/// \u003Csummary\u003EGrip coefficient when fully sliding (typically 75\u201385% of peak).\u003C/summary\u003E\r\n\tpublic float TailGrip { get; set; }\r\n\r\n\tpublic TireCurve( float peakSlip, float peakGrip, float tailSlip, float tailGrip )\r\n\t{\r\n\t\tPeakSlip = peakSlip;\r\n\t\tPeakGrip = peakGrip;\r\n\t\tTailSlip = tailSlip;\r\n\t\tTailGrip = tailGrip;\r\n\t}\r\n\r\n\tpublic readonly float Evaluate( float slip )\r\n\t{\r\n\t\tslip = MathF.Abs( slip );\r\n\r\n\t\tif ( slip \u003C= PeakSlip )\r\n\t\t{\r\n\t\t\t// parabolic rise with zero slope at the peak\r\n\t\t\tfloat n = slip / PeakSlip;\r\n\t\t\treturn PeakGrip * n * (2f - n);\r\n\t\t}\r\n\r\n\t\t// smoothstep decay from peak down to the tail\r\n\t\tfloat t = Math.Clamp( (slip - PeakSlip) / (TailSlip - PeakSlip), 0f, 1f );\r\n\t\tt = t * t * (3f - 2f * t);\r\n\t\treturn PeakGrip \u002B (TailGrip - PeakGrip) * t;\r\n\t}\r\n\r\n\tpublic static TireCurve Street =\u003E new( 0.10f, 1.00f, 0.45f, 0.80f );\r\n\tpublic static TireCurve Sport =\u003E new( 0.09f, 1.15f, 0.40f, 0.92f );\r\n\tpublic static TireCurve Offroad =\u003E new( 0.14f, 0.90f, 0.60f, 0.75f );\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"TireCurve.cs","FileName":"TireCurve.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Parametric peaked tire curve (Pacejka-shaped, authored by peak/tail points).\r\n/// Rises smoothly to a grip peak, then falls off to a sliding asymptote.\r\n/// Input is |slip| \u2014 slip ratio (unitless) for longitudinal, slip angle (radians) for lateral.\r\n/// Output is a grip coefficient multiplied by tire load to get force.\r\n/// \u003C/summary\u003E\r\npublic struct TireCurve\r\n{\r\n\t/// \u003Csummary\u003ESlip value where grip peaks (\u03BA \u2248 0.08\u20130.12, \u03B1 \u2248 6\u20139\u00B0 in radians).\u003C/summary\u003E\r\n\tpublic float PeakSlip { get; set; }\r\n\r\n\t/// \u003Csummary\u003EGrip coefficient at the peak.\u003C/summary\u003E\r\n\tpublic float PeakGrip { get; set; }\r\n\r\n\t/// \u003Csummary\u003ESlip value where the curve has fully fallen to the tail.\u003C/summary\u003E\r\n\tpublic float TailSlip { get; set; }\r\n\r\n\t/// \u003Csummary\u003EGrip coefficient when fully sliding (typically 75\u201385% of peak).\u003C/summary\u003E\r\n\tpublic float TailGrip { get; set; }\r\n\r\n\tpublic TireCurve( float peakSlip, float peakGrip, float tailSlip, float tailGrip )\r\n\t{\r\n\t\tPeakSlip = peakSlip;\r\n\t\tPeakGrip = peakGrip;\r\n\t\tTailSlip = tailSlip;\r\n\t\tTailGrip = tailGrip;\r\n\t}\r\n\r\n\tpublic readonly float Evaluate( float slip )\r\n\t{\r\n\t\tslip = MathF.Abs( slip );\r\n\r\n\t\tif ( slip \u003C= PeakSlip )\r\n\t\t{\r\n\t\t\t// parabolic rise with zero slope at the peak\r\n\t\t\tfloat n = slip / PeakSlip;\r\n\t\t\treturn PeakGrip * n * (2f - n);\r\n\t\t}\r\n\r\n\t\t// smoothstep decay from peak down to the tail\r\n\t\tfloat t = Math.Clamp( (slip - PeakSlip) / (TailSlip - PeakSlip), 0f, 1f );\r\n\t\tt = t * t * (3f - 2f * t);\r\n\t\treturn PeakGrip \u002B (TailGrip - PeakGrip) * t;\r\n\t}\r\n\r\n\tpublic static TireCurve Street =\u003E new( 0.10f, 1.00f, 0.45f, 0.80f );\r\n\tpublic static TireCurve Sport =\u003E new( 0.09f, 1.15f, 0.40f, 0.92f );\r\n\tpublic static TireCurve Offroad =\u003E new( 0.14f, 0.90f, 0.60f, 0.75f );\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"WheelVisual.cs","FileName":"WheelVisual.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Spins and vertically tracks the wheel\u0027s visual mesh from VehicleWheel state.\r\n/// Steering yaw lives on the wheel GameObject itself (set by VehicleController);\r\n/// spin composes with whatever base orientation the factory gave the visual.\r\n/// \u003C/summary\u003E\r\npublic sealed class WheelVisual : Component\r\n{\r\n\tpublic VehicleWheel Wheel { get; set; }\r\n\r\n\tRotation _baseRotation;\r\n\tfloat _spinDegrees;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_baseRotation = LocalRotation;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Wheel is null )\r\n\t\t\treturn;\r\n\r\n\t\t_spinDegrees \u002B= Wheel.AngularVelocity.RadianToDegree() * Time.Delta;\r\n\t\t_spinDegrees %= 360f;\r\n\r\n\t\t// Spin about the model-local \u002BY axle. FromPitch uses the Source pitch sign (positive pitch\r\n\t\t// tilts the local \u002BX forward vector DOWN), so a POSITIVE angle here rolls the top of the wheel\r\n\t\t// toward the car\u0027s \u002BX travel direction, the correct rolling sense for forward motion. The old\r\n\t\t// negated angle rolled the tread backwards while driving forward (community report: \u0022wheels\r\n\t\t// rotate the wrong way\u0022). AngularVelocity is \u002Bforward, so the pitch angle is used as-is.\r\n\t\t// Per-frame is correct for SPIN: the accumulator integrates with frame dt, and rotation\r\n\t\t// interpolates in its own buffer independent of the fixed-tick position write below.\r\n\t\tLocalRotation = _baseRotation * Rotation.FromPitch( _spinDegrees );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Suspension tracking runs at the FIXED tick, not per frame (ramp-hitch fix, 2026-07-21,\r\n\t/// LIVE-UNVERIFIED). \u003Csee cref=\u0022VehicleWheel.SuspensionLength\u0022/\u003E is a raw physics-tick field:\r\n\t/// writing it to LocalPosition per RENDER frame is the documented sawtooth anti-pattern (KB\r\n\t/// g-game-camera-follows-raw-fixedtick-feet-model-sawtooths): the body renders engine-interpolated\r\n\t/// while the wheels step at 50 Hz, so wheels judder against the body exactly where suspension\r\n\t/// length changes fast (ramp faces, transients), worse with speed and refresh rate; a wheel that\r\n\t/// unloads for one tick snapped 5-10 cm to full droop and back inside 1-2 frames. Owner\r\n\t/// discriminator: fps_max 50 (render rate = tick rate) made the felt ramp hitch \u0022a million times\r\n\t/// better\u0022. Writing inside OnFixedUpdate lets GameTransform interpolation carry the motion per\r\n\t/// frame, exactly like the chassis (KB g-game-manual-visual-smoother-fights-fixedupdate-\r\n\t/// interpolation: never hand-smooth what engine interpolation already covers).\r\n\t/// \u003C/summary\u003E\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( Wheel is null )\r\n\t\t\treturn;\r\n\r\n\t\tLocalPosition = Vector3.Down * Wheel.SuspensionLength * Units.MetersToUnits;\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"CarDefinition.cs","FileName":"CarDefinition.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\npublic enum DriveLayout\r\n{\r\n\tFWD,\r\n\tRWD,\r\n\tAWD\r\n}\r\n\r\npublic enum BodyStyle\r\n{\r\n\tBox,  // sedan/hatch blockout: body \u002B cabin\r\n\tKart  // flat deck \u002B seat back \u002B citizen driver\r\n}\r\n\r\npublic enum AssistLevel\r\n{\r\n\tCasual, // full traction control \u002B yaw-stability damping \u002B ABS\r\n\tSport,  // ABS \u002B optional REDUCED-authority TC / yaw-stability (per-car opt-in; drift feel preserved)\r\n\tSim     // nothing\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// Complete tuning spec for one car. Plain class, instantiated from \u003Csee cref=\u0022CarDefinitions\u0022/\u003E;\r\n/// promote to a [GameResource] .car asset once an editor workflow is in play so designers can tune\r\n/// without recompiling. All values SI: meters, kg, N, N-m, N/m.\r\n/// \u003C/summary\u003E\r\npublic class CarDefinition\r\n{\r\n\tpublic string Name { get; set; } = \u0022Car\u0022;\r\n\tpublic BodyStyle Style { get; set; } = BodyStyle.Box;\r\n\tpublic bool HasDriver { get; set; }\r\n\r\n\t/// \u003Csummary\u003EOptional opaque body-manifest path for a consumer-supplied custom body builder\r\n\t/// (see \u003Csee cref=\u0022VehicleFactory.CustomBodyBuilder\u0022/\u003E). The kit itself never reads it \u2014 with no\r\n\t/// custom builder plugged in, the factory builds a primitive blockout body and this value is\r\n\t/// inert. It exists so a consumer\u0027s builder (e.g. a part-kit assembler) can look up which body to\r\n\t/// assemble for this car. null keeps the blockout path byte-for-byte unchanged.\u003C/summary\u003E\r\n\tpublic string BodyManifest { get; set; }\r\n\r\n\t// Chassis\r\n\tpublic float Mass { get; set; } = 1200f;\r\n\tpublic Vector3 BodySize { get; set; } = new( 4.0f, 1.8f, 1.3f ); // length, width, height (m)\r\n\tpublic float Wheelbase { get; set; } = 2.55f;\r\n\tpublic float TrackWidth { get; set; } = 1.55f;\r\n\tpublic float RideHeight { get; set; } = 0.35f; // chassis center above wheel attach plane\r\n\tpublic float GroundClearance { get; set; } = 0.14f; // collider bottom above ground at rest\r\n\tpublic float CenterOfMassDrop { get; set; } = 0.20f; // CoM below chassis center; must stay above wheel plane\r\n\r\n\t// Wheels \u0026 suspension (per wheel)\r\n\tpublic float WheelRadius { get; set; } = 0.31f;\r\n\tpublic float WheelInertia { get; set; } = 1.2f; // kg m^2\r\n\tpublic float SuspensionTravel { get; set; } = 0.20f;\r\n\tpublic float SpringRate { get; set; } = 38000f;\r\n\tpublic float DamperRate { get; set; } = 2800f;\r\n\r\n\t// Tires\r\n\tpublic TireCurve LongitudinalCurve { get; set; } = TireCurve.Street;\r\n\tpublic TireCurve LateralCurve { get; set; } = new( 0.14f, 1.00f, 0.55f, 0.80f ); // radians: peak ~8 deg\r\n\tpublic float LoadSensitivity { get; set; } = 0.06f;\r\n\r\n\t// Drivetrain\r\n\tpublic DriveLayout Layout { get; set; } = DriveLayout.FWD;\r\n\t// \u002B20% speed pass 2026-07-21: baseline PeakTorque 150-\u003E180 and RedlineRpm 6500-\u003E7800 so a bare\r\n\t// kit consumer starts from the new faster baseline (the demo roster below overrides both per car).\r\n\tpublic float PeakTorque { get; set; } = 180f;\r\n\tpublic float IdleRpm { get; set; } = 900f;\r\n\tpublic float RedlineRpm { get; set; } = 7800f;\r\n\tpublic float EngineInertia { get; set; } = 0.25f; // kg m^2 at crank\r\n\tpublic float EngineBrakeTorque { get; set; } = 40f;\r\n\tpublic float[] GearRatios { get; set; } = { 3.6f, 2.1f, 1.4f, 1.05f, 0.85f };\r\n\tpublic float ReverseRatio { get; set; } = 3.4f;\r\n\tpublic float FinalDrive { get; set; } = 3.9f;\r\n\tpublic float ShiftUpRpm { get; set; } = 5800f;\r\n\tpublic float ShiftDownRpm { get; set; } = 2200f;\r\n\r\n\t// Engine audio (read by EngineAudio). Per-class dials so a small buzzy kart and a deep\r\n\t// sedan/truck share one code path:\r\n\t//   EngineSoundEvent     \u2014 the LOW-RPM loop this class plays (idle/low, the deep layer).\r\n\t//   EngineSoundEventHigh \u2014 the HIGH-RPM loop, crossfaded in as revs climb. When null/empty the\r\n\t//     class runs the legacy SINGLE-loop model (one handle, wide pitch sweep) \u2014 the kart keeps\r\n\t//     this so its single synth purr is unchanged. When set, EngineAudio blends the two recorded\r\n\t//     layers by RPM, and each layer only pitch-shifts a narrow band around its recorded pitch\r\n\t//     (no far stretch = no screech), which is the point of the layered model.\r\n\t//   EnginePitchBase      \u2014 multiplies the pitch of whichever model runs, so a class reads higher\r\n\t//     or lower overall (kart \u003E1 stays buzzy, truck \u003C1 sits deep).\r\n\tpublic string EngineSoundEvent { get; set; } = \u0022sounds/engine/engine_real_low.sound\u0022;\r\n\tpublic string EngineSoundEventHigh { get; set; }\r\n\tpublic float EnginePitchBase { get; set; } = 1f;\r\n\r\n\t// Brakes\r\n\tpublic float BrakeTorque { get; set; } = 2400f; // total, split by bias\r\n\tpublic float BrakeBias { get; set; } = 0.62f;   // fraction to front\r\n\tpublic float HandbrakeTorque { get; set; } = 3000f; // rear wheels only\r\n\r\n\t// ABS dials (per-car). 0.3/0.55 because telemetry showed the ABS duty cycle, not tire grip, was\r\n\t// the brake limiter, and per-car ABS modulation is a legitimate class trait: the locking kart\r\n\t// needs a different release than the truck. Active in Casual \u002B Sport.\r\n\tpublic float AbsSlipThreshold { get; set; } = 0.25f; // release when SlipRatio \u003C -this under braking\r\n\tpublic float AbsReleaseFactor { get; set; } = 0.70f; // brake torque multiplier while released\r\n\r\n\t// Drift-exit soft-lock: cap the handbrake-INDUCED rear slip ratio. When a cap is active and a rear\r\n\t// wheel is already sliding PAST it, its handbrake torque is withheld that substep (ABS-style duty\r\n\t// cycle) so the rear spins back up toward the cap and keeps some rotation \u2014 hence some lateral bite\r\n\t// \u2014 mid-slide. Default -1.0 = NO effective cap (byte-identical full-lock behavior).\r\n\tpublic float HandbrakeSlipCap { get; set; } = -1.0f;\r\n\r\n\t// Steering\r\n\tpublic float MaxSteerAngle { get; set; } = 32f; // degrees at standstill\r\n\tpublic float HighSpeedSteerAngle { get; set; } = 8f; // degrees at/above 30 m/s\r\n\r\n\t// Arcade feel dials (defaults = the original sim-leaning behavior)\r\n\tpublic float SteerRateScale { get; set; } = 1f;    // multiplies how fast steering ramps\r\n\t// Reverse speed pass 2026-07-21 (owner feel): raised 4.5 -\u003E 20.0 m/s (~10 -\u003E ~45 mph). This is the\r\n\t// ACTUAL reverse cap mechanism (VehicleController cuts reverse throttle above this speed), and the\r\n\t// game roster inherits it (only the pickup used to override it). Each car is still additionally\r\n\t// limited by its reverse-gear redline-equivalent wheel speed (RedlineRpm/ReverseRatio/FinalDrive/\r\n\t// WheelRadius via Drivetrain.RedlineWheelSpeed), so torquey-but-tall reverse gears self-limit below\r\n\t// this cap (kart ~8 m/s, pickup ~12 m/s) while the cars reach the high-30s/40s mph.\r\n\tpublic float ReverseSpeedCap { get; set; } = 20.0f; // m/s before reverse throttle cuts\r\n\tpublic float LaunchBoost { get; set; } = 1f;       // torque multiplier at standstill, fades out by ~54 km/h\r\n\tpublic float BrakeAssist { get; set; } = 0f;       // extra chassis-level decel while braking (m/s\u00B2)\r\n\t// Spin-recovery assist: after a handbrake spin the car keeps rolling BACKWARDS (old travel\r\n\t// direction) while the player holds throttle the new way \u2014 BrakeAssist can\u0027t help there (forward\r\n\t// throttle sets Brake=0), so nothing arcade-level arrests the stale velocity. This is extra\r\n\t// chassis-level decel along -velocity, applied ONLY when input throttle opposes the ground velocity\r\n\t// along the car\u0027s facing, fading out as the car rotates to face its motion\r\n\t// (VehicleController.ApplySpinRecoveryAssist). Same m/s\u00B2 unit \u002B never-reverse-within-a-step cap as\r\n\t// BrakeAssist; gated Assists != Sim. 0 disables.\r\n\tpublic float SpinRecoveryAssist { get; set; } = 0f; // extra chassis decel killing stale opposing velocity (m/s\u00B2)\r\n\tpublic float HandbrakeGripScale { get; set; } = 1f; // rear grip multiplier while handbrake held (\u003C1 = drift button)\r\n\r\n\t// Wall-glance forgiveness assist: a sustained near-horizontal chassis contact while moving\r\n\t// re-projects velocity along the wall tangent and gently yaws the heading to match, scaled by\r\n\t// incidence (VehicleController.ApplyWallGlanceAssist). Active only when Assists != Sim. Head-on hits\r\n\t// (incidence \u003E= WallGlanceHeadOnDeg) get NO assist \u2014 frontal crashes stay hard stops.\r\n\tpublic bool WallGlanceAssist { get; set; } = true;\r\n\tpublic float WallScrubFactor { get; set; } = 0.75f;    // fraction of speed kept along the wall tangent on a shallow graze\r\n\tpublic float WallGlanceShallowDeg { get; set; } = 35f; // at/below this velocity-vs-wall incidence: full assist\r\n\tpublic float WallGlanceHeadOnDeg { get; set; } = 60f;  // at/above this incidence: no assist (hard stop preserved)\r\n\tpublic float WallAlignStrength { get; set; } = 6f;     // yaw-align rate toward the wall tangent (per second)\r\n\r\n\t// \u2500\u2500 Sport-mode stability posture (owner call 2026-07-21) \u2500\u2500\r\n\t// Sport historically ran with NO traction control and NO yaw-stability damping (both Casual-only),\r\n\t// so a full-throttle RWD car spun the rears to redline and the counter-steer pendulum went divergent\r\n\t// \u2014 uncatchable spin-outs. These give Sport a REDUCED-authority version of each assist, opt-in per\r\n\t// car. Both default to 0 (disabled), so any car that doesn\u0027t set them keeps the raw \u0022ABS only\u0022 Sport\r\n\t// and \u2014 critically \u2014 Casual and Sim stay byte-identical (the controller multiplies Casual authority\r\n\t// by exactly 1 and returns early for Sim, unchanged).\r\n\r\n\t// Sport traction control. When \u003E0, Sport runs the same proportional TC as Casual\r\n\t// (VehicleController.ApplyTractionControl) but holds driven-wheel slip near THIS ratio instead of the\r\n\t// Casual 0.14 grip-peak target. Set it LOOSER than the peak (e.g. 0.30-0.45) so the rears still break\r\n\t// into a throttle-steerable slide (drift stays alive) while the redline free-spin that torches rear\r\n\t// lateral grip is capped. 0 = no Sport TC (raw wheelspin, the old behavior).\r\n\tpublic float SportTcSlipTarget { get; set; } = 0f;\r\n\r\n\t// Sport yaw-stability. When \u003E0, Sport runs the yaw-rate damper (VehicleController.ApplyStabilityAssist)\r\n\t// at THIS fraction of the Casual authority (0-1). It bleeds the stored yaw rate that snaps a\r\n\t// counter-steered slide the OTHER way (the pendulum spin-out) so slides stay CATCHABLE, without\r\n\t// killing deliberate rotation the way full Casual authority would. 0 = no Sport yaw damping.\r\n\tpublic float SportStabilityScale { get; set; } = 0f;\r\n\r\n\t// Driver seated pose (citizen animgraph; sit enum: 0 none, 1-3 chair poses, 4-5 ground poses).\r\n\t// Defaults = the original upright chair pose. Made per-car so a recumbent kart driver \u2014 legs\r\n\t// extended forward to the pedals \u2014 can be authored without disturbing any upright-seated car.\r\n\tpublic int DriverSit { get; set; } = 1;\r\n\tpublic float DriverSitOffsetHeight { get; set; } = 4f;\r\n\r\n\t// Defaults\r\n\tpublic AssistLevel DefaultAssists { get; set; } = AssistLevel.Casual;\r\n\tpublic Color Tint { get; set; } = new( 0.85f, 0.55f, 0.35f );\r\n}\r\n\r\n/// \u003Csummary\u003ECar roster. Hatch is the default/first car; Kart and Coupe round out the roster.\r\n/// These are blockout-body demo definitions \u2014 physics is identical whether a car renders as a\r\n/// primitive blockout or a consumer-supplied custom body.\u003C/summary\u003E\r\npublic static class CarDefinitions\r\n{\r\n\t/// \u003Csummary\u003EThe default/first roster car \u2014 an ORANGE hot hatch. FWD, mid-power, street tires.\u003C/summary\u003E\r\n\tpublic static CarDefinition Hatch =\u003E new()\r\n\t{\r\n\t\tName = \u0022Compact Hatch\u0022,\r\n\t\tMass = 1150f,\r\n\t\tBodySize = new Vector3( 3.9f, 1.75f, 1.45f ),\r\n\t\tWheelbase = 2.55f,\r\n\t\tTrackWidth = 1.50f,\r\n\t\tWheelRadius = 0.30f,\r\n\t\tLayout = DriveLayout.FWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 162-\u003E194.4, RedlineRpm 6300-\u003E7560 (top-gear top speed \u002B20%).\r\n\t\tPeakTorque = 194.4f,\r\n\t\tRedlineRpm = 7560f,\r\n\t\t// Real recorded car set: deep muscle idle crossfaded up to a revving high layer.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_real_low.sound\u0022,\r\n\t\tEngineSoundEventHigh = \u0022sounds/engine/engine_real_high.sound\u0022,\r\n\t\tLongitudinalCurve = new TireCurve( 0.10f, 1.35f, 0.45f, 1.08f ),\r\n\t\tBrakeTorque = 4300f,\r\n\t\tLateralCurve = new TireCurve( 0.14f, 1.30f, 0.55f, 1.04f ),\r\n\t\tHandbrakeGripScale = 0.55f, // the drift button \u2014 rear grip cut while handbrake held; also the FWD J-turn lever\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tHighSpeedSteerAngle = 9.5f,\r\n\t\tSpringRate = 34000f,\r\n\t\tDamperRate = 2500f,\r\n\t\tTint = new Color( 0.93f, 0.42f, 0.03f ), // orange\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EFull-size pickup. Heaviest in roster, RWD, torquey low-rev engine, longer-travel\r\n\t/// softer suspension, offroad-leaning tires, high ride. Signature strength = hill grade.\u003C/summary\u003E\r\n\tpublic static CarDefinition Pickup =\u003E new()\r\n\t{\r\n\t\tName = \u0022Utility Pickup\u0022,\r\n\t\tMass = 1900f,\r\n\t\tBodySize = new Vector3( 5.2f, 1.95f, 1.55f ),\r\n\t\tWheelbase = 3.40f,\r\n\t\tTrackWidth = 1.70f,\r\n\t\tRideHeight = 0.44f,          // high ride: the class signature (hatch 0.35)\r\n\t\tGroundClearance = 0.24f,\r\n\t\tCenterOfMassDrop = 0.15f,    // higher CoM than the cars = truck-like roll; track 1.70 keeps rollover margin\r\n\t\tWheelRadius = 0.35f,\r\n\t\tWheelInertia = 2.4f,\r\n\t\tSuspensionTravel = 0.26f,    // longest in roster \u2014 washboard/offroad strength\r\n\t\tSpringRate = 42000f,\r\n\t\tDamperRate = 3400f,\r\n\t\tLongitudinalCurve = new TireCurve( 0.14f, 1.25f, 0.60f, 1.05f ),\r\n\t\tLateralCurve = new TireCurve( 0.15f, 1.22f, 0.60f, 1.06f ),\r\n\t\tLoadSensitivity = 0.07f,\r\n\t\tLayout = DriveLayout.RWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 320-\u003E384, RedlineRpm 3900-\u003E4680 (top-gear top speed \u002B20%).\r\n\t\tPeakTorque = 384f,           // torquey: \u003E2\u00D7 hatch; strong hills\r\n\t\tIdleRpm = 650f,\r\n\t\tRedlineRpm = 4680f,          // lowest redline in roster; low-rev truck character (was 3900, \u002B20% pass)\r\n\t\tEngineInertia = 0.5f,\r\n\t\tEngineBrakeTorque = 90f,     // strong engine braking downhill\r\n\t\t// Real recorded truck set: deeper truck idle \u002B revving high layer; base pitch dropped.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_real_truck_low.sound\u0022,\r\n\t\tEngineSoundEventHigh = \u0022sounds/engine/engine_real_truck_high.sound\u0022,\r\n\t\tEnginePitchBase = 0.85f,\r\n\t\tGearRatios = new[] { 3.8f, 2.3f, 1.5f, 1.1f, 0.85f },\r\n\t\tReverseRatio = 3.8f,\r\n\t\tFinalDrive = 3.9f,\r\n\t\tShiftUpRpm = 3500f,\r\n\t\tShiftDownRpm = 1700f,\r\n\t\tBrakeTorque = 7000f,\r\n\t\tBrakeBias = 0.65f,           // unladen bed = light rear axle\r\n\t\tHandbrakeTorque = 5000f,\r\n\t\tMaxSteerAngle = 27f,         // slow truck steering; long wheelbase stabilizes\r\n\t\tHighSpeedSteerAngle = 8f,\r\n\t\tSteerRateScale = 0.9f,\r\n\t\t// Reverse cap override removed 2026-07-21: inherits the new 20 m/s default; the pickup\u0027s tall reverse\r\n\t\t// gear \u002B low redline self-limit it to ~12 m/s (~26 mph) reverse, so it stays the roster\u0027s slowest\r\n\t\t// reverse by gearing character rather than an artificial ~9 mph clamp.\r\n\t\tHandbrakeGripScale = 0.45f,  // deepest rear cut in the roster \u2014 1900 kg \u002B 3.4 m wheelbase needs real help\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tSportTcSlipTarget = 0.30f,   // Sport keeps the torquey RWD truck from lighting the rears to redline\r\n\t\tSportStabilityScale = 0.5f,  // half-authority yaw damp so a Sport slide stays catchable\r\n\t\tDefaultAssists = AssistLevel.Casual,\r\n\t\tTint = new Color( 0.55f, 0.13f, 0.11f ), // dark brick red\r\n\t};\r\n\r\n\tpublic static CarDefinition Kart =\u003E new()\r\n\t{\r\n\t\tName = \u0022Go-Kart\u0022,\r\n\t\tStyle = BodyStyle.Kart,\r\n\t\tHasDriver = true,\r\n\t\tMass = 260f,\r\n\t\tBodySize = new Vector3( 1.9f, 1.15f, 0.30f ),\r\n\t\tWheelbase = 1.55f,\r\n\t\tTrackWidth = 1.14f,\r\n\t\tRideHeight = 0.17f,\r\n\t\tGroundClearance = 0.08f,\r\n\t\tCenterOfMassDrop = 0.02f, // tiny chassis: a deep drop puts CoM below the wheels\r\n\t\tWheelRadius = 0.16f,\r\n\t\tWheelInertia = 0.18f,\r\n\t\tSuspensionTravel = 0.14f,\r\n\t\tSpringRate = 24000f,\r\n\t\tDamperRate = 1600f,\r\n\t\tLayout = DriveLayout.RWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 52-\u003E62.4, RedlineRpm 9000-\u003E10800 (top-gear top speed \u002B20%).\r\n\t\tPeakTorque = 62.4f, // punchy launch, still shifts out of wheelspin quickly\r\n\t\tIdleRpm = 1400f,\r\n\t\tRedlineRpm = 10800f,\r\n\t\tEngineInertia = 0.05f,\r\n\t\tEngineBrakeTorque = 8f,\r\n\t\t// The kart keeps the buzzier single loop; base pitch \u003E1 preserves its whine, while the narrowed\r\n\t\t// band tames the redline chipmunk.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_b_sport_purr.sound\u0022,\r\n\t\tEnginePitchBase = 1.2f,\r\n\t\tGearRatios = new[] { 3.4f, 2.4f, 1.8f, 1.4f, 1.1f },\r\n\t\tReverseRatio = 3.4f,\r\n\t\tFinalDrive = 6.3f,\r\n\t\tShiftUpRpm = 8000f,\r\n\t\tShiftDownRpm = 4000f,\r\n\t\tBrakeTorque = 560f,\r\n\t\tHandbrakeTorque = 900f,\r\n\t\tMaxSteerAngle = 31f,\r\n\t\tHighSpeedSteerAngle = 9f,\r\n\t\t// sticky kart slicks\r\n\t\tLongitudinalCurve = new TireCurve( 0.09f, 1.55f, 0.40f, 1.24f ),\r\n\t\tLateralCurve = new TireCurve( 0.12f, 1.66f, 0.45f, 1.32f ),\r\n\t\tAbsSlipThreshold = 0.20f, // earlier release for the lockup-prone kart\r\n\t\tHandbrakeGripScale = 0.70f, // mild rear cut \u2014 the light kart already rotates\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tSportTcSlipTarget = 0.40f,  // loosest Sport TC \u2014 the light kart is meant to be playful\r\n\t\tSportStabilityScale = 0.45f, // gentle yaw damp; the kart rotates easily so keep it light\r\n\t\tHandbrakeSlipCap = -0.7f, // keep the rears rotating mid-slide for a cleaner drift exit\r\n\t\t// Recumbent kart driver pose: reclines with legs extended forward to the pedals.\r\n\t\tDriverSit = 4,\r\n\t\tDriverSitOffsetHeight = 0f,\r\n\t\tDefaultAssists = AssistLevel.Casual, // default fun car: keep it catchable\r\n\t\tTint = new Color( 0.58f, 0.83f, 0.07f ), // acid green\r\n\t};\r\n\r\n\tpublic static CarDefinition Coupe =\u003E new()\r\n\t{\r\n\t\tName = \u0022Sports Coupe\u0022,\r\n\t\tMass = 1420f,\r\n\t\tBodySize = new Vector3( 4.4f, 1.85f, 1.25f ),\r\n\t\tWheelbase = 2.7f,\r\n\t\tTrackWidth = 1.60f,\r\n\t\tLayout = DriveLayout.RWD,\r\n\t\t// \u002B20% speed pass 2026-07-21: PeakTorque 340-\u003E408, RedlineRpm 7200-\u003E8640 (top-gear top speed \u002B20%).\r\n\t\t// ShiftUpRpm left at 6600 (unchanged) so the shift ladder holds in ground-speed terms; only the\r\n\t\t// top gear extends to the new redline.\r\n\t\tPeakTorque = 408f,\r\n\t\tRedlineRpm = 8640f,\r\n\t\tShiftUpRpm = 6600f,\r\n\t\t// Real recorded car set (shared with the hatch): deep muscle idle crossfaded up to a\r\n\t\t// revving high layer; base pitch neutral so it sweeps the band cleanly.\r\n\t\tEngineSoundEvent = \u0022sounds/engine/engine_real_low.sound\u0022,\r\n\t\tEngineSoundEventHigh = \u0022sounds/engine/engine_real_high.sound\u0022,\r\n\t\tEnginePitchBase = 1.0f,\r\n\t\tLongitudinalCurve = new TireCurve( 0.09f, 1.50f, 0.40f, 1.20f ),\r\n\t\tLateralCurve = new TireCurve( 0.13f, 1.69f, 0.50f, 1.36f ),\r\n\t\tHandbrakeGripScale = 0.55f, // rears break loose under handbrake (J-turn initiation)\r\n\t\tHandbrakeSlipCap = -0.7f,\r\n\t\tSpringRate = 46000f,\r\n\t\tDamperRate = 3600f,\r\n\t\tBrakeTorque = 6200f,\r\n\t\tWheelRadius = 0.33f,\r\n\t\tSpinRecoveryAssist = 7.0f,\r\n\t\tSportTcSlipTarget = 0.35f,   // Sport TC: rears still slide on throttle but can\u0027t free-spin to redline\r\n\t\tSportStabilityScale = 0.5f,  // half-authority yaw damp: the counter-steer pendulum stays catchable\r\n\t\tMaxSteerAngle = 30f,\r\n\t\tHighSpeedSteerAngle = 10f, // sportiest car gets the most high-speed turn-in\r\n\t\tTint = new Color( 0.80f, 0.05f, 0.07f ), // bright signal red\r\n\t};\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/WheelVisual.cs","FileName":"WheelVisual.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Spins and vertically tracks the wheel\u0027s visual mesh from VehicleWheel state.\r\n/// Steering yaw lives on the wheel GameObject itself (set by VehicleController);\r\n/// spin composes with whatever base orientation the factory gave the visual.\r\n/// \u003C/summary\u003E\r\npublic sealed class WheelVisual : Component\r\n{\r\n\tpublic VehicleWheel Wheel { get; set; }\r\n\r\n\tRotation _baseRotation;\r\n\tfloat _spinDegrees;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_baseRotation = LocalRotation;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Wheel is null )\r\n\t\t\treturn;\r\n\r\n\t\t_spinDegrees \u002B= Wheel.AngularVelocity.RadianToDegree() * Time.Delta;\r\n\t\t_spinDegrees %= 360f;\r\n\r\n\t\t// Spin about the model-local \u002BY axle. FromPitch uses the Source pitch sign (positive pitch\r\n\t\t// tilts the local \u002BX forward vector DOWN), so a POSITIVE angle here rolls the top of the wheel\r\n\t\t// toward the car\u0027s \u002BX travel direction, the correct rolling sense for forward motion. The old\r\n\t\t// negated angle rolled the tread backwards while driving forward (community report: \u0022wheels\r\n\t\t// rotate the wrong way\u0022). AngularVelocity is \u002Bforward, so the pitch angle is used as-is.\r\n\t\t// Per-frame is correct for SPIN: the accumulator integrates with frame dt, and rotation\r\n\t\t// interpolates in its own buffer independent of the fixed-tick position write below.\r\n\t\tLocalRotation = _baseRotation * Rotation.FromPitch( _spinDegrees );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Suspension tracking runs at the FIXED tick, not per frame (ramp-hitch fix, 2026-07-21,\r\n\t/// LIVE-UNVERIFIED). \u003Csee cref=\u0022VehicleWheel.SuspensionLength\u0022/\u003E is a raw physics-tick field:\r\n\t/// writing it to LocalPosition per RENDER frame is the documented sawtooth anti-pattern (KB\r\n\t/// g-game-camera-follows-raw-fixedtick-feet-model-sawtooths): the body renders engine-interpolated\r\n\t/// while the wheels step at 50 Hz, so wheels judder against the body exactly where suspension\r\n\t/// length changes fast (ramp faces, transients), worse with speed and refresh rate; a wheel that\r\n\t/// unloads for one tick snapped 5-10 cm to full droop and back inside 1-2 frames. Owner\r\n\t/// discriminator: fps_max 50 (render rate = tick rate) made the felt ramp hitch \u0022a million times\r\n\t/// better\u0022. Writing inside OnFixedUpdate lets GameTransform interpolation carry the motion per\r\n\t/// frame, exactly like the chassis (KB g-game-manual-visual-smoother-fights-fixedupdate-\r\n\t/// interpolation: never hand-smooth what engine interpolation already covers).\r\n\t/// \u003C/summary\u003E\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( Wheel is null )\r\n\t\t\treturn;\r\n\r\n\t\tLocalPosition = Vector3.Down * Wheel.SuspensionLength * Units.MetersToUnits;\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Demo/DemoBootstrap.cs","FileName":"DemoBootstrap.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Minimal demo bootstrap for the Vehicle Physics Kit\u0027s demo scene. Spawns one blockout car per\r\n/// \u003Csee cref=\u0022CarDefinitions\u0022/\u003E roster entry in a row on the flat pad, and points the scene\u0027s\r\n/// \u003Csee cref=\u0022VehicleCamera\u0022/\u003E at the first one. Proves the kit drives in isolation; not part of the\r\n/// consumer surface (the demo scene is the only thing that references it).\r\n/// \u003C/summary\u003E\r\npublic sealed class DemoBootstrap : Component\r\n{\r\n\t/// \u003Csummary\u003ESpacing between spawned cars along the row (metres).\u003C/summary\u003E\r\n\t[Property] public float SpacingMeters { get; set; } = 5f;\r\n\r\n\t/// \u003Csummary\u003ECars falling below this world height (metres) are placed back on their spawn spot.\u003C/summary\u003E\r\n\t[Property] public float VoidResetHeightM { get; set; } = -20f;\r\n\r\n\t/// \u003Csummary\u003EHeld by every non-active car: neutral pedals, handbrake on. A parked car should be parked.\u003C/summary\u003E\r\n\tstatic DriveInputs ParkedInputs =\u003E new() { Handbrake = true };\r\n\r\n\treadonly List\u003CVehicleController\u003E _cars = new();\r\n\treadonly List\u003CVector3\u003E _spawns = new();\r\n\tVehicleController _active;\r\n\tVehicleCamera _camera;\r\n\tDemoTuningPanel _panel;\r\n\tGameObject _hud;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t// ~1.1 g like the source proving ground, so the tuned handling feels right. Read live by the\r\n\t\t// factory\u0027s static-load and seat-height math, so setting it here keeps every car level on spawn.\r\n\t\tif ( Scene.PhysicsWorld is not null )\r\n\t\t\tScene.PhysicsWorld.Gravity = Vector3.Down * 9.81f * 1.1f * Units.MetersToUnits;\r\n\r\n\t\tvar roster = new[]\r\n\t\t{\r\n\t\t\tCarDefinitions.Hatch,\r\n\t\t\tCarDefinitions.Coupe,\r\n\t\t\tCarDefinitions.Kart,\r\n\t\t\tCarDefinitions.Pickup,\r\n\t\t};\r\n\r\n\t\tfloat m = Units.MetersToUnits;\r\n\t\tfloat startY = -(roster.Length - 1) * SpacingMeters * 0.5f;\r\n\r\n\t\t_cars.Clear();\r\n\t\t_spawns.Clear();\r\n\t\tfor ( int i = 0; i \u003C roster.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar def = roster[i];\r\n\t\t\tfloat seatZ = VehicleFactory.SeatHeightM( def );\r\n\t\t\tvar pos = new Vector3( 0f, startY \u002B i * SpacingMeters, seatZ ) * m;\r\n\r\n\t\t\tvar go = VehicleFactory.Spawn( Scene, def, pos, Rotation.Identity );\r\n\t\t\tvar controller = go.Components.Get\u003CVehicleController\u003E();\r\n\t\t\tif ( controller is not null )\r\n\t\t\t{\r\n\t\t\t\t// Park it IMMEDIATELY, before its first physics tick can sample a live device\r\n\t\t\t\t// (a resting gamepad trigger past deadzone reads as brake and latches reverse).\r\n\t\t\t\tcontroller.InputOverride = ParkedInputs;\r\n\t\t\t\t_cars.Add( controller );\r\n\t\t\t\t_spawns.Add( pos );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t_active = _cars.FirstOrDefault();\r\n\t\tif ( _active is not null )\r\n\t\t\t_active.InputOverride = null;\r\n\r\n\t\t// Point the scene\u0027s chase camera at the active car so the demo frames it on load.\r\n\t\t_camera = Scene.GetAllComponents\u003CVehicleCamera\u003E().FirstOrDefault();\r\n\t\tif ( _camera is not null )\r\n\t\t\t_camera.Target = _active;\r\n\r\n\t\tMountTuningLab();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( _cars.Count \u003C 2 )\r\n\t\t\treturn;\r\n\r\n\t\t// Bracket cycling, mirroring Vehicle Prototyping\u0027s bindings ( [ / ] , d-pad left/right ).\r\n\t\t// OnFixedUpdate\u0027s per-tick override pass handles the rest: the old car parks (handbrake),\r\n\t\t// the new one starts listening to the player.\r\n\t\tint step = Input.Pressed( \u0022CycleNext\u0022 ) ? 1 : Input.Pressed( \u0022CyclePrev\u0022 ) ? -1 : 0;\r\n\t\tif ( step == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tint i = _cars.IndexOf( _active );\r\n\t\ti = ((i \u002B step) % _cars.Count \u002B _cars.Count) % _cars.Count;\r\n\t\t_active = _cars[i];\r\n\r\n\t\tif ( _camera is not null )\r\n\t\t\t_camera.Target = _active;\r\n\t\tif ( _panel is not null )\r\n\t\t\t_panel.Car = _active; // rebinds the lab and re-snapshots stock values for the new car\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EStand up the demo-layer live tuning lab: a ScreenPanel-hosted \u003Csee cref=\u0022DemoTuningPanel\u0022/\u003E\r\n\t/// bound to the active car, and the camera\u0027s cursor-yield seam pointed at its open state. This exists\r\n\t/// only in the demo scene; consumers that spawn their own cars never get it. It doubles as a working\r\n\t/// example of the \u003Csee cref=\u0022VehicleCamera.CursorModalOpen\u0022/\u003E seam being consumed from the demo side.\u003C/summary\u003E\r\n\tvoid MountTuningLab()\r\n\t{\r\n\t\tif ( _active is null )\r\n\t\t\treturn;\r\n\r\n\t\t// Start collapsed to the chip legend: an open lab on spawn captured the cursor before\r\n\t\t// players ever drove (owner call, 2026-07-19). The chip keeps T discoverable; reset the\r\n\t\t// static flag here each session.\r\n\t\tDemoTuningPanel.IsOpen = false;\r\n\r\n\t\t_hud = Scene.CreateObject();\r\n\t\t_hud.Name = \u0022Tuning HUD\u0022;\r\n\t\t_hud.Components.GetOrCreate\u003CScreenPanel\u003E();\r\n\r\n\t\t_panel = _hud.Components.Create\u003CDemoTuningPanel\u003E();\r\n\t\t_panel.Car = _active;\r\n\r\n\t\t// Dogfood the kit\u0027s cursor-yield seam: while the lab is open the chase camera must release the\r\n\t\t// cursor so the player can click dials. This is exactly what the seam is for; wiring it here\r\n\t\t// proves it to consumers.\r\n\t\tVehicleCamera.CursorModalOpen = () =\u003E DemoTuningPanel.IsOpen;\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tfloat m = Units.MetersToUnits;\r\n\r\n\t\tfor ( int i = 0; i \u003C _cars.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar car = _cars[i];\r\n\t\t\tif ( car is null || !car.IsValid() )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t// Only the camera\u0027s car listens to the player; parked cars hold the handbrake.\r\n\t\t\t// (With no override every controller samples the same keyboard, so one W press\r\n\t\t\t// would launch the whole row at once; without the handbrake a parked car is\r\n\t\t\t// free-rolling and a device blip or spawn settle can walk it off its mark.)\r\n\t\t\tcar.InputOverride = car == _active ? null : ParkedInputs;\r\n\r\n\t\t\t// Void watchdog: driving off the pad edge otherwise means falling forever.\r\n\t\t\tif ( car.GameObject.WorldPosition.z \u003C VoidResetHeightM * m )\r\n\t\t\t{\r\n\t\t\t\tcar.GameObject.WorldPosition = _spawns[i];\r\n\t\t\t\tcar.GameObject.WorldRotation = Rotation.Identity;\r\n\t\t\t\tvar body = car.GameObject.Components.Get\u003CRigidbody\u003E();\r\n\t\t\t\tif ( body is not null )\r\n\t\t\t\t{\r\n\t\t\t\t\tbody.Velocity = Vector3.Zero;\r\n\t\t\t\t\tbody.AngularVelocity = Vector3.Zero;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Demo/DemoTuningPanel.razor","FileName":"DemoTuningPanel.razor","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"@namespace FieldGuide.VehiclePhysics\r\n@inherits PanelComponent\r\n\r\n@* Kit-native live tuning lab (demo layer). Toggled by the Tune action, bound to the car the chase\r\n   camera is following. Writes tuning changes straight onto the running car through the same paths a\r\n   consumer would use: it mutates the active CarDefinition (read live by the drivetrain and the brake\r\n   model) and pushes suspension/tire values onto the live wheels. This is a demo-scale lab, not the\r\n   full game panel: it exposes the highest-feel dials so the demo reads as a physics lab in the first\r\n   minute. Replace it with your own UI; it lives only in the demo scene. *@\r\n\r\n\u003Croot\u003E\r\n\t@if ( Car.IsValid() )\r\n\t{\r\n\t\t@* Key legend, top-right: how to hop between the demo cars. Always visible; the tuning\r\n\t\t   chip/panel top-left covers the T binding. *@\r\n\t\t\u003Cdiv class=\u0022legend\u0022\u003E\r\n\t\t\t\u003Cdiv class=\u0022mono kbd\u0022\u003E[ ]\u003C/div\u003E\r\n\t\t\t\u003Cdiv class=\u0022title\u0022\u003Eswitch car\u003C/div\u003E\r\n\t\t\u003C/div\u003E\r\n\t}\r\n\t@if ( !IsOpen \u0026\u0026 Car.IsValid() )\r\n\t{\r\n\t\t@* Collapsed legend chip: sits exactly where the expanded panel\u0027s top-left corner lands, so\r\n\t\t   pressing T reads as the chip expanding into the lab. Mouse-look stays with the camera. *@\r\n\t\t\u003Cdiv class=\u0022panel chip\u0022 onclick=@ToggleOpen\u003E\r\n\t\t\t\u003Cdiv class=\u0022head\u0022\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022mono kbd\u0022\u003E@ToggleKeyLabel\u003C/div\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022title\u0022\u003Etuning\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\t\t\u003C/div\u003E\r\n\t}\r\n\t@if ( IsOpen \u0026\u0026 Car.IsValid() )\r\n\t{\r\n\t\t\u003Cdiv class=\u0022panel\u0022\u003E\r\n\t\t\t\u003Cdiv class=\u0022head\u0022\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022title\u0022\u003ETuning lab \u00B7 @CarName\u003C/div\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022mono kbd\u0022\u003E@ToggleKeyLabel\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\r\n\t\t\t\u003Cdiv class=\u0022hint\u0022\u003ETuning the car you are driving. Changes apply live.\u003C/div\u003E\r\n\r\n\t\t\t\u003Cdiv class=\u0022dials\u0022\u003E\r\n\t\t\t\t@foreach ( var d in SliderDials )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar dd = d;\r\n\t\t\t\t\t\u003Cdiv class=\u0022dial\u0022\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022drow\u0022\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022dname\u0022\u003E@d.Name\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022mono dval\u0022\u003E@d.Display()\u003C/div\u003E\r\n\t\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022dctl\u0022\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022mono step\u0022 onclick=@( () =\u003E Step( dd, -1 ) )\u003E-\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022track\u0022\r\n\t\t\t\t\t\t\t\tonmousedown=@( e =\u003E Scrub( dd, e, true ) )\r\n\t\t\t\t\t\t\t\tonmousemove=@( e =\u003E Scrub( dd, e, false ) )\u003E\r\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022fill\u0022 style=\u0022width: @WidthPct( dd )%\u0022\u003E\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022mono step\u0022 onclick=@( () =\u003E Step( dd, \u002B1 ) )\u003E\u002B\u003C/div\u003E\r\n\t\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t}\r\n\r\n\t\t\t\t\u003Cdiv class=\u0022dial\u0022\u003E\r\n\t\t\t\t\t\u003Cdiv class=\u0022drow\u0022\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022dname\u0022\u003EAssists\u003C/div\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022mono cycle\u0022 onclick=@CycleAssists\u003E@AssistLabel\u003C/div\u003E\r\n\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\u003C/div\u003E\r\n\r\n\t\t\t\t\u003Cdiv class=\u0022dial\u0022\u003E\r\n\t\t\t\t\t\u003Cdiv class=\u0022drow\u0022\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022dname\u0022\u003ETires\u003C/div\u003E\r\n\t\t\t\t\t\t\u003Cdiv class=\u0022mono cycle\u0022 onclick=@CycleTires\u003E@TireLabel\u003C/div\u003E\r\n\t\t\t\t\t\u003C/div\u003E\r\n\t\t\t\t\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\r\n\t\t\t\u003Cdiv class=\u0022foot\u0022\u003E\r\n\t\t\t\t\u003Cdiv class=\u0022btn\u0022 onclick=@ResetToStock\u003EReset to stock\u003C/div\u003E\r\n\t\t\t\u003C/div\u003E\r\n\t\t\u003C/div\u003E\r\n\t}\r\n\u003C/root\u003E\r\n\r\n@code {\r\n\t/// \u003Csummary\u003EThe car this panel tunes: the one the chase camera follows. Set by DemoBootstrap. On\r\n\t/// change the panel snapshots the car\u0027s pristine (authored) values so Reset-to-stock can restore\r\n\t/// them and the grip/torque multipliers rebase to the new car.\u003C/summary\u003E\r\n\tVehicleController _car;\r\n\tpublic VehicleController Car\r\n\t{\r\n\t\tget =\u003E _car;\r\n\t\tset\r\n\t\t{\r\n\t\t\tif ( ReferenceEquals( _car, value ) )\r\n\t\t\t\treturn;\r\n\t\t\t_car = value;\r\n\t\t\tSnapshot();\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EOpen state. Static so the kit\u0027s chase camera can read it through the\r\n\t/// \u003Csee cref=\u0022VehicleCamera.CursorModalOpen\u0022/\u003E seam (DemoBootstrap wires that) without holding a\r\n\t/// reference to this panel. The demo runs exactly one panel, so a single flag is enough.\u003C/summary\u003E\r\n\tpublic static bool IsOpen;\r\n\r\n\t// Toggle input action. Documented in the README input table; the host ProjectSettings/Input.config\r\n\t// ships it. Never Escape or an F-key.\r\n\tconst string ToggleAction = \u0022Tune\u0022;\r\n\tstring ToggleKeyLabel =\u003E \u0022T\u0022;\r\n\r\n\tstring CarName =\u003E Car?.Definition?.Name ?? \u0022Car\u0022;\r\n\r\n\t// Live multipliers over the pristine base. Grip scales the (preset-selected) tire curves; torque\r\n\t// scales the authored peak engine torque.\r\n\tfloat _gripScale = 1f;\r\n\tfloat _torqueScale = 1f;\r\n\tint _tirePreset; // 0 Stock, 1 Street, 2 Sport, 3 Offroad\r\n\r\n\t// Pristine snapshot, captured value-by-value when the car is bound (its definition is untouched at\r\n\t// that point). Value types only (floats, TireCurve struct, enum), so later live tuning of the\r\n\t// definition can never corrupt these; Reset restores from here.\r\n\tbool _snapped;\r\n\tfloat _stockPeakTorque, _stockSpring, _stockDamper, _stockTravel, _stockBrake;\r\n\tTireCurve _stockLat, _stockLong;\r\n\tAssistLevel _stockAssists;\r\n\r\n\t// Current UNSCALED tire base (Stock or a named preset). Grip multiplies these to get the live curves.\r\n\tTireCurve _baseLat, _baseLong;\r\n\r\n\tvoid Snapshot()\r\n\t{\r\n\t\tvar def = _car?.Definition;\r\n\t\t_dials = null;\r\n\t\tif ( def is null )\r\n\t\t{\r\n\t\t\t_snapped = false;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_stockPeakTorque = def.PeakTorque;\r\n\t\t_stockSpring = def.SpringRate;\r\n\t\t_stockDamper = def.DamperRate;\r\n\t\t_stockTravel = def.SuspensionTravel;\r\n\t\t_stockBrake = def.BrakeTorque;\r\n\t\t_stockLat = def.LateralCurve;\r\n\t\t_stockLong = def.LongitudinalCurve;\r\n\t\t// Stock assist = the authored definition default. Read it off the definition, not the\r\n\t\t// controller: the controller adopts DefaultAssists in its own OnStart, which may run a frame\r\n\t\t// or two after this bind, so its live Assists is not reliable yet.\r\n\t\t_stockAssists = def.DefaultAssists;\r\n\r\n\t\t_baseLat = _stockLat;\r\n\t\t_baseLong = _stockLong;\r\n\t\t_gripScale = 1f;\r\n\t\t_torqueScale = 1f;\r\n\t\t_tirePreset = 0;\r\n\t\t_snapped = true;\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\t// ---- dial model ----\r\n\tclass Dial\r\n\t{\r\n\t\tpublic string Name;\r\n\t\tpublic float Min, Max, Step;\r\n\t\tpublic Func\u003Cfloat\u003E Get;\r\n\t\tpublic Action\u003Cfloat\u003E Set;\r\n\t\tpublic Func\u003Cstring\u003E Fmt;\r\n\t\tpublic string Display() =\u003E Fmt();\r\n\t}\r\n\r\n\tList\u003CDial\u003E _dials;\r\n\tList\u003CDial\u003E SliderDials =\u003E _dials ??= BuildDials();\r\n\r\n\tList\u003CDial\u003E BuildDials()\r\n\t{\r\n\t\tif ( !Car.IsValid() )\r\n\t\t\treturn new List\u003CDial\u003E();\r\n\r\n\t\tvar def = Car.Definition;\r\n\t\treturn new List\u003CDial\u003E\r\n\t\t{\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Grip\u0022, Min = 0.6f, Max = 2.2f, Step = 0.05f,\r\n\t\t\t\tGet = () =\u003E _gripScale, Set = SetGrip,\r\n\t\t\t\tFmt = () =\u003E _gripScale.ToString( \u00220.00\u0022 ) \u002B \u0022x\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Drive torque\u0022, Min = 0.5f, Max = 2.0f, Step = 0.05f,\r\n\t\t\t\tGet = () =\u003E _torqueScale, Set = SetTorqueScale,\r\n\t\t\t\tFmt = () =\u003E _torqueScale.ToString( \u00220.00\u0022 ) \u002B \u0022x\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Suspension stiffness\u0022, Min = 15000f, Max = 60000f, Step = 2000f,\r\n\t\t\t\tGet = () =\u003E def.SpringRate, Set = v =\u003E { def.SpringRate = v; ApplyWheels(); },\r\n\t\t\t\tFmt = () =\u003E def.SpringRate.ToString( \u00220\u0022 ) \u002B \u0022 N/m\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Suspension damping\u0022, Min = 800f, Max = 6000f, Step = 200f,\r\n\t\t\t\tGet = () =\u003E def.DamperRate, Set = v =\u003E { def.DamperRate = v; ApplyWheels(); },\r\n\t\t\t\tFmt = () =\u003E def.DamperRate.ToString( \u00220\u0022 ) \u002B \u0022 Ns/m\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Suspension travel\u0022, Min = 0.10f, Max = 0.35f, Step = 0.01f,\r\n\t\t\t\tGet = () =\u003E def.SuspensionTravel, Set = v =\u003E { def.SuspensionTravel = v; ApplyWheels(); },\r\n\t\t\t\tFmt = () =\u003E (def.SuspensionTravel * 100f).ToString( \u00220\u0022 ) \u002B \u0022 cm\u0022,\r\n\t\t\t},\r\n\t\t\tnew()\r\n\t\t\t{\r\n\t\t\t\tName = \u0022Brake force\u0022, Min = 1500f, Max = 8000f, Step = 200f,\r\n\t\t\t\tGet = () =\u003E def.BrakeTorque, Set = v =\u003E def.BrakeTorque = v,\r\n\t\t\t\tFmt = () =\u003E def.BrakeTorque.ToString( \u00220\u0022 ) \u002B \u0022 Nm\u0022,\r\n\t\t\t},\r\n\t\t};\r\n\t}\r\n\r\n\tfloat Frac( Dial d )\r\n\t{\r\n\t\tif ( d.Max \u003C= d.Min )\r\n\t\t\treturn 0f;\r\n\t\treturn Math.Clamp( (d.Get() - d.Min) / (d.Max - d.Min), 0f, 1f );\r\n\t}\r\n\r\n\tstring WidthPct( Dial d ) =\u003E\r\n\t\t(Frac( d ) * 100f).ToString( \u00220.#\u0022, System.Globalization.CultureInfo.InvariantCulture );\r\n\r\n\tvoid Step( Dial d, int clicks )\r\n\t{\r\n\t\tfloat v = Math.Clamp( d.Get() \u002B clicks * d.Step, d.Min, d.Max );\r\n\t\td.Set( v );\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\t// Click-to-jump \u002B drag-to-scrub on the dial track (mirrors the game panel\u0027s proven pattern). jump\r\n\t// is true on mousedown (a bare click sets the value at the cursor) and false on mousemove (scrub\r\n\t// only while the track owns the press). Value = mouse local x over track width, snapped to the\r\n\t// dial\u0027s step, pushed through the SAME Set path as the \u002B/- steps.\r\n\tvoid Scrub( Dial d, Sandbox.UI.PanelEvent ev, bool jump )\r\n\t{\r\n\t\tif ( ev is not Sandbox.UI.MousePanelEvent e )\r\n\t\t\treturn;\r\n\r\n\t\tvar track = e.This;\r\n\t\tif ( track is null )\r\n\t\t\treturn;\r\n\r\n\t\t// mousemove fires whether or not the button is held; only scrub while the track owns the press.\r\n\t\tif ( !jump \u0026\u0026 !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) )\r\n\t\t\treturn;\r\n\r\n\t\tfloat w = track.Box.Rect.Width;\r\n\t\tif ( w \u003C= 0f )\r\n\t\t\treturn;\r\n\r\n\t\tfloat frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );\r\n\t\tfloat v = d.Min \u002B frac * (d.Max - d.Min);\r\n\t\tif ( d.Step \u003E 0f )\r\n\t\t\tv = MathF.Round( v / d.Step ) * d.Step;\r\n\t\tv = Math.Clamp( v, d.Min, d.Max );\r\n\t\td.Set( v );\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\t// ---- apply paths (same seams a consumer would use) ----\r\n\tstatic TireCurve Scaled( TireCurve c, float k ) =\u003E\r\n\t\tnew( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );\r\n\r\n\t// Push the definition\u0027s suspension \u002B tire values onto the live wheels. The factory copies these at\r\n\t// spawn; the wheel re-reads them every substep, so writing them here is the live-apply path.\r\n\tvoid ApplyWheels()\r\n\t{\r\n\t\tvar def = Car.Definition;\r\n\t\tforeach ( var w in Car.Wheels )\r\n\t\t{\r\n\t\t\tw.SpringRate = def.SpringRate;\r\n\t\t\tw.DamperRate = def.DamperRate;\r\n\t\t\tw.SuspensionTravel = def.SuspensionTravel;\r\n\t\t\tw.LateralCurve = def.LateralCurve;\r\n\t\t\tw.LongitudinalCurve = def.LongitudinalCurve;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid SetGrip( float k )\r\n\t{\r\n\t\t_gripScale = k;\r\n\t\tvar def = Car.Definition;\r\n\t\tdef.LateralCurve = Scaled( _baseLat, k );\r\n\t\tdef.LongitudinalCurve = Scaled( _baseLong, k );\r\n\t\tApplyWheels();\r\n\t}\r\n\r\n\t// Drive torque scale multiplies the authored peak torque. Drivetrain reads def.PeakTorque live\r\n\t// (it holds the same definition instance), so no drivetrain touch is needed.\r\n\tvoid SetTorqueScale( float k )\r\n\t{\r\n\t\t_torqueScale = k;\r\n\t\tCar.Definition.PeakTorque = _stockPeakTorque * k;\r\n\t}\r\n\r\n\tvoid CycleAssists()\r\n\t{\r\n\t\tCar.Assists = Car.Assists switch\r\n\t\t{\r\n\t\t\tAssistLevel.Casual =\u003E AssistLevel.Sport,\r\n\t\t\tAssistLevel.Sport =\u003E AssistLevel.Sim,\r\n\t\t\t_ =\u003E AssistLevel.Casual,\r\n\t\t};\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tstring AssistLabel =\u003E Car.IsValid() ? Car.Assists.ToString() : \u0022\u0022;\r\n\r\n\t// Tire preset swaps the UNSCALED base curves, then re-applies the current grip multiplier so the\r\n\t// grip dial and the preset compose. Stock restores the car\u0027s own authored curves.\r\n\tvoid CycleTires()\r\n\t{\r\n\t\t_tirePreset = (_tirePreset \u002B 1) % 4;\r\n\t\t(_baseLat, _baseLong) = _tirePreset switch\r\n\t\t{\r\n\t\t\t1 =\u003E (TireCurve.Street, TireCurve.Street),\r\n\t\t\t2 =\u003E (TireCurve.Sport, TireCurve.Sport),\r\n\t\t\t3 =\u003E (TireCurve.Offroad, TireCurve.Offroad),\r\n\t\t\t_ =\u003E (_stockLat, _stockLong),\r\n\t\t};\r\n\t\tSetGrip( _gripScale );\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tstring TireLabel =\u003E _tirePreset switch\r\n\t{\r\n\t\t1 =\u003E \u0022Street\u0022,\r\n\t\t2 =\u003E \u0022Sport\u0022,\r\n\t\t3 =\u003E \u0022Offroad\u0022,\r\n\t\t_ =\u003E \u0022Stock\u0022,\r\n\t};\r\n\r\n\t// Re-apply the car\u0027s pristine authored values (captured at bind). Definitions in the demo roster\r\n\t// are fresh per car, so \u0022stock\u0022 is unambiguous: the values this car spawned with.\r\n\tvoid ResetToStock()\r\n\t{\r\n\t\tif ( !Car.IsValid() || !_snapped )\r\n\t\t\treturn;\r\n\r\n\t\tvar def = Car.Definition;\r\n\t\tdef.PeakTorque = _stockPeakTorque;\r\n\t\tdef.SpringRate = _stockSpring;\r\n\t\tdef.DamperRate = _stockDamper;\r\n\t\tdef.SuspensionTravel = _stockTravel;\r\n\t\tdef.BrakeTorque = _stockBrake;\r\n\t\tdef.LateralCurve = _stockLat;\r\n\t\tdef.LongitudinalCurve = _stockLong;\r\n\r\n\t\t_gripScale = 1f;\r\n\t\t_torqueScale = 1f;\r\n\t\t_tirePreset = 0;\r\n\t\t_baseLat = _stockLat;\r\n\t\t_baseLong = _stockLong;\r\n\r\n\t\tCar.Assists = _stockAssists;\r\n\t\tApplyWheels();\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t// Start COLLAPSED: the chip legend keeps the keybind discoverable while mouse-look stays\r\n\t\t// with the camera (an open lab on spawn captured the cursor before players ever drove,\r\n\t\t// owner call 2026-07-19). Static flag, so reset it here each session.\r\n\t\tIsOpen = false;\r\n\t}\r\n\r\n\tvoid ToggleOpen()\r\n\t{\r\n\t\tIsOpen = !IsOpen;\r\n\t\tMouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;\r\n\t\tStateHasChanged();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Input.Pressed( ToggleAction ) )\r\n\t\t{\r\n\t\t\tIsOpen = !IsOpen;\r\n\t\t\tMouse.Visibility = IsOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;\r\n\t\t\tStateHasChanged();\r\n\t\t}\r\n\r\n\t\t// The chase camera re-hides the cursor every frame while it owns it; hold it visible while open.\r\n\t\tif ( IsOpen )\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible;\r\n\t}\r\n\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\t// Closed still renders the chip legend, and the chip waits on the car binding, so the\r\n\t\t// hash must move when the car arrives or the first build would stick on the empty tree.\r\n\t\tif ( !IsOpen )\r\n\t\t\treturn Car.IsValid() ? 1 : 0;\r\n\r\n\t\tvar h = new HashCode();\r\n\t\th.Add( _gripScale );\r\n\t\th.Add( _torqueScale );\r\n\t\th.Add( _tirePreset );\r\n\t\tif ( Car.IsValid() )\r\n\t\t{\r\n\t\t\tvar def = Car.Definition;\r\n\t\t\th.Add( def.SpringRate );\r\n\t\t\th.Add( def.DamperRate );\r\n\t\t\th.Add( def.SuspensionTravel );\r\n\t\t\th.Add( def.BrakeTorque );\r\n\t\t\th.Add( (int)Car.Assists );\r\n\t\t}\r\n\t\treturn h.ToHashCode();\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/Demo/DemoTuningPanel.razor.scss","FileName":"DemoTuningPanel.razor.scss","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"// Kit-native live tuning lab (demo layer). Compact left-anchored card; s\u0026box panels default to flex\r\n// so no display rules are needed (which also keeps this free of inline-flex / inline-block). All text\r\n// is bright: instructional copy never uses dim gray.\r\n\r\nDemoTuningPanel {\r\n\tposition: absolute;\r\n\tinset: 0;\r\n\tpointer-events: none;\r\n\tfont-family: Poppins, sans-serif;\r\n\r\n\t.mono { font-family: \u0022Roboto Mono\u0022, monospace; }\r\n\r\n\t// Top-right key legend (car switching). Mirrors the chip\u0027s card language at the opposite corner.\r\n\t.legend {\r\n\t\tposition: absolute;\r\n\t\tright: 40px;\r\n\t\ttop: 40px;\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t\tgap: 8px;\r\n\t\tpadding: 10px 14px;\r\n\t\tbackground-color: rgba( 15, 17, 21, 0.92 );\r\n\t\tborder: 1px solid rgba( 255, 255, 255, 0.08 );\r\n\t\tborder-radius: 16px;\r\n\t\tbox-shadow: 0 20px 60px rgba( 0, 0, 0, 0.6 );\r\n\r\n\t\t.title { font-weight: 600; color: #F2F4F7; }\r\n\t\t.kbd {\r\n\t\t\tcolor: #F2F4F7;\r\n\t\t\tborder: 1px solid rgba( 255, 255, 255, 0.12 );\r\n\t\t\tborder-radius: 5px;\r\n\t\t\tpadding: 3px 8px;\r\n\t\t\tbackground-color: rgba( 255, 255, 255, 0.05 );\r\n\t\t}\r\n\t}\r\n\r\n\t.panel {\r\n\t\tposition: absolute;\r\n\t\tleft: 40px;\r\n\t\ttop: 40px;\r\n\t\twidth: 360px;\r\n\t\tflex-direction: column;\r\n\t\tgap: 12px;\r\n\t\tpadding: 20px;\r\n\t\tbackground-color: rgba( 15, 17, 21, 0.92 );\r\n\t\tborder: 1px solid rgba( 255, 255, 255, 0.08 );\r\n\t\tborder-radius: 16px;\r\n\t\tbox-shadow: 0 20px 60px rgba( 0, 0, 0, 0.6 );\r\n\t\tpointer-events: all;\r\n\r\n\t\t.head {\r\n\t\t\tflex-direction: row;\r\n\t\t\tjustify-content: space-between;\r\n\t\t\talign-items: center;\r\n\r\n\t\t\t.title { font-weight: 700; color: #F2F4F7; }\r\n\t\t\t.kbd {\r\n\t\t\t\tcolor: #F2F4F7;\r\n\t\t\t\tborder: 1px solid rgba( 255, 255, 255, 0.12 );\r\n\t\t\t\tborder-radius: 5px;\r\n\t\t\t\tpadding: 3px 8px;\r\n\t\t\t\tbackground-color: rgba( 255, 255, 255, 0.05 );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t.hint { color: #F2F4F7; }\r\n\r\n\t\t// Collapsed legend chip: same anchor as the expanded card (left/top 40px via .panel), sized\r\n\t\t// to its content so it reads as the card\u0027s top-left corner waiting to grow.\r\n\t\t\u0026.chip {\r\n\t\t\twidth: auto;\r\n\t\t\tpadding: 10px 14px;\r\n\t\t\tgap: 0;\r\n\r\n\t\t\t.head {\r\n\t\t\t\tgap: 8px;\r\n\t\t\t\tjustify-content: flex-start;\r\n\r\n\t\t\t\t.title { font-weight: 600; color: #F2F4F7; }\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t.dials {\r\n\t\t\tflex-direction: column;\r\n\t\t\tgap: 12px;\r\n\r\n\t\t\t.dial { flex-direction: column; gap: 6px; }\r\n\r\n\t\t\t.drow {\r\n\t\t\t\tflex-direction: row;\r\n\t\t\t\tjustify-content: space-between;\r\n\t\t\t\talign-items: center;\r\n\r\n\t\t\t\t.dname { color: #F2F4F7; }\r\n\t\t\t\t.dval { color: #E8EAED; }\r\n\t\t\t}\r\n\r\n\t\t\t.dctl {\r\n\t\t\t\tflex-direction: row;\r\n\t\t\t\talign-items: center;\r\n\t\t\t\tgap: 10px;\r\n\r\n\t\t\t\t.step {\r\n\t\t\t\t\twidth: 28px;\r\n\t\t\t\t\theight: 28px;\r\n\t\t\t\t\tjustify-content: center;\r\n\t\t\t\t\talign-items: center;\r\n\t\t\t\t\tcolor: #F2F4F7;\r\n\t\t\t\t\tbackground-color: rgba( 255, 255, 255, 0.06 );\r\n\t\t\t\t\tborder: 1px solid rgba( 255, 255, 255, 0.10 );\r\n\t\t\t\t\tborder-radius: 6px;\r\n\t\t\t\t\tcursor: pointer;\r\n\t\t\t\t\ttransition: all 0.12s ease;\r\n\r\n\t\t\t\t\t\u0026:hover { color: #0A161A; background-color: #4AD9F2; }\r\n\t\t\t\t}\r\n\r\n\t\t\t\t.track {\r\n\t\t\t\t\t// Click-to-jump \u002B drag-to-scrub hit box. Events must land on the track (not the fill)\r\n\t\t\t\t\t// so the mouse local position stays track-relative: pointer-events all here, none on\r\n\t\t\t\t\t// the fill.\r\n\t\t\t\t\tposition: relative;\r\n\t\t\t\t\tflex-grow: 1;\r\n\t\t\t\t\theight: 14px;\r\n\t\t\t\t\tborder-radius: 99px;\r\n\t\t\t\t\tbackground-color: rgba( 255, 255, 255, 0.12 );\r\n\t\t\t\t\tpointer-events: all;\r\n\t\t\t\t\tcursor: pointer;\r\n\r\n\t\t\t\t\t.fill {\r\n\t\t\t\t\t\tposition: absolute;\r\n\t\t\t\t\t\tleft: 0px;\r\n\t\t\t\t\t\theight: 100%;\r\n\t\t\t\t\t\tborder-radius: 99px;\r\n\t\t\t\t\t\tbackground: linear-gradient( to right, #3AC4DE, #4AD9F2 );\r\n\t\t\t\t\t\tpointer-events: none;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\t.cycle {\r\n\t\t\t\tjustify-content: center;\r\n\t\t\t\talign-items: center;\r\n\t\t\t\tfont-weight: 600;\r\n\t\t\t\tcolor: #0A161A;\r\n\t\t\t\tbackground-color: #4AD9F2;\r\n\t\t\t\tborder-radius: 6px;\r\n\t\t\t\tpadding: 5px 14px;\r\n\t\t\t\tcursor: pointer;\r\n\t\t\t\ttransition: all 0.12s ease;\r\n\r\n\t\t\t\t\u0026:hover { background-color: #58E1F7; }\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t.foot {\r\n\t\t\tflex-direction: column;\r\n\t\t\tgap: 8px;\r\n\r\n\t\t\t.btn {\r\n\t\t\t\tjustify-content: center;\r\n\t\t\t\talign-items: center;\r\n\t\t\t\tfont-weight: 700;\r\n\t\t\t\tcolor: #F2F4F7;\r\n\t\t\t\tbackground-color: rgba( 255, 255, 255, 0.06 );\r\n\t\t\t\tborder: 1px solid rgba( 255, 255, 255, 0.10 );\r\n\t\t\t\tborder-radius: 10px;\r\n\t\t\t\tpadding: 11px 0;\r\n\t\t\t\tcursor: pointer;\r\n\t\t\t\ttransition: all 0.12s ease;\r\n\r\n\t\t\t\t\u0026:hover { color: #E8EAED; background-color: rgba( 255, 255, 255, 0.10 ); }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/DriveInputs.cs","FileName":"DriveInputs.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Per-frame driver intent, the value-struct swap point at VehicleController\u0027s input seam\r\n/// (\u003Csee cref=\u0022VehicleController.InputOverride\u0022/\u003E). Carries the raw intent the controller samples\r\n/// from a device: the move axis (forward/back \u002B steer), the handbrake action, and the sequential\r\n/// shift requests. A keyboard/gamepad/wheel source or a scripted source (a test pilot, an AI) all\r\n/// produce one of these, so the controller\u0027s gear/reverse/steer-ramp logic stays source-agnostic.\r\n///\r\n/// The live-device sampler \u003Csee cref=\u0022SampleDeviceInputs\u0022/\u003E and its gamepad shaping helpers live\r\n/// here (lifted out of the controller): the controller either consumes an injected override or calls\r\n/// this sampler, and nothing else in the class touches device input.\r\n/// \u003C/summary\u003E\r\npublic struct DriveInputs\r\n{\r\n\t/// \u003Csummary\u003E-1..1 signed drive axis: \u002Bforward accelerates, -back brakes then engages reverse near a\r\n\t/// stop. Built in \u003Csee cref=\u0022SampleDeviceInputs\u0022/\u003E as (throttle \u2212 brake) where each channel is the\r\n\t/// MAX of the keyboard/stick component (\u003Cc\u003EInput.AnalogMove.x\u003C/c\u003E) and the ANALOG gamepad trigger\r\n\t/// pull (\u003Cc\u003EInput.GetAnalog(InputAnalog.RightTrigger|LeftTrigger)\u003C/c\u003E, 0..1) \u2014 so a partial trigger\r\n\t/// gives a partial pedal. A scripted source sets this float directly.\u003C/summary\u003E\r\n\tpublic float MoveForward;\r\n\r\n\t/// \u003Csummary\u003E-1..1, maps to \u003Cc\u003EInput.AnalogMove.y\u003C/c\u003E (gamepad path reshaped by the gamepad deadzone\r\n\t/// \u002B response curve). Note \u003Cc\u003ERotation.FromYaw(\u002B)\u003C/c\u003E is a LEFT/CCW turn, so \u002BSteer steers left.\u003C/summary\u003E\r\n\tpublic float Steer;\r\n\r\n\t/// \u003Csummary\u003EThe handbrake / drift button: keyboard \u0022Jump\u0022 (Space), or gamepad A (Jump\u0027s own\r\n\t/// GamepadCode) or the left bumper (\u0022Handbrake\u0022 action).\u003C/summary\u003E\r\n\tpublic bool Handbrake;\r\n\r\n\t/// \u003Csummary\u003EEdge-triggered request to shift UP one gear (sequential MANUAL mode). Keyboard E /\r\n\t/// gamepad R1 while live; a scripted source pulses it for one tick. The controller rising-edge-\r\n\t/// detects it, so a source that holds it across ticks still shifts exactly once.\u003C/summary\u003E\r\n\tpublic bool ShiftUp;\r\n\r\n\t/// \u003Csummary\u003EEdge-triggered request to shift DOWN one gear (sequential MANUAL mode). Keyboard Q /\r\n\t/// gamepad L1. Same one-shot rising-edge semantics as \u003Csee cref=\u0022ShiftUp\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool ShiftDown;\r\n\r\n\t/// \u003Csummary\u003EEdge-triggered request to toggle the transmission mode AUTO\u2194MANUAL. Keyboard G /\r\n\t/// gamepad D-pad down. Same one-shot rising-edge semantics as \u003Csee cref=\u0022ShiftUp\u0022/\u003E.\u003C/summary\u003E\r\n\tpublic bool ShiftModeToggle;\r\n\r\n\t// Gamepad tier: deadzone \u002B response curve for the analog steer axis.\r\n\tconst float GamepadSteerDeadzone = 0.12f;\r\n\tconst float GamepadSteerCurvePower = 1.6f; // \u003E1 softens the center for fine control, still reaches full lock\r\n\r\n\t// Analog throttle/brake tier: a small trigger deadzone so resting triggers can\u0027t creep the pedals.\r\n\t// The engine ALSO applies its own 12.5% deadzone one layer down (Controller.SetAxis zeroes any\r\n\t// |axis| \u003C= 0.125 before Input.GetAnalog sees it), so values arrive as 0 or \u003E~0.125 and this floor\r\n\t// is a belt-and-suspenders guard that also rescales so full pull still reaches 1.0.\r\n\tconst float GamepadTriggerDeadzone = 0.05f;\r\n\r\n\t/// \u003Csummary\u003ESample the live input devices into a DriveInputs value (this is the keyboard/gamepad\r\n\t/// source; other sources produce the same struct and set \u003Csee cref=\u0022VehicleController.InputOverride\u0022/\u003E).\r\n\t///\r\n\t/// Steering rides \u003Cc\u003EInput.AnalogMove.y\u003C/c\u003E straight off the left stick; keyboard emits exact\r\n\t/// -1/0/1 through the same path and passes through \u003Csee cref=\u0022ApplyGamepadSteerCurve\u0022/\u003E unchanged.\r\n\t///\r\n\t/// Throttle/brake are VARIABLE per device: keyboard W/S ride \u003Cc\u003EInput.AnalogMove.x\u003C/c\u003E as an exact\r\n\t/// \u00B11 digital forward/back; the gamepad triggers are read as a true ANALOG 0..1 pull via\r\n\t/// \u003Cc\u003EInput.GetAnalog(InputAnalog.RightTrigger|LeftTrigger)\u003C/c\u003E (right = gas, left = brake). The two\r\n\t/// devices combine per channel by MAX \u2014 so either device works and neither fights the other \u2014 then\r\n\t/// the net (throttle \u2212 brake) folds into the single signed \u003Csee cref=\u0022MoveForward\u0022/\u003E scalar. On\r\n\t/// keyboard \u003Cc\u003EInput.GetAnalog\u003C/c\u003E returns 0, so keyboard-only players are byte-identical.\u003C/summary\u003E\r\n\tpublic static DriveInputs SampleDeviceInputs()\r\n\t{\r\n\t\tvar move = Input.AnalogMove;\r\n\r\n\t\t// keyboard/stick forward\u002Bback split off the shared move axis (W = \u002Bx, S = -x)\r\n\t\tfloat keyThrottle = MathF.Max( 0f, move.x );\r\n\t\tfloat keyBrake = MathF.Max( 0f, -move.x );\r\n\r\n\t\t// gamepad triggers, true analog 0..1 (right = gas, left = brake)\r\n\t\tfloat triggerThrottle = ReadTrigger( InputAnalog.RightTrigger );\r\n\t\tfloat triggerBrake = ReadTrigger( InputAnalog.LeftTrigger );\r\n\r\n\t\t// MAX blend per channel so either device drives the pedal, neither overrides the other\r\n\t\tfloat throttle = MathF.Max( keyThrottle, triggerThrottle );\r\n\t\tfloat brake = MathF.Max( keyBrake, triggerBrake );\r\n\t\tfloat moveForward = Math.Clamp( throttle - brake, -1f, 1f );\r\n\r\n\t\treturn new DriveInputs\r\n\t\t{\r\n\t\t\tMoveForward = moveForward,\r\n\t\t\tSteer = ApplyGamepadSteerCurve( move.y ),\r\n\t\t\tHandbrake = Input.Down( \u0022Jump\u0022 ) || Input.Down( \u0022Handbrake\u0022 ),\r\n\t\t};\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERead a gamepad trigger as a linear 0..1 pull with a small deadzone floor (rescaled so\r\n\t/// full pull still reaches 1.0). \u003Cc\u003EInput.GetAnalog\u003C/c\u003E already returns 0 for a trigger on keyboard,\r\n\t/// so this only shapes the gamepad path.\u003C/summary\u003E\r\n\tstatic float ReadTrigger( InputAnalog trigger )\r\n\t{\r\n\t\tfloat v = Math.Clamp( Input.GetAnalog( trigger ), 0f, 1f );\r\n\t\tif ( v \u003C GamepadTriggerDeadzone )\r\n\t\t\treturn 0f;\r\n\t\treturn (v - GamepadTriggerDeadzone) / (1f - GamepadTriggerDeadzone);\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EDeadzone \u002B power curve for the analog steer axis. Values under the deadzone snap to 0;\r\n\t/// the remaining range is rescaled so full stick deflection still reaches \u00B11 (no lost lock), then\r\n\t/// raised to \u003Csee cref=\u0022GamepadSteerCurvePower\u0022/\u003E for a softer center. Keyboard\u0027s exact -1/0/1\r\n\t/// passes through unaffected (0 is inside the deadzone; 1 rescales to 1 and 1^n == 1).\u003C/summary\u003E\r\n\tstatic float ApplyGamepadSteerCurve( float raw )\r\n\t{\r\n\t\tfloat mag = MathF.Abs( raw );\r\n\t\tif ( mag \u003C GamepadSteerDeadzone )\r\n\t\t\treturn 0f;\r\n\r\n\t\tfloat t = Math.Clamp( (mag - GamepadSteerDeadzone) / (1f - GamepadSteerDeadzone), 0f, 1f );\r\n\t\treturn MathF.Sign( raw ) * MathF.Pow( t, GamepadSteerCurvePower );\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/Units.cs","FileName":"Units.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// All vehicle math is done in SI units (meters, kg, N); convert only at the engine boundary.\r\n/// s\u0026amp;box uses Source-style inch units.\r\n/// \u003C/summary\u003E\r\npublic static class Units\r\n{\r\n\tpublic const float MetersToUnits = 39.37f;\r\n\tpublic const float UnitsToMeters = 1f / MetersToUnits;\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/VehicleCamera.cs","FileName":"VehicleCamera.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Chase camera: spring-arm follow behind the car\u0027s flattened heading, FOV widens with speed.\r\n/// Mouse orbits around the car; after a few seconds without mouse input it eases back to the chase\r\n/// position. Mouse wheel zooms distance.\r\n/// \u003C/summary\u003E\r\npublic sealed class VehicleCamera : Component\r\n{\r\n\t/// \u003Csummary\u003E\r\n\t/// Seam for \u0022a UI cursor modal is currently open\u0022 (spec 3.1). Drive mode needs a locked cursor\r\n\t/// or AnalogLook stays at zero, so this camera hides the cursor every frame \u2014 UNLESS a consumer\u0027s\r\n\t/// UI has a modal up that owns the cursor. Plug that check in here; null (default) means the kit\r\n\t/// never yields the cursor (single-window driving with no UI modals). Null-safe: unset reads false.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Func\u003Cbool\u003E CursorModalOpen { get; set; }\r\n\r\n\tstatic bool AnyCursorModalOpen\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\treturn CursorModalOpen?.Invoke() ?? false;\r\n\t\t\t}\r\n\t\t\tcatch ( NotImplementedException )\r\n\t\t\t{\r\n\t\t\t\t// Hotload survivor: this static can hold a lambda authored by a REPLACED assembly\r\n\t\t\t\t// (s\u0026box hotload copies static state but cannot substitute an orphaned lambda, so\r\n\t\t\t\t// invoking it throws). Drop the stale delegate and fall back to \u0022no modal\u0022; whoever\r\n\t\t\t\t// owns the seam (the demo bootstrap, or a consumer) re-wires it at session start.\r\n\t\t\t\tCursorModalOpen = null;\r\n\t\t\t\treturn false;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tpublic VehicleController Target { get; set; }\r\n\r\n\t[Property] public float Distance { get; set; } = 6.5f; // m\r\n\t[Property] public float Height { get; set; } = 2.2f;   // m\r\n\t[Property] public float BaseFov { get; set; } = 70f;\r\n\t[Property] public float OrbitReturnDelay { get; set; } = 5f;\r\n\t[Property] public float MinDistance { get; set; } = 3.0f;  // m \u2014 wheel zoom in\r\n\t[Property] public float MaxDistance { get; set; } = 14.0f; // m \u2014 wheel zoom out\r\n\t[Property] public float ZoomPerNotch { get; set; } = 0.85f; // m per wheel tick\r\n\r\n\tCameraComponent _camera;\r\n\tfloat _orbitYaw;\r\n\tfloat _orbitPitch;\r\n\tTimeSince _lastMouseInput = 999f;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_camera = Components.Get\u003CCameraComponent\u003E();\r\n\t\t// Drive mode needs a locked cursor or AnalogLook stays at zero. UI modals unlock\r\n\t\t// it; we re-lock every frame when none are open (panels only set Visible while up).\r\n\t\tif ( !AnyCursorModalOpen )\r\n\t\t\tMouse.Visibility = MouseVisibility.Hidden;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Target is null || !Target.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tbool uiOwnsCursor = AnyCursorModalOpen;\r\n\t\tif ( !uiOwnsCursor )\r\n\t\t{\r\n\t\t\t// Re-assert every frame \u2014 panel dismiss paths miss edge cases, and the engine\r\n\t\t\t// can resurface the cursor after focus changes.\r\n\t\t\tMouse.Visibility = MouseVisibility.Hidden;\r\n\r\n\t\t\tvar look = Input.AnalogLook;\r\n\t\t\tif ( MathF.Abs( look.yaw ) \u003E 0.05f || MathF.Abs( look.pitch ) \u003E 0.05f )\r\n\t\t\t{\r\n\t\t\t\t_lastMouseInput = 0;\r\n\t\t\t\t_orbitYaw \u002B= look.yaw;\r\n\t\t\t\t_orbitPitch = Math.Clamp( _orbitPitch \u002B look.pitch, -30f, 12f );\r\n\t\t\t}\r\n\r\n\t\t\t// Scroll up = closer (smaller Distance). Ignore while UI has the cursor.\r\n\t\t\tfloat scroll = Input.MouseWheel.y;\r\n\t\t\tif ( MathF.Abs( scroll ) \u003E 0.01f )\r\n\t\t\t\tDistance = Math.Clamp( Distance - scroll * ZoomPerNotch, MinDistance, MaxDistance );\r\n\t\t}\r\n\r\n\t\tif ( !uiOwnsCursor \u0026\u0026 _lastMouseInput \u003E OrbitReturnDelay )\r\n\t\t{\r\n\t\t\t// ease back behind the car\r\n\t\t\tfloat decay = 1f - MathF.Exp( -3f * Time.Delta );\r\n\t\t\t_orbitYaw = MathX.Lerp( _orbitYaw, 0f, decay );\r\n\t\t\t_orbitPitch = MathX.Lerp( _orbitPitch, 0f, decay );\r\n\t\t}\r\n\r\n\t\tfloat m = Units.MetersToUnits;\r\n\t\tvar car = Target.GameObject;\r\n\r\n\t\t// follow the flattened heading so rolls/flips don\u0027t whip the camera\r\n\t\tvar flatForward = car.WorldRotation.Forward.WithZ( 0f );\r\n\t\tflatForward = flatForward.IsNearZeroLength ? Vector3.Forward : flatForward.Normal;\r\n\t\tfloat baseYaw = MathF.Atan2( flatForward.y, flatForward.x ).RadianToDegree();\r\n\r\n\t\tvar orbit = Rotation.From( _orbitPitch, baseYaw \u002B _orbitYaw, 0f );\r\n\r\n\t\tfloat speedStretch = Math.Clamp( Target.SpeedMs / 40f, 0f, 1f );\r\n\t\tvar wantedPos = car.WorldPosition\r\n\t\t\t- orbit.Forward * (Distance \u002B speedStretch * 1.5f) * m\r\n\t\t\t\u002B Vector3.Up * Height * m;\r\n\r\n\t\tfloat smooth = 1f - MathF.Exp( -8f * Time.Delta );\r\n\t\tWorldPosition = Vector3.Lerp( WorldPosition, wantedPos, smooth );\r\n\r\n\t\tvar lookTarget = car.WorldPosition \u002B Vector3.Up * 0.8f * m \u002B orbit.Forward * 1.5f * m;\r\n\t\tWorldRotation = Rotation.LookAt( (lookTarget - WorldPosition).Normal );\r\n\r\n\t\tif ( _camera.IsValid() )\r\n\t\t\t_camera.FieldOfView = BaseFov \u002B speedStretch * 15f;\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"VehicleWheel.cs","FileName":"VehicleWheel.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// One wheel: shapecast ground detection \u002B spring/damper suspension (skeleton pattern from\r\n/// Facepunch/sbox-libwheel, MIT) with the friction model replaced by the spec \u00A75.2.1 slip\r\n/// physics \u2014 wheel angular velocity as integrated state, slip ratio/angle into peaked curves,\r\n/// friction ellipse, load sensitivity, low-speed blend.\r\n///\r\n/// Driven by \u003Csee cref=\u0022VehicleController\u0022/\u003E in substeps; forces are accumulated across\r\n/// substeps and applied once per fixed update (ApplyForceAt applies for the whole step).\r\n/// All internal math in SI.\r\n/// \u003C/summary\u003E\r\npublic sealed class VehicleWheel : Component\r\n{\r\n\t// Configured by VehicleFactory from CarDefinition\r\n\tpublic float Radius { get; set; } = 0.31f;\r\n\tpublic float Inertia { get; set; } = 1.2f;\r\n\tpublic float SuspensionTravel { get; set; } = 0.18f;\r\n\tpublic float SpringRate { get; set; } = 38000f;\r\n\tpublic float DamperRate { get; set; } = 2800f;\r\n\tpublic TireCurve LongitudinalCurve { get; set; }\r\n\tpublic TireCurve LateralCurve { get; set; }\r\n\tpublic float LoadSensitivity { get; set; } = 0.06f;\r\n\tpublic float StaticLoad { get; set; } = 3000f; // N, set by factory: mass\u00B7g/4\r\n\tpublic bool IsSteering { get; set; }\r\n\tpublic bool IsDriven { get; set; }\r\n\tpublic bool HasHandbrake { get; set; }\r\n\tpublic float GripScale { get; set; } = 1f; // live multiplier, e.g. handbrake drift cuts rear grip\r\n\tpublic float ParkBrakeScale { get; set; } = 1f; // anti-jitter stiction strength; controller fades it with throttle\r\n\r\n\t/// \u003Csummary\u003EPer-substep DRIVE-side angular-velocity cap (rad/s), set by the controller each\r\n\t/// substep to the drivetrain\u0027s redline-equivalent wheel speed for the current gear (see\r\n\t/// \u003Csee cref=\u0022Drivetrain.RedlineWheelSpeed\u0022/\u003E). Drive torque can never push the wheel PAST this\r\n\t/// within a substep; the ground (tire reaction) still can, and a pre-existing overspeed is never\r\n\t/// yanked down (no phantom braking). float.MaxValue = no cap (undriven wheels, neutral).\u003C/summary\u003E\r\n\tpublic float DriveOmegaCap { get; set; } = float.MaxValue;\r\n\r\n\t// State\r\n\tpublic float AngularVelocity { get; private set; } // rad/s, \u002Bforward\r\n\tpublic float SteerAngle { get; set; } // degrees, set by controller\r\n\tpublic float SlipRatio { get; private set; }\r\n\tpublic float SlipAngle { get; private set; } // radians\r\n\tpublic float Load { get; private set; } // N\r\n\tpublic bool IsGrounded =\u003E _trace.Hit;\r\n\tpublic float GroundSpeed { get; private set; } // m/s along wheel forward\r\n\tpublic float SuspensionLength { get; private set; } // m, attach point to wheel center, for visuals\r\n\r\n\tpublic string DebugTrace =\u003E !_trace.Hit\r\n\t\t? \u0022miss\u0022\r\n\t\t: $\u0022{_trace.GameObject?.Name ?? \u0022?\u0022}{(_trace.StartedSolid ? \u0022!SOLID\u0022 : \u0022\u0022)} d{_trace.Distance:F1}u n{_trace.Normal.z:F2} sf{(_trace.Surface?.Friction ?? -1f):F2}\u0022;\r\n\r\n\tconst float TraceSphereRadius = 1f * Units.UnitsToMeters; // shapecast sphere, see DoTrace\r\n\r\n\tRigidbody _rigidbody;\r\n\tSceneTraceResult _trace;\r\n\tVector3 _accumulatedForce; // N, world space\r\n\tVector3 _forcePosition;\r\n\tint _substeps;\r\n\tfloat _smoothedSlipAngle;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t_rigidbody = Components.GetInAncestorsOrSelf\u003CRigidbody\u003E();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETrace and reset accumulators. Call once per fixed update, before substeps.\u003C/summary\u003E\r\n\tpublic void BeginStep()\r\n\t{\r\n\t\tDoTrace();\r\n\t\t_accumulatedForce = Vector3.Zero;\r\n\t\t_substeps = 0;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// One physics substep. driveTorque/brakeTorque in N\u00B7m. Accumulates chassis force,\r\n\t/// integrates wheel spin.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void Substep( float dt, float driveTorque, float brakeTorque )\r\n\t{\r\n\t\t_substeps\u002B\u002B;\r\n\r\n\t\tvar up = _rigidbody.WorldRotation.Up;\r\n\r\n\t\t// a grazing contact (car tipped over, wheel scraping a wall) is not suspension:\r\n\t\t// generating spring force there self-propels a flipped car along the ground\r\n\t\tbool validContact = IsGrounded \u0026\u0026 Vector3.Dot( _trace.Normal, up ) \u003E 0.4f;\r\n\r\n\t\tif ( !validContact )\r\n\t\t{\r\n\t\t\tLoad = 0f;\r\n\t\t\tSlipRatio = 0f;\r\n\t\t\tSlipAngle = 0f;\r\n\t\t\tSuspensionLength = SuspensionTravel;\r\n\t\t\t// AIRBORNE DRIVE GATE (ramp lip-cluster fix 2026-07-21, LIVE-UNVERIFIED): a driven wheel\r\n\t\t\t// with no valid contact gets ZERO drive torque - it freewheels (brakes still act). With\r\n\t\t\t// drive torque passed through, an unloaded driven wheel flared to redline-equivalent\r\n\t\t\t// within ~3 ticks of leaving a kicker lip, the engine pinned at 94-98% redline for the\r\n\t\t\t// whole flight (live capture: rpm 6008-6010 at the lip), the drivetrain\u0027s limiter-camp\r\n\t\t\t// escape upshifted MID-AIR ~0.27 s into every full-throttle flight at 20-30 m/s, and the\r\n\t\t\t// car touched down at the flared surface speed: slip \u002B0.4..\u002B0.5, a 10-12 kN (~1 g) tire\r\n\t\t\t// spike for ~2 ticks, the skid chirp, and a landing one gear too tall (post-landing drive\r\n\t\t\t// force -26..-37% vs pre-lip) - the felt \u0022hitch going off the ramps\u0022 at any speed\r\n\t\t\t// (offline quantification: tools/ramp_lip_drivetrain_port.py). Zeroing drive here starves\r\n\t\t\t// that whole cascade: omega holds ~rolling speed, rpm stays steady, the escape shift\r\n\t\t\t// never arms in air, and touchdown slip is ~0. Flat-ground behavior is byte-identical BY\r\n\t\t\t// CONSTRUCTION (this branch never runs with valid ground contact; grounded-tick A/B in\r\n\t\t\t// the port asserts bit-identical trajectories). Registered prediction at commit time: the\r\n\t\t\t// owner\u0027s Sim-mode discriminator will NOT kill the hitch (the cascade is\r\n\t\t\t// assist-independent); result to be recorded next to this comment either way.\r\n\t\t\tIntegrateWheelSpin( dt, 0f, brakeTorque, 0f );\r\n\t\t\t// Airborne wind-down is bearing drag only: 2%/s. The previous 0.5f here was 50%/s\r\n\t\t\t// (documented in round 4 as \u00220.5%/s\u0022, a 100x misread of its own constant): after a\r\n\t\t\t// 1.2 s flight the wheels arrived at ~55% of road speed and braked the car while\r\n\t\t\t// spinning back up (flight recorder 2026-07-21: ~2 m/s of the touchdown loss plus\r\n\t\t\t// the landing skid chirp came from exactly this). Real free wheels barely slow.\r\n\t\t\tAngularVelocity *= 1f - 0.02f * dt;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// --- suspension ---\r\n\t\t// Force acts along the CONTACT NORMAL, not body-up: body-up creates a feedback\r\n\t\t// loop where a tilted car pushes itself sideways, slides, and flips.\r\n\t\t// Compression is clamped to physical travel: bottoming out is a rigid contact\r\n\t\t// for the body collider (bump stop), not unbounded spring force.\r\n\t\tvar normal = _trace.Normal;\r\n\t\tfloat restLength = SuspensionTravel \u002B Radius;\r\n\t\tfloat hitLength = _trace.Distance * Units.UnitsToMeters \u002B TraceSphereRadius; // sphere stops one radius short\r\n\t\tfloat compression = Math.Clamp( restLength - hitLength, 0f, SuspensionTravel );\r\n\t\tSuspensionLength = Math.Clamp( hitLength - Radius, 0f, SuspensionTravel );\r\n\r\n\t\tvar velAtWheel = _rigidbody.GetVelocityAtPoint( WorldPosition ) * Units.UnitsToMeters;\r\n\t\tfloat compressionSpeed = -Vector3.Dot( velAtWheel, normal );\r\n\r\n\t\tfloat springForce = SpringRate * compression \u002B DamperRate * compressionSpeed;\r\n\t\tLoad = Math.Clamp( springForce, 0f, StaticLoad * 4f );\r\n\r\n\t\t_accumulatedForce \u002B= normal * Load;\r\n\t\t_forcePosition = _trace.EndPosition;\r\n\r\n\t\t// --- tire frame on the contact plane ---\r\n\t\tvar steerRot = _rigidbody.WorldRotation * Rotation.FromYaw( SteerAngle );\r\n\t\tvar forward = (steerRot.Forward - normal * Vector3.Dot( steerRot.Forward, normal )).Normal;\r\n\t\tvar side = Vector3.Cross( normal, forward );\r\n\r\n\t\tvar contactVel = _rigidbody.GetVelocityAtPoint( _trace.EndPosition ) * Units.UnitsToMeters;\r\n\t\tfloat vLong = Vector3.Dot( contactVel, forward );\r\n\t\tfloat vLat = Vector3.Dot( contactVel, side );\r\n\t\tGroundSpeed = vLong;\r\n\r\n\t\t// --- slip ---\r\n\t\t// longitudinal uses RAW slip \u002B a one-substep force clamp below (smoothing it added\r\n\t\t// ~100 ms of feedback lag that turned wheel spin into a rhythmic surge);\r\n\t\t// lateral keeps light relaxation, its loop is chassis-side and much slower\r\n\t\tfloat slipVelocity = AngularVelocity * Radius - vLong; // m/s at the contact patch\r\n\t\tSlipRatio = slipVelocity / MathF.Max( MathF.Abs( vLong ), 2.0f );\r\n\r\n\t\tfloat rawSlipAngle = MathF.Atan2( vLat, MathF.Abs( vLong ) \u002B 0.7f );\r\n\t\tfloat relax = Math.Clamp( (MathF.Abs( vLong ) \u002B 1f) * dt / 0.2f, 0.1f, 1f );\r\n\t\t_smoothedSlipAngle \u002B= (rawSlipAngle - _smoothedSlipAngle) * relax;\r\n\t\tSlipAngle = _smoothedSlipAngle;\r\n\r\n\t\t// --- forces from curves, load sensitivity, surface grip ---\r\n\t\tfloat surfaceGrip = _trace.Surface?.Friction ?? 1f;\r\n\t\tfloat loadFactor = 1f - LoadSensitivity * MathF.Max( 0f, Load / StaticLoad - 1f );\r\n\t\tfloat maxForce = Load * loadFactor * surfaceGrip * GripScale;\r\n\r\n\t\tfloat fx = LongitudinalCurve.Evaluate( SlipRatio ) * MathF.Sign( SlipRatio ) * maxForce;\r\n\t\tfloat fy = -LateralCurve.Evaluate( SlipAngle ) * MathF.Sign( SlipAngle ) * maxForce;\r\n\r\n\t\t// one-substep stability clamp: never push the wheel past ground-speed match within\r\n\t\t// a single substep (gain*dt/inertia \u003E 2 here, the raw loop oscillates without this)\r\n\t\tfloat fxStable = MathF.Abs( slipVelocity ) * Inertia / (Radius * Radius * dt);\r\n\t\tfx = Math.Clamp( fx, -fxStable, fxStable );\r\n\r\n\t\t// --- friction ellipse (spec \u00A75.2.1.4) ---\r\n\t\tfloat combined = MathF.Sqrt( fx * fx \u002B fy * fy );\r\n\t\tif ( combined \u003E maxForce \u0026\u0026 combined \u003E 0.001f )\r\n\t\t{\r\n\t\t\tfloat scale = maxForce / combined;\r\n\t\t\tfx *= scale;\r\n\t\t\tfy *= scale;\r\n\t\t}\r\n\r\n\t\t// --- low-speed parking blend: kill standstill jitter ---\r\n\t\t// force is capped at what stops this wheel\u0027s mass share within one fixed frame\r\n\t\t// (spec 5.2.1.6) \u2014 uncapped, it overshoots and becomes a self-sustaining oscillator\r\n\t\tvar planarVel = forward * vLong \u002B side * vLat;\r\n\t\tfloat planarSpeed = planarVel.Length;\r\n\t\tfloat parkOmegaBlend = 0f;\r\n\t\tif ( planarSpeed \u003C 1.5f \u0026\u0026 MathF.Abs( AngularVelocity * Radius ) \u003C 1.5f \u0026\u0026 planarSpeed \u003E 0.001f )\r\n\t\t{\r\n\t\t\t// ParkBrakeScale: throttle dissolves the stiction \u2014 steered fronts were\r\n\t\t\t// \u0022parking\u0022 against full-lock standstill launches (2 km/h crawls, tele 07-07)\r\n\t\t\tfloat blend = (1f - planarSpeed / 1.5f) * ParkBrakeScale;\r\n\t\t\tfloat massShare = StaticLoad / 9.81f; // kg carried by this wheel\r\n\t\t\tfloat frameDt = dt * VehicleController.Substeps;\r\n\t\t\tfloat stopForce = massShare * planarSpeed / frameDt * 0.8f;\r\n\t\t\tfloat parkMag = MathF.Min( MathF.Min( planarSpeed * Load * 1.5f, stopForce ), maxForce );\r\n\r\n\t\t\tvar park = -planarVel / planarSpeed * parkMag;\r\n\t\t\tfx = fx * (1f - blend) \u002B Vector3.Dot( park, forward ) * blend;\r\n\t\t\tfy = fy * (1f - blend) \u002B Vector3.Dot( park, side ) * blend;\r\n\t\t\tparkOmegaBlend = blend;\r\n\t\t}\r\n\r\n\t\t_accumulatedForce \u002B= forward * fx \u002B side * fy;\r\n\r\n\t\tIntegrateWheelSpin( dt, driveTorque, brakeTorque, fx );\r\n\r\n\t\t// Park the wheel\u0027s SPIN, not just the chassis. The parking blend above brakes the chassis, but\r\n\t\t// its reaction torque (-fx*Radius, fed into IntegrateWheelSpin) spins this wheel up and, with\r\n\t\t// nothing anchoring omega at rest, it settles at a nonzero spin that keeps pushing the car: a\r\n\t\t// permanent slow creep with the tyre visibly rotating and the slip ratio pinging the skid\r\n\t\t// threshold (community report: \u0022unless you\u0027ve perfectly stopped, it infinitely skids and rotates\r\n\t\t// the wheels in place\u0022). Pull omega toward the ground-rolling speed vLong/Radius (= 0 at a true\r\n\t\t// standstill) by the SAME blend, so a parked, unbraked wheel settles to zero spin and zero slip.\r\n\t\t// Offline sim: a 0.2 m/s creep that never stopped and skidded 50-100% of frames now settles to\r\n\t\t// under 1 mm/s with zero skid, while cruise (over 1.5 m/s, blend inert) is byte-identical. Fades\r\n\t\t// out with throttle (blend carries ParkBrakeScale) so standstill launches are unaffected.\r\n\t\tif ( parkOmegaBlend \u003E 0f )\r\n\t\t\tAngularVelocity = MathX.Lerp( AngularVelocity, vLong / Radius, parkOmegaBlend );\r\n\t}\r\n\r\n\t// Cap-aware drive-torque rolloff onset (kart cap-camping fix 2026-07-18): drive torque begins\r\n\t// fading toward zero once the wheel\u0027s own spin reaches this fraction of DriveOmegaCap; below it\r\n\t// the rolloff is inert so below-cap behavior is byte-identical.\r\n\tconst float DriveRolloffOnset = 0.90f;\r\n\r\n\tvoid IntegrateWheelSpin( float dt, float driveTorque, float brakeTorque, float tireForce )\r\n\t{\r\n\t\tfloat preOmega = AngularVelocity;\r\n\t\tbool driving = driveTorque != 0f;\r\n\t\tfloat driveDir = driving ? MathF.Sign( driveTorque ) : 0f;\r\n\r\n\t\t// Cap-aware drive-torque rolloff (kart \u0022stuck turning\u0022 fix 2026-07-18). The per-substep\r\n\t\t// clamp below is a hard backstop, but with the clamp ALONE a driven wheel under sustained\r\n\t\t// full torque CAMPS exactly at the cap; when forward speed then collapses in a corner the\r\n\t\t// slip ratio blows far past the grip peak (live: 7\u002B) and the longitudinal tail force eats the\r\n\t\t// friction ellipse, killing rear lateral grip so the yaw holds against countersteer. Fade\r\n\t\t// drive torque to zero as the wheel approaches the cap so it settles OFF the cap instead of\r\n\t\t// camping on it. Smoothstep, not a hard corner, to avoid a torque-fade limit cycle. Inert\r\n\t\t// below the onset (approach \u003C= DriveRolloffOnset) so below-cap behavior is byte-identical.\r\n\t\tif ( driving \u0026\u0026 DriveOmegaCap \u003C float.MaxValue )\r\n\t\t{\r\n\t\t\tfloat approach = driveDir * preOmega / DriveOmegaCap;\r\n\t\t\tif ( approach \u003E DriveRolloffOnset )\r\n\t\t\t{\r\n\t\t\t\tfloat f = Math.Clamp( (1f - approach) / (1f - DriveRolloffOnset), 0f, 1f );\r\n\t\t\t\tdriveTorque *= f * f * (3f - 2f * f);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// drive \u002B tire reaction\r\n\t\tfloat torque = driveTorque - tireForce * Radius;\r\n\t\tAngularVelocity \u002B= torque / Inertia * dt;\r\n\r\n\t\t// Per-substep drive-side overshoot clamp (kart high-PeakTorque wobble hunt 2026-07-18).\r\n\t\t// The rev limiter zeroes torque only on the substep AFTER wheel-implied rpm crosses redline,\r\n\t\t// so one substep of unlimited drive torque on a light wheel overshoots redline-equivalent\r\n\t\t// omega by up to 6-8x (measured live: kart at 900 N-m spikes to 289-339 rad/s vs the 44 rad/s\r\n\t\t// gear-1 redline equivalent; even stock 52 N-m reaches 141). The spike is what lets an\r\n\t\t// unloaded rear diverge violently from its loaded twin over any perturbation (the felt\r\n\t\t// \u0022individual tires have different traction\u0022 left-right wobble). Clamp: DRIVE torque may\r\n\t\t// never push omega past the cap within a substep. Signed by drive direction so reverse works;\r\n\t\t// cap floors at the pre-integration omega so ground-driven overspeed (downhill coast) is\r\n\t\t// never yanked down, and the ground reaction path is untouched. Guarded on the ORIGINAL drive\r\n\t\t// intent so a rolloff that faded torque to zero still cannot let the wheel blow past the cap.\r\n\t\tif ( driving \u0026\u0026 DriveOmegaCap \u003C float.MaxValue )\r\n\t\t{\r\n\t\t\tfloat cap = MathF.Max( DriveOmegaCap, driveDir * preOmega );\r\n\t\t\tAngularVelocity = driveDir \u003E 0f\r\n\t\t\t\t? MathF.Min( AngularVelocity, cap )\r\n\t\t\t\t: MathF.Max( AngularVelocity, -cap );\r\n\t\t}\r\n\r\n\t\t// brakes can stop the wheel but never reverse it within a step\r\n\t\tif ( brakeTorque \u003E 0f )\r\n\t\t{\r\n\t\t\tfloat brakeDelta = brakeTorque / Inertia * dt;\r\n\t\t\tAngularVelocity = MathF.Abs( AngularVelocity ) \u003C= brakeDelta\r\n\t\t\t\t? 0f\r\n\t\t\t\t: AngularVelocity - MathF.Sign( AngularVelocity ) * brakeDelta;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EApply the substep-averaged force to the chassis. Call once per fixed update.\u003C/summary\u003E\r\n\tpublic void EndStep()\r\n\t{\r\n\t\tif ( _substeps == 0 || _accumulatedForce.IsNearZeroLength )\r\n\t\t\treturn;\r\n\r\n\t\tvar averaged = _accumulatedForce / _substeps;\r\n\t\t_rigidbody.ApplyForceAt( _forcePosition, averaged * Units.MetersToUnits );\r\n\t}\r\n\r\n\tvoid DoTrace()\r\n\t{\r\n\t\tvar down = _rigidbody.WorldRotation.Down;\r\n\t\tfloat lengthUnits = (SuspensionTravel \u002B Radius) * Units.MetersToUnits;\r\n\r\n\t\t_trace = Scene.Trace\r\n\t\t\t.Radius( 1f )\r\n\t\t\t.IgnoreGameObjectHierarchy( _rigidbody.GameObject )\r\n\t\t\t.WithoutTags( \u0022car\u0022 )\r\n\t\t\t.FromTo( WorldPosition, WorldPosition \u002B down * lengthUnits )\r\n\t\t\t.Run();\r\n\t}\r\n}\r\n"},{"Ident":"fieldguide.vehiclephysics","Path":"Code/Drivetrain.cs","FileName":"Drivetrain.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":313829,"Code":"namespace FieldGuide.VehiclePhysics;\r\n\r\n/// \u003Csummary\u003E\r\n/// Engine \u002B clutch \u002B gearbox \u002B diff state machine. Plain class owned by\r\n/// VehicleController, simulated per substep. RPM is its own integrated state coupled to\r\n/// the wheels through an auto-clutch, so revving at standstill and shift flare exist.\r\n/// \u003C/summary\u003E\r\npublic class Drivetrain\r\n{\r\n\treadonly CarDefinition _def;\r\n\r\n\tpublic float Rpm { get; private set; }\r\n\tpublic int Gear { get; private set; } = 1; // 1-based; 0 = neutral, -1 = reverse\r\n\tpublic bool IsShifting =\u003E _shiftTimer \u003E 0f;\r\n\r\n\t/// \u003Csummary\u003EDriver-selectable transmission mode. false (default) = the untuned automatic box\r\n\t/// (byte-identical existing behavior); true = sequential MANUAL \u2014 the auto-shift block in\r\n\t/// \u003Csee cref=\u0022Simulate\u0022/\u003E is suppressed and gear changes come from \u003Csee cref=\u0022ShiftUp\u0022/\u003E /\r\n\t/// \u003Csee cref=\u0022ShiftDown\u0022/\u003E driven off input. The rev limiter, shift timer/lockout, and clutch\r\n\t/// machinery are untouched by the mode (they model the shift event either way).\u003C/summary\u003E\r\n\tpublic bool ManualMode { get; set; }\r\n\r\n\t/// \u003Csummary\u003ERedline (rpm) for this car \u2014 read by the manual over-rev guard / UI.\u003C/summary\u003E\r\n\tpublic float Redline =\u003E _def.RedlineRpm;\r\n\r\n\tfloat _shiftTimer;\r\n\tfloat _shiftLockout;\r\n\tfloat _freeRpm; // engine-side rpm when the clutch slips\r\n\tfloat _limiterHold; // seconds spent pinned near redline under power (limiter-camp escape)\r\n\r\n\t// Downshift arming (anti-hunt hysteresis): a gear entered by UPSHIFT starts unproven and may\r\n\t// not auto-downshift under power until groundRpm has first risen past ShiftDownRpm \u2014 an escape\r\n\t// shift can land below that threshold on purpose (recovery in progress), and a fixed-length\r\n\t// lockout can expire before the low-rpm clutch-slip zone climbs out (measured: 2nd entered at\r\n\t// ~1150 ground rpm climbs ~575 rpm/s, so 1.5 s ends at ~1980 \u003C the 2200 downshift point \u2014\r\n\t// the box bounced straight back into the wheelspin it had escaped). Lifting the throttle\r\n\t// re-arms the downshift immediately, so coasting to a stop still steps down normally.\r\n\tbool _gearProven = true;\r\n\r\n\tpublic Drivetrain( CarDefinition def )\r\n\t{\r\n\t\t_def = def;\r\n\t\tRpm = def.IdleRpm;\r\n\t\t_freeRpm = def.IdleRpm;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EAnalytic torque curve: ~50% at idle, peak ~75% of the band, mild falloff at redline.\u003C/summary\u003E\r\n\tpublic float EngineTorqueAt( float rpm )\r\n\t{\r\n\t\tfloat n = Math.Clamp( (rpm - _def.IdleRpm) / (_def.RedlineRpm - _def.IdleRpm), 0f, 1f );\r\n\t\tfloat shape = (0.5f \u002B 1.5f * n - n * n) / 1.0625f;\r\n\t\treturn _def.PeakTorque * shape;\r\n\t}\r\n\r\n\tfloat CurrentRatio =\u003E Gear switch\r\n\t{\r\n\t\t\u003E 0 =\u003E _def.GearRatios[Gear - 1] * _def.FinalDrive,\r\n\t\t-1 =\u003E -_def.ReverseRatio * _def.FinalDrive,\r\n\t\t_ =\u003E 0f\r\n\t};\r\n\r\n\t/// \u003Csummary\u003ERedline-equivalent driven-wheel angular speed (rad/s) for the CURRENT gear ratio;\r\n\t/// float.MaxValue in neutral (no drive coupling). Read fresh each substep by the controller\r\n\t/// (the ratio changes on a shift) and handed to the wheels as the per-substep drive-side omega\r\n\t/// cap: the rev limiter cuts torque only on the substep AFTER wheel-implied rpm crosses redline,\r\n\t/// and a light wheel at high PeakTorque can blow 6x past redline-equivalent within that one\r\n\t/// substep (measured: kart at 900 N-m hits 289-339 rad/s vs a 44 rad/s gear-1 redline\r\n\t/// equivalent). The cap makes the limiter effectively per-substep on the drive side.\u003C/summary\u003E\r\n\tpublic float RedlineWheelSpeed\r\n\t\t=\u003E CurrentRatio == 0f ? float.MaxValue : _def.RedlineRpm * MathF.Tau / 60f / MathF.Abs( CurrentRatio );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// One substep. avgDrivenWheelSpeed and groundWheelSpeed in rad/s. Returns torque per driven\r\n\t/// wheel (N\u00B7m). Clutch engagement is a continuous blend \u2014 a binary locked/slipping switch\r\n\t/// sits right at town-driving speeds and judders. Shift decisions use GROUND speed, not\r\n\t/// engine/wheel rpm: wheelspin inflates engine rpm, causing 1-2-1-2 shift hunting where\r\n\t/// every downshift torque-spikes the rear tires (spin-outs).\r\n\t/// \u003C/summary\u003E\r\n\tpublic float Simulate( float dt, float throttle, float avgDrivenWheelSpeed, float groundWheelSpeed, int drivenWheelCount )\r\n\t{\r\n\t\tif ( _shiftTimer \u003E 0f )\r\n\t\t{\r\n\t\t\t_shiftTimer -= dt;\r\n\t\t\tthrottle = 0f; // torque cut during shift\r\n\t\t}\r\n\r\n\t\tfloat ratio = CurrentRatio;\r\n\t\tfloat wheelImpliedRpm = MathF.Abs( avgDrivenWheelSpeed * ratio ) * 60f / MathF.Tau;\r\n\r\n\t\t// continuous auto-clutch: 0 at stall rpm, fully locked a few hundred rpm above idle\r\n\t\tfloat stallRpm = _def.IdleRpm * 1.05f;\r\n\t\tfloat lockRpm = _def.IdleRpm * 1.7f;\r\n\t\tfloat engagement = Gear == 0 ? 0f : Math.Clamp( (wheelImpliedRpm - stallRpm) / (lockRpm - stallRpm), 0f, 1f );\r\n\r\n\t\t// engine-side rpm when slipping: revs toward the throttle target\r\n\t\tfloat freeTarget = _def.IdleRpm \u002B throttle * (_def.RedlineRpm * 0.5f - _def.IdleRpm);\r\n\t\t_freeRpm = Math.Clamp( _freeRpm \u002B (freeTarget - _freeRpm) * 5f * dt, _def.IdleRpm, _def.RedlineRpm );\r\n\r\n\t\tfloat lockedRpm = Math.Clamp( wheelImpliedRpm, _def.IdleRpm, _def.RedlineRpm );\r\n\t\tRpm = MathX.Lerp( _freeRpm, lockedRpm, engagement );\r\n\r\n\t\tfloat engineTorque = EngineTorqueAt( Rpm ) * throttle;\r\n\t\tfloat engineBrake = _def.EngineBrakeTorque * (1f - throttle) * (Rpm / _def.RedlineRpm) * engagement;\r\n\t\tfloat slipTransmission = Math.Clamp( throttle * 1.2f, 0f, 1f ) * 0.85f;\r\n\t\tfloat clutchFactor = MathX.Lerp( slipTransmission, 1f, engagement );\r\n\t\tfloat torqueOut = (engineTorque * clutchFactor - engineBrake) * ratio;\r\n\r\n\t\t// rev limiter\r\n\t\tif ( Rpm \u003E= _def.RedlineRpm \u0026\u0026 throttle \u003E 0f )\r\n\t\t\ttorqueOut = MathF.Min( torqueOut, 0f );\r\n\r\n\t\t// automatic shifting from ground-speed-implied rpm \u002B post-shift lockout\r\n\t\t_shiftLockout -= dt;\r\n\t\tfloat groundRpm = MathF.Abs( groundWheelSpeed * ratio ) * 60f / MathF.Tau;\r\n\r\n\t\t// Limiter-camp escape: the upshift decision reads GROUND-speed rpm (anti-hunt \u2014 see the\r\n\t\t// header comment) but the engine \u002B rev limiter run on WHEEL-implied rpm, and wheelspin\r\n\t\t// separates the two. With traction control off, a hard launch inflates engine rpm onto the\r\n\t\t// limiter while groundRpm is still below ShiftUpRpm \u2014 the box then bounces on the limiter\r\n\t\t// until ground speed catches up (measured on the hatch: ~3.4 s pinned 5945\u20136300 in 1st\r\n\t\t// while groundRpm crawled 560\u21925730). The limiter cut decelerates a spinning wheel within\r\n\t\t// substeps, so the bounce dips a few percent below redline many times a second \u2014 the\r\n\t\t// near-limiter window is therefore WIDE (0.94) and the hold DECAYS on dips instead of\r\n\t\t// resetting, or the oscillation zeroes the timer forever and the escape never fires.\r\n\t\tbool nearLimiter = Rpm \u003E= _def.RedlineRpm * 0.94f \u0026\u0026 throttle \u003E 0.5f;\r\n\t\t_limiterHold = nearLimiter ? _limiterHold \u002B dt : MathF.Max( 0f, _limiterHold - dt * 0.5f );\r\n\r\n\t\t// Downshift arming: the current gear proves itself the moment ground rpm clears the\r\n\t\t// downshift threshold; from then on the normal downshift rule applies unchanged.\r\n\t\tif ( !_gearProven \u0026\u0026 groundRpm \u003E= _def.ShiftDownRpm )\r\n\t\t\t_gearProven = true;\r\n\r\n\t\tif ( !ManualMode \u0026\u0026 Gear \u003E 0 \u0026\u0026 !IsShifting \u0026\u0026 _shiftLockout \u003C= 0f )\r\n\t\t{\r\n\t\t\tbool wantUp = groundRpm \u003E _def.ShiftUpRpm;\r\n\t\t\tbool escape = false;\r\n\t\t\tif ( !wantUp \u0026\u0026 _limiterHold \u003E 0.25f \u0026\u0026 Gear \u003C _def.GearRatios.Length )\r\n\t\t\t{\r\n\t\t\t\t// Escape guard: the next gear only needs to be VIABLE, not already above the\r\n\t\t\t\t// downshift point \u2014 a spinning launch hooks up instantly on the taller gear and\r\n\t\t\t\t// recovers under the downshift-arming hysteresis (which holds the box in the new\r\n\t\t\t\t// gear through the sub-ShiftDownRpm climb no matter how long it takes).\r\n\t\t\t\tfloat nextRatio = _def.GearRatios[Gear] * _def.FinalDrive; // Gear is 1-based \u2192 [Gear] = next gear up\r\n\t\t\t\tfloat postShiftGroundRpm = MathF.Abs( groundWheelSpeed * nextRatio ) * 60f / MathF.Tau;\r\n\t\t\t\twantUp = escape = postShiftGroundRpm \u003E= _def.ShiftDownRpm * 0.5f;\r\n\t\t\t}\r\n\r\n\t\t\tif ( wantUp \u0026\u0026 Gear \u003C _def.GearRatios.Length )\r\n\t\t\t{\r\n\t\t\t\tGear\u002B\u002B;\r\n\t\t\t\t_shiftTimer = 0.15f;\r\n\t\t\t\t// Escape shifts keep a longer settle hold; the arming hysteresis above is the real\r\n\t\t\t\t// anti-hunt guard (a fixed timer alone measurably expired mid-recovery).\r\n\t\t\t\t_shiftLockout = escape ? 1.5f : 0.8f;\r\n\t\t\t\t_limiterHold = 0f;\r\n\t\t\t\t_gearProven = false; // entered by upshift \u2192 must prove itself before downshifting under power\r\n\t\t\t}\r\n\t\t\telse if ( groundRpm \u003C _def.ShiftDownRpm \u0026\u0026 Gear \u003E 1 \u0026\u0026 (_gearProven || throttle \u003C 0.5f) )\r\n\t\t\t{\r\n\t\t\t\tGear--;\r\n\t\t\t\t_shiftTimer = 0.12f;\r\n\t\t\t\t_shiftLockout = 0.8f;\r\n\t\t\t\t_gearProven = true; // entered downward \u2014 ground rpm is higher here by ratio; nothing to prove\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// open diff v1: equal split across driven wheels\r\n\t\treturn drivenWheelCount \u003E 0 ? torqueOut / drivenWheelCount : 0f;\r\n\t}\r\n\r\n\tpublic void EngageReverse() { Gear = -1; _shiftTimer = 0f; _gearProven = true; }\r\n\tpublic void EngageForward() { if ( Gear \u003C= 0 ) { Gear = 1; _shiftTimer = 0f; _gearProven = true; } }\r\n\r\n\t// \u2500\u2500 sequential MANUAL shift \u2500\u2500\r\n\t// Gated on IsShifting only (the 0.15 s torque-cut window), NOT on _shiftLockout \u2014 the 0.8 s\r\n\t// lockout is the auto box\u0027s anti-hunt guard and would make a hand-shifted sequential box feel\r\n\t// sluggish; the player decides when to shift. Both timers are still set so the shift flare /\r\n\t// torque cut model exactly as the auto path, and so toggling back to AUTO inherits a clean state.\r\n\r\n\t/// \u003Csummary\u003ESequential manual up-shift: advance one forward gear. No-op in reverse/neutral, at the\r\n\t/// top gear, or mid-shift. Returns true if a gear change happened.\u003C/summary\u003E\r\n\tpublic bool ShiftUp()\r\n\t{\r\n\t\tif ( IsShifting ) return false;\r\n\t\tif ( Gear \u003C= 0 || Gear \u003E= _def.GearRatios.Length ) return false;\r\n\t\tGear\u002B\u002B;\r\n\t\t_shiftTimer = 0.15f;\r\n\t\t_shiftLockout = 0.8f;\r\n\t\t_gearProven = true; // player-commanded \u2014 the auto arming rule never second-guesses manual shifts\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESequential manual down-shift: drop one forward gear \u2014 BLOCKED if it would throw engine\r\n\t/// rpm past redline at the current ground speed (money-shift / over-rev guard). No-op below 1st, in\r\n\t/// reverse/neutral, or mid-shift. \u003Cparamref name=\u0022groundWheelSpeed\u0022/\u003E is the ground-implied wheel\r\n\t/// speed (rad/s), the same quantity the auto path shifts on. Returns true if a gear change happened.\u003C/summary\u003E\r\n\tpublic bool ShiftDown( float groundWheelSpeed )\r\n\t{\r\n\t\tif ( IsShifting ) return false;\r\n\t\tif ( Gear \u003C= 1 ) return false; // below 1st does nothing; reverse engages automatically (ReadInput)\r\n\t\tif ( PredictedDownshiftRpm( groundWheelSpeed ) \u003E _def.RedlineRpm ) return false; // over-rev guard\r\n\t\tGear--;\r\n\t\t_shiftTimer = 0.12f;\r\n\t\t_shiftLockout = 0.8f;\r\n\t\t_gearProven = true; // player-commanded \u2014 the auto arming rule never second-guesses manual shifts\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EEngine rpm a one-gear downshift would produce at this ground wheel speed (rad/s), using\r\n\t/// the SAME ground-speed\u2192rpm mapping as the auto-shift decision so the over-rev guard and the rev\r\n\t/// limiter agree. Returns the current \u003Csee cref=\u0022Rpm\u0022/\u003E when there is no lower forward gear.\u003C/summary\u003E\r\n\tpublic float PredictedDownshiftRpm( float groundWheelSpeed )\r\n\t{\r\n\t\tif ( Gear \u003C= 1 ) return Rpm;\r\n\t\tfloat lowerRatio = _def.GearRatios[Gear - 2] * _def.FinalDrive;\r\n\t\treturn MathF.Abs( groundWheelSpeed * lowerRatio ) * 60f / MathF.Tau;\r\n\t}\r\n}\r\n"}]}