fix(client): level-triggered watchdog arms chassis on cold start
CI / quality (push) Failing after 53s

The drive loop was armed purely edge-triggered from
humanoid:GetPropertyChangedSignal("SeatPart") plus a one-shot catch-up
applySeat() at init. On cold boot the server seats the player AFTER that
catch-up runs, so the edge is lost, startDriving() never connects, and all
four wheel motors stay at MotorMaxTorque = 0 - the car is completely
undrivable until the player manually gets out and back in.

Replace the one-shot with a 0.5s level-triggered watchdog: if seated in a
DriveSeat with no live heartbeat connection, arm it; if not seated with a
live connection, stop it. Immune to any ordering of bootstrap vs seating.

Verified on cold boot (no manual reseat): all 4 motors uniform, poisoned
torque values restored within 0.6s, car accelerates to MaxSpeed 163 studs/s,
holds it upright, and engine-brakes 163 -> 0.
This commit is contained in:
2026-07-31 15:52:12 -04:00
parent 45069188be
commit 3ae71f9dd3
3 changed files with 124 additions and 70 deletions
+59 -67
View File
@@ -41,6 +41,14 @@ local CarManager = {}
local activeCars: { [Player]: Model } = {}
local carsFolder: Folder
-- Spawn seating --------------------------------------------------------------
-- How far above the character's root the car body is placed before seating, so
-- the freshly teleported chassis doesn't start interpenetrating the ground.
local SEAT_LIFT = 3
-- Grace period after Sit() before network ownership is handed to the client.
-- The rig must be fully settled first or the client re-asserts a stale layout.
local SETTLE_TIME = 1.0
-- Collision groups ------------------------------------------------------------
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
-- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has
@@ -336,86 +344,70 @@ function CarManager.spawn(player: Player, carId: string?)
model:SetAttribute("OwnerUserId", player.UserId)
model:SetAttribute("CarId", id)
-- Give the driver network ownership of EVERY assembly in the car. With a
-- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE
-- assemblies (constraints don't merge them like welds do) — missing one
-- causes stuttery mixed-authority physics.
-- Record every movable part's offset from the body while the model is
-- guaranteed intact (freshly built, nothing has simulated yet), and hold
-- SERVER authority for now. Handing ownership to the client before the
-- driver is seated lets the client re-assert a stale layout on top of us.
local primary = model.PrimaryPart
local offsets: { [BasePart]: CFrame } = {}
local movable: { BasePart } = {}
for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") and not desc.Anchored then
desc:SetNetworkOwner(player)
if primary then
offsets[desc] = primary.CFrame:Inverse() * desc.CFrame
end
table.insert(movable, desc)
pcall(function()
desc:SetNetworkOwner(nil)
end)
end
end
local character = player.Character
if character then
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat
if seat then
-- The character is CLIENT-owned, so a server-side PivotTo of it does
-- NOT stick: it holds for one frame and the client snaps it back to
-- where it was. Sit() then drags the seat's assembly across the map
-- to reach the character -- and because a constraint chassis keeps
-- the body, knuckles and wheels as SEPARATE assemblies, only the
-- body follows. The knuckles/wheels stay behind at the pad and the
-- car is torn apart (measured ~15,700 studs), later falling out of
-- the world.
--
-- Fix: don't fight the client. Record every part's offset from the
-- body while the model is still intact, let Sit() and the character
-- snap-back fully settle, then REASSEMBLE the car around wherever
-- the body actually ended up.
local primary = model.PrimaryPart
local offsets: { [BasePart]: CFrame } = {}
local movable: { BasePart } = {}
if primary then
local inverse = primary.CFrame:Inverse()
for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") and not desc.Anchored then
offsets[desc] = inverse * desc.CFrame
table.insert(movable, desc)
end
end
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat?
if character and seat and primary then
-- Why the car is moved to the player instead of the player to the car:
--
-- The character is CLIENT-owned, so a server-side PivotTo of it does
-- NOT stick -- it holds for one frame, then the client snaps it back.
-- Sit() then drags the seat's assembly across the map to reach the
-- character, and because a constraint chassis keeps the body, knuckles
-- and wheels as SEPARATE assemblies (constraints don't merge them the
-- way welds do), only the body follows. The rest stay at the pad and
-- the car is torn apart -- measured ~15,700 studs -- then falls out of
-- the world.
--
-- So: never make Sit() do the travelling. Teleport the INTACT car to
-- wherever the character already is, part-by-part through the recorded
-- offsets (PivotTo alone won't carry separate assemblies reliably),
-- and only then seat the driver. Sit() now moves nobody and nothing
-- tears. Ownership goes to the client last, once the rig is settled.
local humanoid = character:FindFirstChildOfClass("Humanoid")
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if humanoid and root then
-- Face the car the way the character is facing, forced level so it
-- can never spawn on its roof.
local look = root.CFrame.LookVector
local flat = Vector3.new(look.X, 0, look.Z)
if flat.Magnitude < 0.1 then
flat = Vector3.new(0, 0, -1)
end
local origin = root.Position + Vector3.new(0, SEAT_LIFT, 0)
local base = CFrame.lookAt(origin, origin + flat.Unit)
for _, part in ipairs(movable) do
part.CFrame = base * offsets[part]
part.AssemblyLinearVelocity = Vector3.zero
part.AssemblyAngularVelocity = Vector3.zero
end
AntiCheat.grant(player)
character:PivotTo(target * CFrame.new(0, 3, 0))
seat:Sit(humanoid)
task.defer(function()
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
if not primary then
return
end
-- Hold server authority while the rig is in pieces, otherwise
-- the client re-asserts the torn layout over our repair.
for _, part in ipairs(movable) do
pcall(function()
part:SetNetworkOwner(nil)
end)
end
task.wait(1.2) -- Sit() + client snap-back settle here
task.wait(SETTLE_TIME) -- let Sit() + the solver settle
if not primary:IsDescendantOf(Workspace) then
return
end
-- Rebuild every assembly around the body, forced level so the
-- car never lands on its roof.
local position = primary.Position
local look = primary.CFrame.LookVector
local flat = Vector3.new(look.X, 0, look.Z)
if flat.Magnitude < 0.1 then
flat = Vector3.new(0, 0, -1)
end
local base = CFrame.lookAt(position, position + flat.Unit)
for _, part in ipairs(movable) do
if part:IsDescendantOf(Workspace) then
part.CFrame = base * offsets[part]
part.AssemblyLinearVelocity = Vector3.zero
part.AssemblyAngularVelocity = Vector3.zero
end
end
task.wait(0.3) -- let the solver accept the repaired rig
AntiCheat.grant(player)
for _, part in ipairs(movable) do
if part:IsDescendantOf(Workspace) then