--[[ diag_steering.luau -- measure WHY the car will not turn. Builds a real car through CarManager, gives it a straight test surface, then drives it at full lock and measures what actually happens: * masses (body vs unsprung vs ballast) * servo TargetAngle vs CurrentAngle under load * achieved yaw rate and turning radius vs the Ackermann prediction * lateral slip at each wheel Everything is measured in-engine; nothing here is assumed. ]] local ServerScriptService = game:GetService("ServerScriptService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local RunService = game:GetService("RunService") local out = {} local function log(...) local parts = {} for i = 1, select("#", ...) do parts[#parts + 1] = tostring((select(i, ...))) end table.insert(out, table.concat(parts, " ")) end local Shared = ReplicatedStorage:FindFirstChild("Shared") local Cars = require(Shared.Cars) local Chassis = require(Shared.Chassis) local Server = ServerScriptService:FindFirstChild("Server", true) local CarManager = require(Server.CarManager) -- Flat, generous test pad well away from the map. local pad = Instance.new("Part") pad.Name = "DiagPad" pad.Size = Vector3.new(4000, 8, 4000) pad.Position = Vector3.new(0, 5000, 0) pad.Anchored = true pad.Material = Enum.Material.Concrete pad.Parent = workspace local padTop = pad.Position.Y + 4 log( "pad friction:", string.format("%.2f", pad.CurrentPhysicalProperties.Friction), " frictionWeight:", string.format("%.2f", pad.CurrentPhysicalProperties.FrictionWeight) ) local carId = "gtr_r34" local car = Cars.Get(carId) local tuning = Chassis.tuningFor(car) log("car:", carId, "Handling:", car.Stats.Handling, "Speed:", car.Stats.Speed) log( string.format( "tuning MaxSteerAngle %.1f deg, SteerSpeed %.1f, WheelFriction %.2f, MaxSpeed %.0f", tuning.MaxSteerAngle, tuning.SteerSpeed, tuning.WheelFriction, tuning.MaxSpeed ) ) -- Build the car exactly as the game does. local model = CarManager._buildForDiag and CarManager._buildForDiag(car, tuning) or nil if not model then -- fall back: use the public spawn path into the pad area log("NOTE: no diag hook; using CarManager.SpawnCarAt if present") end if not model then log("FATAL: cannot build a car model headlessly -- need a build hook") return table.concat(out, "\n") end model.Parent = workspace model:PivotTo(CFrame.new(0, padTop + 4, 0)) local body = model.PrimaryPart local function findAll(name) local t = {} for _, d in ipairs(model:GetDescendants()) do if d.Name == name or (d:IsA("BasePart") and d.Name:sub(1, #name) == name) then table.insert(t, d) end end return t end -- MASS BREAKDOWN ----------------------------------------------------------- local total, sprung, unsprung = 0, 0, 0 for _, d in ipairs(model:GetDescendants()) do if d:IsA("BasePart") and not d.Massless then local m = d:GetMass() total += m if d.Name:sub(1, 5) == "Wheel" or d.Name:sub(1, 3) == "Hub" or d.Name:sub(1, 7) == "Knuckle" then unsprung += m else sprung += m end end end log( string.format( "MASS total %.1f | sprung %.1f | unsprung %.1f (%.0f%% unsprung)", total, sprung, unsprung, 100 * unsprung / math.max(total, 1) ) ) local wheels = findAll("Wheel_") local servos = {} for _, d in ipairs(model:GetDescendants()) do if d:IsA("HingeConstraint") and d.Name == Chassis.STEER_SERVO_NAME then table.insert(servos, d) end end local motors = {} for _, d in ipairs(model:GetDescendants()) do if d:IsA("HingeConstraint") and d.Name == Chassis.WHEEL_MOTOR_NAME then table.insert(motors, d) end end log("found", #wheels, "wheels,", #servos, "servos,", #motors, "motors") if #wheels > 0 then local w = wheels[1] local p = w.CurrentPhysicalProperties log(string.format("wheel friction %.2f weight %.2f | mass %.2f", p.Friction, p.FrictionWeight, w:GetMass())) end -- Let it settle on the pad. for _ = 1, 90 do RunService.Heartbeat:Wait() end log(string.format("settled: body Y %.2f, vel %.2f", body.Position.Y, body.AssemblyLinearVelocity.Magnitude)) -- DRIVE TEST: full throttle, full right lock -------------------------------- local samples = {} local t0 = os.clock() local startCF = body.CFrame local maxServoErr, servoErrSum, servoN = 0, 0, 0 for step = 1, 300 do local speed = body.AssemblyLinearVelocity.Magnitude local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1) local lockScale = 1 - 0.6 * speedFrac local targetAngle = Chassis.STEER_SIGN * 1 * tuning.MaxSteerAngle * lockScale for _, s in ipairs(servos) do s.TargetAngle = targetAngle local err = math.abs(s.CurrentAngle - targetAngle) maxServoErr = math.max(maxServoErr, err) servoErrSum += err servoN += 1 end for _, m in ipairs(motors) do m.AngularVelocity = Chassis.DRIVE_SIGN * tuning.MaxWheelAngularVelocity m.MotorMaxTorque = tuning.MotorMaxTorque end if step % 30 == 0 then local yawRate = body.AssemblyAngularVelocity.Y table.insert(samples, { t = os.clock() - t0, speed = speed, yaw = yawRate, target = targetAngle, cur = #servos > 0 and servos[1].CurrentAngle or 0, }) end RunService.Heartbeat:Wait() end log(string.format("SERVO tracking: mean err %.2f deg, max %.2f deg", servoErrSum / math.max(servoN, 1), maxServoErr)) log("t(s) speed yawRate targetAng curAng radius") for _, s in ipairs(samples) do local radius = math.abs(s.yaw) > 1e-4 and (s.speed / math.abs(s.yaw)) or math.huge log(string.format("%4.1f %6.1f %7.3f %8.1f %7.1f %8.1f", s.t, s.speed, s.yaw, s.target, s.cur, radius)) end -- Ackermann prediction for comparison. local wheelbase = Chassis.WHEELBASE_HALF * 2 local last = samples[#samples] if last then local predicted = wheelbase / math.tan(math.rad(math.abs(last.cur))) local actual = math.abs(last.yaw) > 1e-4 and (last.speed / math.abs(last.yaw)) or math.huge log( string.format( "wheelbase %.1f studs | Ackermann radius at %.1f deg = %.1f studs | ACTUAL %.1f studs", wheelbase, math.abs(last.cur), predicted, actual ) ) log(string.format("UNDERSTEER FACTOR: %.2fx (1.0 = neutral, >2 = plough)", actual / math.max(predicted, 0.01))) end -- Net heading change over the run. local dot = startCF.LookVector:Dot(body.CFrame.LookVector) log(string.format("heading change over run: %.1f deg", math.deg(math.acos(math.clamp(dot, -1, 1))))) model:Destroy() pad:Destroy() return table.concat(out, "\n")