diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index aa62271..c9676bc 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -155,7 +155,21 @@ end -- Build one corner of the suspension. `isFront` corners get a steering servo. -- All positions are in BODY-LOCAL space, converted via body.CFrame. local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number) - local isFront = zSign < 0 -- car forward is -Z + -- FORWARD CONVENTION: car forward is body-local -Z. This is the Roblox + -- standard -- CFrame.LookVector IS the -Z basis vector. + -- + -- DO NOT "fix" this to `zSign > 0`. That was tried on 2026-08-02 and made + -- things strictly worse: 2 of 5 cars were backwards before, all 5 after. + -- It was justified by tools/diag_seatdir.luau reporting "throttle drives + -- toward local +Z", which was a SIGN ERROR in the diagnostic, not a fact + -- about the game: expressing a part's own LookVector in its own local + -- frame is (0,0,-1) BY DEFINITION, so the script mixed world and local + -- space and then "confirmed" the inversion it was looking for. + -- + -- Lesson: a measurement that merely restates a definition cannot be + -- evidence. Before trusting a diagnostic, ask what result would DISPROVE + -- the hypothesis -- if no possible output would, the check is worthless. + local isFront = zSign < 0 local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R") local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF) @@ -247,6 +261,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new()) local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new()) + -- STEERING/MOTOR CONFLICT -- UNRESOLVED, see docs note below. + -- + -- A steered front corner is two hinges in series: Hub --SteerServo(Y)--> + -- Knuckle --WheelMotor(X)--> Wheel. Measured (tools/diag_steer4.luau, + -- gtr_r34, target -20 deg): the servo tracks to within 0.01 deg ONLY when + -- the front wheel is detached or its hinge disabled; with the wheel + -- attached it stays pinned near 0 deg even hoisted in free air with + -- ServoMaxTorque up to 1e9. So it is NOT grip and NOT a torque contest. + -- + -- Do NOT "fix" this by skipping the front motor: the motor hinge is the + -- only joint attaching Wheel_F* to Knuckle_F*, so omitting it leaves the + -- front wheels physically detached (verified, tools/diag_frontjoint.luau) + -- -- the servo then turns freely because it carries no load, and the car + -- generates no front lateral force at all. local motor = Instance.new("HingeConstraint") motor.Name = Chassis.WHEEL_MOTOR_NAME motor.Attachment0 = parentAtt @@ -410,10 +438,22 @@ local function buildCarModel(car: Car, tuning: Tuning): Model -- Derive each shell's true forward from its OWN wheel names rather -- than a hand-maintained per-car constant: F* wheels are the front -- axle, R* the rear, so (front - rear) is the nose direction in the - -- shell's local frame. Solve the yaw that maps it onto -Z. This - -- also covers 90-degree-rotated shells, and self-corrects for any - -- shell added later. If the cue is missing or degenerate, rotate - -- nothing (fail soft to previous behaviour) rather than guess. + -- shell's local frame. Solve the yaw that maps it onto the chassis + -- forward axis. This also covers 90-degree-rotated shells, and + -- self-corrects for any shell added later. If the cue is missing or + -- degenerate, rotate nothing (fail soft) rather than guess. + -- + -- HISTORY: a SHELL_YAW_OVERRIDE table used to pin hatch_junk and + -- hyper_x to yaw 0, on the theory that their wheel labels were + -- reversed. That was WRONG and has been removed. Measurement + -- (tools/diag_shellyaw.luau) showed shell-vs-body yaw was already + -- 0.0 degrees on those cars -- there was no shell rotation error at + -- all. The real defect was the chassis forward convention being + -- inverted against the VehicleSeat (see buildCorner, isFront), which + -- made every car drive out of its labelled rear. With that fixed, + -- an override forcing yaw 0 would REINTRODUCE a 180-degree flip. + -- Do not re-add per-car yaw constants without first running + -- diag_shellyaw.luau and showing a non-zero yawDelta. local shellYaw = 0 local wheelsFolder = shell:FindFirstChild("Wheels") if wheelsFolder then @@ -421,10 +461,22 @@ local function buildCarModel(car: Car, tuning: Tuning): Model local fSum, fN = Vector3.zero, 0 local rSum, rN = Vector3.zero, 0 for _, w in ipairs(wheelsFolder:GetChildren()) do - local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) - if part then + -- Use the group's CENTROID, not an arbitrary first BasePart: + -- FindFirstChildWhichIsA can return a rotor or caliper that + -- sits up to ~1 stud off the wheel centre (measured, see the + -- root-pick note in the wheel re-hang below). + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then local head = w.Name:sub(1, 1):upper() - local localPos = pivotInv * part.Position + local localPos = pivotInv * (sum / n) if head == "F" then fSum, fN = fSum + localPos, fN + 1 elseif head == "R" then @@ -437,6 +489,8 @@ local function buildCarModel(car: Car, tuning: Tuning): Model -- Horizontal component only; a shell sitting nose-down must not -- tilt the whole body. if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then + -- Map the shell's nose onto chassis forward, which is -Z + -- (see the forward-convention note in buildCorner). shellYaw = -math.atan2(nose.X, -nose.Z) end end @@ -572,19 +626,36 @@ local function buildCarModel(car: Car, tuning: Tuning): Model if wheelGroups then for _, w in ipairs(wheelGroups:GetChildren()) do local phys = model:FindFirstChild("Wheel_" .. w.Name:upper()) - local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + local group, inGroup = {}, {} + if w:IsA("BasePart") then + group[#group + 1] = w + inGroup[w] = true + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + group[#group + 1] = d + inGroup[d] = true + end + end + -- ROOT MUST BE THE TYRE, so pick the largest-volume part in the + -- group. This previously used FindFirstChildWhichIsA, i.e. an + -- ARBITRARY first BasePart -- on the 18-part wheel groups that + -- lands on a rotor/caliper offset from the wheel centre. The + -- group is then translated to make root concentric with the + -- axle (see delta below), so a bad root displaces the visible + -- tyre by exactly that offset, which reads as wheels sticking + -- out past the bodywork. Measured root-to-centre offsets on the + -- templates (tools/diag_wheelroot2.luau): rx7_fc 0.955, + -- silv_s15 0.988, gtr_r34 0.285-0.310, hatch_junk 0.000-0.035 + -- -- matching the report that the RX-7 was the obvious one. + local root, bestVol = nil, -1 + for _, p in ipairs(group) do + local vol = p.Size.X * p.Size.Y * p.Size.Z + if vol > bestVol then + root, bestVol = p, vol + end + end if phys and root then - local group, inGroup = {}, {} - if w:IsA("BasePart") then - group[#group + 1] = w - inGroup[w] = true - end - for _, d in ipairs(w:GetDescendants()) do - if d:IsA("BasePart") then - group[#group + 1] = d - inGroup[d] = true - end - end -- Drop only the welds that tie this group to the OUTSIDE -- (i.e. to the bodywork). Welds wholly inside the group are -- the wheel's own construction and must survive. @@ -968,4 +1039,9 @@ function CarManager.getRoot(player: Player): BasePart? return model.PrimaryPart end +-- Test seam: build a bare chassis with no player, seat or network ownership. +-- Used by tools/diag_steering.luau to measure handling headlessly. Not part of +-- the game path -- CarManager.spawn is the only way a player ever gets a car. +CarManager._buildForDiag = buildCarModel + return CarManager diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index d02d2e0..cadc923 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -335,11 +335,33 @@ local function planPA() open[((centreIdx + k - 1) % n) + 1] = true end end + -- The OUTER edge barrier needs its own, much narrower opening -- and it is + -- NOT the same thing as the median opening. + -- + -- BUG (2026-08-02): buildRoad reused `medianOpen` to skip the outer + -- barriers too, and did it inside `for side in {-1,1}`, so at all 13 + -- samples per junction the mainline lost its parapet, railing AND kill + -- wall on BOTH edges -- 26 samples of expressway with no walls at all, + -- including the far side where no ramp exists. That is the "some of the + -- wangan has no walls, jumbled near the parking area" report. + -- + -- `medianOpen` exists so both carriageways can reach the PA (the median is + -- the centre divider). The outer edge is a different structure with a drop + -- behind it, so it may only open on the ONE side the ramp leaves through, + -- and only right at the ramp mouth. + local RAMP_SPAN = 2 -- 5 samples, vs 13 for the median + local rampOpen = {} + for _, centreIdx in ipairs({ entryIdx, exitIdx }) do + for k = -RAMP_SPAN, RAMP_SPAN do + rampOpen[((centreIdx + k - 1) % n) + 1] = true + end + end PAPlan = { nearIdx = bestIdx, entryIdx = entryIdx, exitIdx = exitIdx, medianOpen = open, + rampOpen = rampOpen, ringRadius = Circuit.PA_RADIUS + 130, } return PAPlan @@ -500,9 +522,26 @@ local function buildRoad(map: Folder) -- backstop against tunnelling at high speed. -- At a PA junction the barrier AND the kill wall must open on the side -- the ramp leaves through, or the ramp would dead-end into a wall. - local junction = plan.medianOpen[i] + -- + -- FIXED (2026-08-02): this used `plan.medianOpen[i]` and `continue`d + -- for BOTH sides, stripping barrier + rail + kill wall from 26 samples + -- of mainline on both edges. Two corrections: + -- * use `rampOpen` (5 samples) not `medianOpen` (13) -- the median + -- opening is a different, wider thing for a different purpose. + -- * only skip the ONE side the PA is actually on. The far edge of + -- the expressway has a drop behind it and must never open. + local junction = plan.rampOpen[i] + local paSide = 0 + if junction then + -- Which side of the carriageway is the PA on? Positive dot with + -- `right` means the PA lies to the +1 side at this sample. + local toPA = Vector3.new(Circuit.PA_CENTER.X - mid.X, 0, Circuit.PA_CENTER.Z - mid.Z) + if toPA.Magnitude > 0.01 then + paSide = (toPA.Unit:Dot(right) >= 0) and 1 or -1 + end + end for _, side in ipairs({ -1, 1 }) do - if junction then + if junction and side == paSide then continue end local boff = right * (side * (HALF - BARRIER_T / 2)) @@ -1072,6 +1111,68 @@ local function buildPA(map: Folder) -- The ring itself local ringY = c.Y + + -- Ramp junctions, resolved BEFORE the ring is built so the ring wall can + -- open a gate at each one. + -- + -- BUG (2026-08-02): the parapet loop below ran `for sgn in {1,-1}` over + -- EVERY segment, walling both edges of the ring with no opening anywhere. + -- The inner lip therefore sealed the parking apron completely: 72 of 72 + -- rays cast outward from the SpawnPad hit a wall/barrier standing ~5.7 + -- studs above the apron (tools/diag_pawall.luau), well over the ~2-3 studs + -- a car can climb -- so there was no way to drive out of the PA at all. + -- The mainline barrier already handles this correctly at PA junctions + -- ("the barrier AND the kill wall must open on the side the ramp leaves + -- through", see the deck loop above); the ring never got the same + -- treatment. + -- + -- TWO gates are needed per junction, and both matter: + -- * INNER lip -- so the apron connects to the ring road at all. + -- * OUTER lip -- so the ring road connects to the ramp deck. + -- Opening only one still leaves the lot sealed. + -- + -- The two gates are deliberately different sizes. Inside the ring the + -- apron is solid deck, so a wide opening is free. OUTSIDE the ring is a + -- ~46-stud drop to ground level, so that gate is kept tight and every + -- stud of it is backed by ramp/flare deck (see PARampFlare below) -- + -- otherwise we would re-create the old "drove off the side and fell past + -- FallenPartsDestroyHeight" failure this structure was built to prevent. + local GATE_IN = 70 -- half-width of the apron-side opening + local GATE_OUT = 30 -- half-width of the ramp-side opening + local junctions = {} + for _, j in ipairs({ + { idx = plan.entryIdx, name = "PAOnRamp" }, + { idx = plan.exitIdx, name = "PAOffRamp" }, + }) do + local road = samples[j.idx].pos + local toC = Vector3.new(c.X - road.X, 0, c.Z - road.Z) + if toC.Magnitude >= 1 then + local ringPt = Vector3.new(c.X, ringY, c.Z) - toC.Unit * RING_R + if (ringPt - road).Magnitude >= 4 then + -- roadY: the mainline deck height AT this junction. The road is + -- lifted over the PA (Circuit.PA_LIFT), so the ramp has to climb + -- to whatever the sample actually sits at -- reading it here + -- keeps ramp and road in lockstep if the lift profile changes. + table.insert(junctions, { + name = j.name, + road = road, + ringPt = ringPt, + roadY = road.Y, + }) + end + end + end + + local function nearJunction(pos, halfWidth) + for _, jn in ipairs(junctions) do + local d = Vector3.new(pos.X - jn.ringPt.X, 0, pos.Z - jn.ringPt.Z) + if d.Magnitude <= halfWidth then + return true + end + end + return false + end + for i = 0, ARC_N - 1 do local a0 = (i / ARC_N) * math.pi * 2 local a1 = ((i + 1) / ARC_N) * math.pi * 2 @@ -1088,56 +1189,125 @@ local function buildPA(map: Folder) local outN = (segMid - Vector3.new(c.X, ringY, c.Z)).Unit for _, sgn in ipairs({ 1, -1 }) do local ePos = segMid + outN * (sgn * RING_W / 2) - local eCF = CFrame.lookAt(ePos, ePos + fwd, UP) - local lip = part({ - Name = "PARingWall", - Size = Vector3.new(2.2, BARRIER_H, segLen + 1), - CFrame = eCF * CFrame.new(0, 1 + BARRIER_H / 2, 0), - Color = C_PARAPET, - Material = Enum.Material.Concrete, - }) - lip.Parent = pa - local lr = part({ - Name = "PARingRail", - Size = Vector3.new(0.6, RAIL_H, segLen + 1), - CFrame = eCF * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0), - Color = C_RAIL, - Material = Enum.Material.Metal, - }) - lr.Parent = pa + -- sgn == 1 is the OUTER edge (away from the apron), sgn == -1 the + -- INNER edge facing the parking lot. Skip the parapet entirely + -- where a ramp tees in, or the ring seals the PA (see above). + local gate = (sgn == 1) and GATE_OUT or GATE_IN + if not nearJunction(ePos, gate) then + local eCF = CFrame.lookAt(ePos, ePos + fwd, UP) + local lip = part({ + Name = "PARingWall", + Size = Vector3.new(2.2, BARRIER_H, segLen + 1), + CFrame = eCF * CFrame.new(0, 1 + BARRIER_H / 2, 0), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + lip.Parent = pa + local lr = part({ + Name = "PARingRail", + Size = Vector3.new(0.6, RAIL_H, segLen + 1), + CFrame = eCF * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0), + Color = C_RAIL, + Material = Enum.Material.Metal, + }) + lr.Parent = pa + end end - -- Pillars holding the loop up over the parking lot, like the aerial. - if i % 6 == 0 and ringY > 12 then - local pil = part({ - Name = "PARingPillar", - Size = Vector3.new(7, ringY, 7), - CFrame = CFrame.new(segMid.X, ringY / 2 - 1, segMid.Z), - Color = C_CONCRETE, - Material = Enum.Material.Concrete, - }) - pil.Parent = pa - end + -- No pillars under the ring any more. The ring is now a GROUND-LEVEL + -- circulation road sharing the apron slab (Circuit.PA_LIFT raises the + -- MAINLINE over the PA instead of leaving both at y=46). It is the + -- expressway that flies overhead on pillars here, exactly as in the + -- reference photos: a flat lot with parked cars, elevated deck above. + -- Mainline pillars are built in buildRoad; see PA_LIFT. end -- Two ramps: mainline -> ring (entry) and ring -> mainline (exit). - for _, j in ipairs({ - { idx = plan.entryIdx, name = "PAOnRamp" }, - { idx = plan.exitIdx, name = "PAOffRamp" }, - }) do - local road = samples[j.idx].pos - local toC = Vector3.new(c.X - road.X, 0, c.Z - road.Z) - if toC.Magnitude < 1 then - continue + -- `junctions` was resolved above (the ring wall needed it to place gates); + -- reuse it so the ramp deck and the wall opening can never disagree. + -- + -- These are now GRADED: the ring sits on the apron at ringY while the + -- mainline flies Circuit.PA_LIFT studs overhead, so each ramp is a sloped + -- deck built as a stack of short segments that climb from the ring up to + -- the road. A single flat slab would leave a PA_LIFT-stud cliff at the + -- top -- undrivable in either direction. + local LIFT = Circuit.PA_LIFT or 0 + for _, jn in ipairs(junctions) do + local span = (jn.ringPt - jn.road).Magnitude + -- The road end must meet the ACTUAL mainline deck height at that + -- junction (jn.roadY, captured from the sample), not a guess. + local yLo, yHi = ringY, jn.roadY or (ringY + LIFT) + local STEPS = math.max(8, math.floor(span / 40)) + -- Ease the grade at both ends so a car doesn't nose into the + -- transition or launch off the crest. + local function ease(t) + return t * t * (3 - 2 * t) end - -- Meet the ring at its nearest point to this junction, not the centre, - -- so the ramp tees into the loop instead of crossing over it. - local ringPt = Vector3.new(c.X, ringY, c.Z) - toC.Unit * RING_R - local span = (ringPt - road).Magnitude - if span < 4 then - continue + for s = 0, STEPS - 1 do + local t0, t1 = s / STEPS, (s + 1) / STEPS + local p0 = jn.ringPt:Lerp(jn.road, t0) + local p1 = jn.ringPt:Lerp(jn.road, t1) + local y0 = yLo + (yHi - yLo) * ease(t0) + local y1 = yLo + (yHi - yLo) * ease(t1) + local a = Vector3.new(p0.X, y0, p0.Z) + local b = Vector3.new(p1.X, y1, p1.Z) + local segMid = (a + b) / 2 + -- Overlap consecutive segments slightly: a sloped deck is a + -- rotated box, so butt-jointed segments leave a thin wedge gap at + -- each seam that a wheel can catch on. + local segLen = (b - a).Magnitude + 3 + local d = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP)) + d.Name = jn.name .. "_" .. s + -- Support pillar down to the apron under the raised part of the + -- ramp, matching the reference photos' elevated structure. + local h = segMid.Y - (ringY - 1) + if h > 10 and s % 2 == 0 then + local pil = part({ + Name = "PARampPillar", + Size = Vector3.new(6, h, 6), + CFrame = CFrame.new(segMid.X, segMid.Y - h / 2, segMid.Z), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + }) + pil.Parent = pa + end + -- Edge kerbs so a car can't slide off the side of a raised ramp. + if h > 8 then + local fwd2 = (b - a) + fwd2 = Vector3.new(fwd2.X, 0, fwd2.Z) + if fwd2.Magnitude > 0.01 then + local rightV = fwd2.Unit:Cross(UP) + for _, sg in ipairs({ -1, 1 }) do + local kerb = part({ + Name = "PARampKerb", + Size = Vector3.new(1.6, 3.2, segLen), + CFrame = CFrame.lookAt( + segMid + rightV * (sg * RING_W / 2), + segMid + rightV * (sg * RING_W / 2) + (b - a).Unit, + UP + ) * CFrame.new(0, 2.6, 0), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + kerb.Parent = pa + end + end + end + end + + -- Flare the ramp mouth across the outer gate so the opening in the + -- ring parapet is fully backed by deck -- the ramp meets the ring at a + -- tangent and would otherwise leave triangular gaps at the corners. + local toRing = Vector3.new(jn.ringPt.X - jn.road.X, 0, jn.ringPt.Z - jn.road.Z) + if toRing.Magnitude > 1 then + local flareCF = CFrame.lookAt( + Vector3.new(jn.ringPt.X, ringY, jn.ringPt.Z), + Vector3.new(jn.road.X, ringY, jn.road.Z), + UP + ) + local flare = deck("PARampFlare", Vector3.new(GATE_OUT * 2 + RING_W, 2, RING_W + 24), flareCF) + flare.Color = Color3.fromRGB(38, 38, 44) end - deck(j.name, Vector3.new(RING_W, 2, span), CFrame.lookAt((ringPt + road) / 2, road, UP)) end -- The PA apron used to be a bare 520x520 slab floating ~46 studs up with diff --git a/src/shared/Circuit.luau b/src/shared/Circuit.luau index 988820e..f518acf 100644 --- a/src/shared/Circuit.luau +++ b/src/shared/Circuit.luau @@ -873,6 +873,67 @@ for _, r in ipairs(RAW) do bridge = (r[5] :: number) == 1, }) end +-- DAIKOKU PA UNDERPASS ELEVATION ---------------------------------------------- +-- Reference (Commons "Aerial view of Daikoku Junction", CC0, plus the +-- ground-level PA shots): the parking apron is at GROUND level and the +-- expressway flies OVER it on pillars. Cars park underneath the decks; the +-- PA's circulation road passes beneath the mainline. The elevated roadway +-- above a flat lot is the iconic look, and it is also what makes the PA +-- drivable. +-- +-- BUG this fixes (2026-08-02): the mainline and the PA were BOTH baked at +-- y=46, and the PA ring road (radius 390) reaches 60 studs past the mainline +-- centreline (PA centre is only 330 studs off it). Same height + overlapping +-- footprint meant the ring's parapet stood in the middle of the carriageway -- +-- anyone driving the wangan past the PA was destroyed by a wall in the road. +-- Vertical separation is what resolves it; the footprints are SUPPOSED to +-- overlap, exactly as they do in the aerial. +-- +-- Applied here as a smooth profile rather than by hand-editing 1050 baked +-- heights. Everything downstream (deck, median, barrier, kill wall, lane +-- paint, ramps) is built from sample.pos, so lifting the samples lifts the +-- whole structure coherently. +Circuit.PA_LIFT = 38 -- studs of clearance over the apron (a car is ~7 tall) +local LIFT_FLAT = 20 -- samples held at full lift, centred on the PA +local LIFT_RAMP = 18 -- samples of smooth transition on each side +do + local c = Circuit.PA_CENTER + local n = #samples + -- Same "nearest non-tunnel sample" rule planPA uses, so the lift and the + -- ramp junctions can never disagree about where the PA is. + local bestIdx, bestD = 1, math.huge + for i, s in ipairs(samples) do + local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude + if not s.tunnel and d < bestD then + bestIdx, bestD = i, d + end + end + Circuit.PA_NEAREST = bestIdx + + local function circDist(i, j) + local d = math.abs(i - j) + return math.min(d, n - d) + end + + for i, s in ipairs(samples) do + local d = circDist(i, bestIdx) + local t + if d <= LIFT_FLAT then + t = 1 + elseif d >= LIFT_FLAT + LIFT_RAMP then + t = 0 + else + -- Smoothstep: zero slope at both ends, so the grade eases in and + -- out instead of kinking the deck at the seam. + local u = (d - LIFT_FLAT) / LIFT_RAMP + t = 1 - (u * u * (3 - 2 * u)) + end + if t > 0 then + s.pos = Vector3.new(s.pos.X, s.pos.Y + Circuit.PA_LIFT * t, s.pos.Z) + end + end +end + Circuit.samples = samples -- Cumulative arc length at each sample, and total lap length ------------------ diff --git a/tools/check_live_source.luau b/tools/check_live_source.luau new file mode 100644 index 0000000..7dc3e73 --- /dev/null +++ b/tools/check_live_source.luau @@ -0,0 +1,48 @@ +--[[ + check_live_source.luau -- did the pushed CarManager source actually land, + and is the running session using it? + + diag_steering still reports "found 4 motors" after a push that reported + assembled==expected match=true. Two candidate explanations: + 1. the source in the DataModel is stale (push landed elsewhere), or + 2. the source is fresh but require() returned a CACHED module instance + from earlier in this Play session, so the old build function is live. +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +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 server = ServerScriptService:FindFirstChild("Server", true) +local mod = server and server:FindFirstChild("CarManager") +log("module:", mod and mod:GetFullName() or "") + +if mod then + local src = mod.Source + log("source bytes:", #src) + log("has REAR-WHEEL DRIVE marker:", tostring(src:find("REAR%-WHEEL DRIVE") ~= nil)) + log("has 'if isFront then\\n\\t\\treturn' guard:", tostring(src:find("isFront then\n\t\treturn") ~= nil)) +end + +-- How many CarManager-like ModuleScripts exist anywhere? A duplicate copy +-- would explain a push landing in the wrong place. +local hits = {} +for _, d in ipairs(game:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + table.insert(hits, d:GetFullName() .. " (" .. #d.Source .. " bytes)") + end +end +log("") +log("all CarManager ModuleScripts in this DataModel:") +for _, h in ipairs(hits) do + log(" " .. h) +end + +return table.concat(out, "\n") diff --git a/tools/daikoku_check.png b/tools/daikoku_check.png new file mode 100644 index 0000000..d15f56a Binary files /dev/null and b/tools/daikoku_check.png differ diff --git a/tools/diag_bootcheck.luau b/tools/diag_bootcheck.luau new file mode 100644 index 0000000..9e6c9a9 --- /dev/null +++ b/tools/diag_bootcheck.luau @@ -0,0 +1,109 @@ +--[[ + diag_bootcheck.luau -- decide facing using the BOOT/SPOILER cue, which is + directly interpretable, and verify it survives the build. + + Findings so far: + * gtr_r34 template: wheels -10.27, hood -6.22, boot +6.64 -> all three + cues AGREE, nose at -Z. So wheel labels are NOT swapped. + * bodywork "asym" gave gtr_r34 -0.37 while rx7_fc/silv_s15 were + positive, yet all three have boot at +Z. asym is therefore a poor + proxy (it is dominated by whichever big slab a given shell happens to + have) and its verdicts must be discarded. + + The boot/spoiler is unambiguous: it belongs at the TAIL. On a correctly + faced car (chassis forward = -Z) the boot must land at POSITIVE body-local + Z. Measure that on the BUILT car, per shell, and compare to the template. + Shells with no named boot get a fallback: the tail is the end furthest + from the front wheels, per that shell's own wheel cue. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local cm = orig:Clone() +cm.Parent = orig.Parent +local CarManager = require(cm) + +local TAIL = { boot = true, spoiler = true, trunk = true, taillight = true, exhaust = true, diffuser = true } +local NOSE = { hood = true, windshield = true, bonnet = true, grille = true, headlight = true, splitter = true } + +local function cueZ(root: Instance, frame: CFrame) + local inv = frame:Inverse() + local tS, tN, nS, nN = 0, 0, 0, 0 + for _, p in ipairs(root:GetDescendants()) do + if p:IsA("BasePart") then + local nm = p.Name:lower() + local z = (inv * p.Position).Z + if TAIL[nm] then + tS, tN = tS + z, tN + 1 + elseif NOSE[nm] then + nS, nN = nS + z, nN + 1 + end + end + end + return (tN > 0 and tS / tN or nil), (nN > 0 and nS / nN or nil), tN, nN +end + +log("chassis forward = -Z, so TAIL parts must land at POSITIVE body-local Z.") +log("") +log(string.format("%-14s %22s %30s", "car", "TEMPLATE (tail/nose)", "BUILT (tail/nose) verdict")) +log(string.rep("-", 74)) + +for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local id = t.Name + local tT, tNz, tCount, nCount = cueZ(t, t:GetPivot()) + + local bT, bN = nil, nil + local car = Cars.Get(id) + if car then + local m = CarManager._buildForDiag(car, Chassis.tuningFor(car)) + m.Parent = workspace + m:PivotTo(CFrame.new(0, 5010, 0)) + for _ = 1, 3 do + RunService.Heartbeat:Wait() + end + local sh = m:FindFirstChild("Shell") + if sh then + bT, bN = cueZ(sh, m.PrimaryPart.CFrame) + end + m:Destroy() + end + + local verdict + if bT then + verdict = bT > 0 and "CORRECT" or "*** BACKWARDS ***" + elseif bN then + verdict = bN < 0 and "CORRECT" or "*** BACKWARDS ***" + else + verdict = "no named cue - cannot judge" + end + + log( + string.format( + "%-14s tail %8s nose %7s tail %8s nose %7s %s", + id, + tT and string.format("%+.2f", tT) or "n/a", + tNz and string.format("%+.2f", tNz) or "n/a", + bT and string.format("%+.2f", bT) or "n/a", + bN and string.format("%+.2f", bN) or "n/a", + verdict + ) + ) +end + +cm:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_builtnose.luau b/tools/diag_builtnose.luau new file mode 100644 index 0000000..331da45 --- /dev/null +++ b/tools/diag_builtnose.luau @@ -0,0 +1,167 @@ +--[[ + diag_builtnose.luau -- END-TO-END measurement. Build each car for real via + CarManager._buildForDiag, then measure where the finished shell's nose + actually points relative to the chassis forward axis. + + Why this and not more code reading: the solver computes the right 180-degree + yaw (diag_solveryaw) and applies it without centring error (diag_centrefix), + yet two cars still look backwards. So something AFTER the PivotTo must be + undoing it. Rather than guess which line, measure the finished article. + + noseDot = (shell nose direction) dot (body LookVector) + +1 -> shell nose points where the car drives (CORRECT) + -1 -> shell nose points backwards (REVERSED) + + Cache guard first: require() caches per Studio session, so a stale + CarManager would silently build old code and make this whole test a lie. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") + +local out = {} +local function log(s) + table.insert(out, s) +end + +local function find(name) + for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do + local m = root:FindFirstChild(name, true) + if m and m:IsA("ModuleScript") then + return m + end + end + return nil +end + +local cmMod = find("CarManager") +if not cmMod then + return "FATAL: CarManager not found" +end +-- Guard: the reverted source must be what require() hands back. +local srcOK = cmMod.Source:find("isFront = zSign < 0", 1, true) ~= nil +if not srcOK then + return "FATAL: CarManager source is not the reverted version -- aborting" +end +local ok, CM = pcall(require, cmMod) +if not ok then + return "FATAL: require(CarManager) failed: " .. tostring(CM) +end +if type(CM._buildForDiag) ~= "function" then + return "FATAL: CarManager._buildForDiag missing (stale require cache?) -- restart Studio" +end + +local Cars = require(RS:WaitForChild("Shared"):WaitForChild("Cars")) +local ChassisMod = require(RS:WaitForChild("Shared"):WaitForChild("Chassis")) + +local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" } + +log(string.format("%-11s %-10s %-12s %s", "shell", "noseDot", "yaw err", "verdict")) +log(string.rep("-", 62)) + +local built = 0 +for _, name in ipairs(TARGETS) do + -- buildCarModel(car: Car, tuning: Tuning) -- NOT a string id. Passing a + -- string was the harness bug that produced the CarManager:277 nil error. + local carDef = Cars.Get(name) + if not carDef then + log(string.format("%-11s NOT IN Cars.List", name)) + continue + end + local tuning = ChassisMod.tuningFor(carDef) + local okB, car = pcall(CM._buildForDiag, carDef, tuning) + if not okB or not car then + log(string.format("%-11s BUILD FAILED: %s", name, tostring(car))) + continue + end + built += 1 + + local body = car:FindFirstChild("Body") or car.PrimaryPart + local shell = nil + for _, d in ipairs(car:GetChildren()) do + if d:IsA("Model") and d:FindFirstChild("Wheels") then + shell = d + break + end + end + + if not body or not shell then + log(string.format("%-11s no body/shell on built car", name)) + car:Destroy() + continue + end + + -- Shell nose in WORLD space, from the shell's own wheel labels. + local wf = shell:FindFirstChild("Wheels") + local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0 + for _, w in ipairs(wf:GetChildren()) do + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then + local h = w.Name:sub(1, 1):upper() + if h == "F" then + fSum, fN = fSum + (sum / n), fN + 1 + elseif h == "R" then + rSum, rN = rSum + (sum / n), rN + 1 + end + end + end + + if fN == 0 or rN == 0 then + log(string.format("%-11s no F/R labels on built shell", name)) + car:Destroy() + continue + end + + local nose = (fSum / fN) - (rSum / rN) + nose = Vector3.new(nose.X, 0, nose.Z) + if nose.Magnitude < 0.01 then + log(string.format("%-11s degenerate nose vector", name)) + car:Destroy() + continue + end + nose = nose.Unit + + local fwd = body.CFrame.LookVector + fwd = Vector3.new(fwd.X, 0, fwd.Z).Unit + + local dot = nose:Dot(fwd) + local yawErr = math.deg(math.acos(math.clamp(dot, -1, 1))) + + local verdict + if dot > 0.85 then + verdict = "CORRECT (nose = drive dir)" + elseif dot < -0.85 then + verdict = "*** REVERSED ***" + else + verdict = "skewed" + end + + log( + string.format( + "%-11s %-10s %-12s %s", + name, + string.format("%+.3f", dot), + string.format("%.1f deg", yawErr), + verdict + ) + ) + car:Destroy() +end + +log("") +if built == 0 then + log(">>> NOTHING BUILT -- this is a FAILURE, not a pass.") +else + log(string.format(">>> built and measured %d/5 cars, then destroyed them (no scene changes)", built)) + log(" This is the finished article, after every stage of the build pipeline.") +end + +return table.concat(out, "\n") diff --git a/tools/diag_centrefix.luau b/tools/diag_centrefix.luau new file mode 100644 index 0000000..41367b9 --- /dev/null +++ b/tools/diag_centrefix.luau @@ -0,0 +1,136 @@ +--[[ + diag_centrefix.luau -- test whether centreFix is applied in the wrong order. + + The build does: + shell:PivotTo(body.CFrame * heightCF * CFrame.Angles(0,yaw,0) * centreFix) + + centreFix is a translation derived from the shell's CURRENT (unrotated) + AABB centre. Placing it to the RIGHT of CFrame.Angles means it is expressed + in the ROTATED frame, so a 180-degree yaw negates its X and Z. For the three + shells with yaw ~= 0 this is harmless. For hatch_junk and hyper_x (yaw 180) + it moves the shell by -2 * centreLocal instead of cancelling the offset. + + This measures the resulting AABB-centre error for BOTH orderings, per shell. + The numbers can come out either way -- if the current ordering is fine, both + errors are ~0 and the hypothesis is dead. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") + +local out = {} +local function log(s) + table.insert(out, s) +end + +local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" } +local shells = {} +for _, root in ipairs({ RS, SSS, game:GetService("ServerStorage"), workspace }) do + for _, d in ipairs(root:GetDescendants()) do + if d:IsA("Model") and d:FindFirstChild("Wheels") and not shells[d.Name] then + for _, t in ipairs(TARGETS) do + if d.Name == t then + shells[d.Name] = d + end + end + end + end +end + +-- Replicate trueAABB: min/max corner over all BaseParts, world space. +local function trueAABB(model) + local lo, hi + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") then + local p = d.Position + lo = lo and Vector3.new(math.min(lo.X, p.X), math.min(lo.Y, p.Y), math.min(lo.Z, p.Z)) or p + hi = hi and Vector3.new(math.max(hi.X, p.X), math.max(hi.Y, p.Y), math.max(hi.Z, p.Z)) or p + end + end + return lo, hi +end + +log(string.format("%-11s %-10s %-22s %-14s %s", "shell", "yaw", "centreLocal(x,z)", "CURRENT err", "FIXED err")) +log(string.rep("-", 82)) + +for _, name in ipairs(TARGETS) do + local shell = shells[name] + if not shell then + log(string.format("%-11s NOT FOUND", name)) + continue + end + + -- yaw the solver would compute + local pivotInv = shell:GetPivot():Inverse() + local wf = shell:FindFirstChild("Wheels") + local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0 + for _, w in ipairs(wf:GetChildren()) do + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then + local lp = pivotInv * (sum / n) + local h = w.Name:sub(1, 1):upper() + if h == "F" then + fSum, fN = fSum + lp, fN + 1 + elseif h == "R" then + rSum, rN = rSum + lp, rN + 1 + end + end + end + local yaw = 0 + if fN > 0 and rN > 0 then + local nose = (fSum / fN) - (rSum / rN) + if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then + yaw = -math.atan2(nose.X, -nose.Z) + end + end + + local lo2, hi2 = trueAABB(shell) + local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2) + local centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z) + + -- Simulate both orderings against an identity body at the origin. + local body = CFrame.new() + local rot = CFrame.Angles(0, yaw, 0) + + -- Where does the shell's AABB centre land, relative to the body axis? + -- Model the centre as a point at centreLocal in the shell's pivot frame. + local centrePoint = CFrame.new(centreLocal.X, 0, centreLocal.Z) + + local currentCF = body * rot * centreFix + local fixedCF = body * centreFix * rot -- translation applied BEFORE rotation + + local curCentre = (currentCF * centrePoint).Position + local fixCentre = (fixedCF * centrePoint).Position + + local curErr = math.sqrt(curCentre.X ^ 2 + curCentre.Z ^ 2) + local fixErr = math.sqrt(fixCentre.X ^ 2 + fixCentre.Z ^ 2) + + log( + string.format( + "%-11s %-10s (%+.2f,%+.2f)%-8s %-14s %s", + name, + string.format("%+.0f deg", math.deg(yaw)), + centreLocal.X, + centreLocal.Z, + "", + string.format("%.3f", curErr), + string.format("%.3f", fixErr) + ) + ) +end + +log("") +log("err = how far the shell's AABB centre sits from the chassis axis (studs).") +log("A large CURRENT err on the 180-degree shells with a near-zero FIXED err") +log("confirms the ordering bug. If both columns match, the hypothesis is WRONG") +log("and the offset is not the cause of the reversal.") + +return table.concat(out, "\n") diff --git a/tools/diag_contact.luau b/tools/diag_contact.luau new file mode 100644 index 0000000..eccf65f --- /dev/null +++ b/tools/diag_contact.luau @@ -0,0 +1,161 @@ +--[[ + diag_contact.luau -- the front wheels now steer correctly (0.01 deg error) + but the car still will not change heading (1.4 deg over 5 s, understeer + 3465x). Steering geometry is therefore no longer the problem: the tyres + are producing no lateral force. + + Most likely cause: the hub is welded RIGIDLY to the body with no + suspension travel, and the Body part is CanCollide=true. If the body + is resting on the pad, the car slides on a big low-friction box and the + wheels are carrying little or no load. + + This measures actual ground contact and per-wheel load. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local clone = orig:Clone() +clone.Parent = orig.Parent +local CarManager = require(clone) + +local pad = Instance.new("Part") +pad.Size = Vector3.new(6000, 8, 6000) +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(string.format("pad top surface Y = %.2f", padTop)) + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) +local model = CarManager._buildForDiag(car, tuning) +model.Parent = workspace +model:PivotTo(CFrame.new(0, padTop + 3, 0)) + +local body = model.PrimaryPart +local wheels, servos, motors = {}, {}, {} +for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") and d.Name:sub(1, 6) == "Wheel_" then + table.insert(wheels, d) + elseif d:IsA("HingeConstraint") then + if d.Name == Chassis.STEER_SERVO_NAME then + table.insert(servos, d) + elseif d.Name == Chassis.WHEEL_MOTOR_NAME then + table.insert(motors, d) + end + end +end +table.sort(wheels, function(a, b) + return a.Name < b.Name +end) + +for _ = 1, 120 do + RunService.Heartbeat:Wait() +end + +log("") +log("--- AT REST ---") +log( + string.format( + "body: centre Y %.2f, size %s, CanCollide %s", + body.Position.Y, + tostring(body.Size), + tostring(body.CanCollide) + ) +) +log( + string.format( + "body underside Y = %.2f (pad top %.2f) -> gap %.2f %s", + body.Position.Y - body.Size.Y / 2, + padTop, + (body.Position.Y - body.Size.Y / 2) - padTop, + ((body.Position.Y - body.Size.Y / 2) - padTop) < 0.05 and "*** BODY ON THE GROUND ***" or "(clear)" + ) +) +log(string.format("WHEEL_RADIUS %.2f", Chassis.WHEEL_RADIUS)) + +for _, w in ipairs(wheels) do + local bottom = w.Position.Y - Chassis.WHEEL_RADIUS + log( + string.format( + " %-10s centre Y %7.2f bottom %7.2f gap to pad %6.2f %s", + w.Name, + w.Position.Y, + bottom, + bottom - padTop, + math.abs(bottom - padTop) < 0.1 and "CONTACT" or ((bottom - padTop) > 0.1 and "FLOATING" or "SUNK") + ) + ) +end + +-- Who is actually touching the pad? +log("") +log("--- PARTS TOUCHING THE PAD ---") +local touching = pad:GetTouchingParts() +local names = {} +for _, p in ipairs(touching) do + if p:IsDescendantOf(model) then + names[#names + 1] = p.Name + end +end +log(#names > 0 and (" " .. table.concat(names, ", ")) or " ") + +-- Rolling, steered: is the body dragging? +log("") +log("--- ROLLING @ ~200, STEERED -14 ---") +local topW = tuning.MaxWheelAngularVelocity +for step = 1, 240 do + for _, mo in ipairs(motors) do + mo.MotorMaxTorque = tuning.MotorMaxTorque + mo.AngularVelocity = -topW + end + for _, s in ipairs(servos) do + s.TargetAngle = -14 + end + if step % 60 == 0 then + local contactNames = {} + for _, p in ipairs(pad:GetTouchingParts()) do + if p:IsDescendantOf(model) then + contactNames[#contactNames + 1] = p.Name + end + end + local slip = body.AssemblyLinearVelocity + local fwd = body.CFrame.LookVector + local right = body.CFrame.RightVector + log( + string.format( + "t %.1f speed %6.1f fwd %6.1f lateral %6.1f yaw %6.3f bodyGap %5.2f touching: %s", + step / 60, + slip.Magnitude, + slip:Dot(fwd), + slip:Dot(right), + body.AssemblyAngularVelocity.Y, + (body.Position.Y - body.Size.Y / 2) - padTop, + #contactNames > 0 and table.concat(contactNames, ",") or "none" + ) + ) + end + RunService.Heartbeat:Wait() +end + +model:Destroy() +pad:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_daikoku.luau b/tools/diag_daikoku.luau new file mode 100644 index 0000000..c8fe739 --- /dev/null +++ b/tools/diag_daikoku.luau @@ -0,0 +1,159 @@ +--!strict +-- Verify the rebuilt Daikoku PA junction IN-ENGINE, after MapBuilder has run. +-- +-- MEASURE FIRST (project rule): ray tests and code reasoning both mislead; +-- actual part positions are ground truth. This reads the built parts back out +-- of Workspace rather than trusting the source that generated them. +-- +-- Checks: +-- 1. No PA ring/parapet part intrudes into the mainline carriageway. +-- 2. Mainline deck clears the ring/apron by a usable height. +-- 3. Both ramps form a continuous, climbable chain (no cliff, no gap). +-- 4. The old PARingPillar parts are gone. +local ws = workspace + +local function findFirst(name: string): Instance? + return ws:FindFirstChild(name, true) +end + +local out = {} +local function say(s: string) + table.insert(out, s) +end + +-- Locate the PA + road containers ------------------------------------------- +local pa = findFirst("ParkingArea") or findFirst("PA") or findFirst("DaikokuPA") +local road = findFirst("Road") +local struct = findFirst("Structure") or findFirst("Struct") + +say("== CONTAINERS ==") +say(("pa=%s road=%s struct=%s"):format( + pa and pa:GetFullName() or "NIL", + road and road:GetFullName() or "NIL", + struct and struct:GetFullName() or "NIL" +)) + +-- Gather parts by name pattern anywhere under Workspace ---------------------- +local buckets: { [string]: { BasePart } } = {} +local function bucket(key: string, p: BasePart) + buckets[key] = buckets[key] or {} + table.insert(buckets[key], p) +end + +for _, d in ipairs(ws:GetDescendants()) do + if d:IsA("BasePart") then + local n = d.Name + if n:match("^PARingWall") or n:match("^PARingRail") then + bucket("ringwall", d) + elseif n:match("^PARingPillar") then + bucket("ringpillar", d) + elseif n:match("^PARamp") and n:match("Pillar") then + bucket("ramppillar", d) + elseif n:match("^PARampKerb") then + bucket("rampkerb", d) + elseif n:match("^PAOnRamp") then + bucket("entryramp", d) + elseif n:match("^PAOffRamp") then + bucket("exitramp", d) + elseif n:match("^Road_") then + bucket("roaddeck", d) + end + end +end + +say("") +say("== PART COUNTS ==") +for _, k in ipairs({ "roaddeck", "ringwall", "ringpillar", "entryramp", "exitramp", "ramppillar", "rampkerb" }) do + say(("%-12s %d"):format(k, buckets[k] and #buckets[k] or 0)) +end + +-- 4. Old ring pillars must be gone ------------------------------------------ +say("") +say("== CHECK: legacy ring pillars removed ==") +local rp = buckets["ringpillar"] +if rp and #rp > 0 then + say(("FAIL: %d PARingPillar parts still present (ring should be ground-level)"):format(#rp)) +else + say("PASS: no PARingPillar parts (ring is ground-level)") +end + +-- 1 + 2. Ring structures vs mainline carriageway ----------------------------- +say("") +say("== CHECK: ring walls do not intrude into the carriageway ==") +local decks = buckets["roaddeck"] or {} +local walls = buckets["ringwall"] or {} +local worstGap, worstDesc = math.huge, "n/a" +local intrusions = 0 +for _, w in ipairs(walls) do + local wTop = w.Position.Y + w.Size.Y / 2 + for _, dk in ipairs(decks) do + local dxz = (Vector3.new(dk.Position.X, 0, dk.Position.Z) - Vector3.new(w.Position.X, 0, w.Position.Z)).Magnitude + -- Only decks whose footprint actually overlaps this wall matter. + if dxz < (dk.Size.X / 2 + w.Size.X / 2 + 30) then + local deckBottom = dk.Position.Y - dk.Size.Y / 2 + local gap = deckBottom - wTop + if gap < worstGap then + worstGap = gap + worstDesc = ("%s vs %s dxz=%.1f"):format(dk.Name, w.Name, dxz) + end + if gap < 0 then + intrusions += 1 + end + end + end +end +if worstGap == math.huge then + say("PASS: no ring wall shares a footprint with any mainline deck") +else + say(("closest ring-wall/deck pair: %s"):format(worstDesc)) + say(("vertical gap = %.1f studs"):format(worstGap)) + if intrusions > 0 then + say(("FAIL: %d wall/deck pairs overlap vertically -- wall in the road"):format(intrusions)) + else + say("PASS: every overlapping ring wall passes UNDER the deck") + end +end + +-- 3. Ramp continuity --------------------------------------------------------- +local function checkRamp(key: string, label: string) + say("") + say(("== CHECK: %s continuity =="):format(label)) + local segs = buckets[key] + if not segs or #segs == 0 then + say("FAIL: no segments found") + return + end + table.sort(segs, function(a, b) + local ai = tonumber(a.Name:match("_(%d+)$")) or 0 + local bi = tonumber(b.Name:match("_(%d+)$")) or 0 + return ai < bi + end) + local loY = segs[1].Position.Y + local hiY = segs[#segs].Position.Y + say(("%d segments, Y from %.1f to %.1f (rise %.1f)"):format(#segs, loY, hiY, hiY - loY)) + local maxStep, maxGap = 0, 0 + for i = 2, #segs do + local a, b = segs[i - 1], segs[i] + local step = math.abs(b.Position.Y - a.Position.Y) + local horiz = (Vector3.new(a.Position.X, 0, a.Position.Z) - Vector3.new(b.Position.X, 0, b.Position.Z)).Magnitude + -- gap = horizontal distance minus the two half-lengths that face + -- each other; negative means the segments overlap (good). + local gap = horiz - (a.Size.Z / 2 + b.Size.Z / 2) + maxStep = math.max(maxStep, step) + maxGap = math.max(maxGap, gap) + end + say(("max vertical step between segments = %.2f studs"):format(maxStep)) + say(("max horizontal gap between segments = %.2f studs (negative = overlap)"):format(maxGap)) + if maxStep > 6 then + say("FAIL: a step that large is a cliff a car cannot climb") + elseif maxGap > 0.5 then + say("FAIL: segments do not meet -- wheels will drop into the seam") + else + say("PASS: ramp is continuous and climbable") + end +end + +checkRamp("entryramp", "entry ramp") +checkRamp("exitramp", "exit ramp") + +return table.concat(out, "\n") diff --git a/tools/diag_editstate.luau b/tools/diag_editstate.luau new file mode 100644 index 0000000..212e11c --- /dev/null +++ b/tools/diag_editstate.luau @@ -0,0 +1,72 @@ +--[[ + diag_editstate.luau -- read CarManager in the EDIT datamodel. + + This is the datamodel that PERSISTS (unlike Play, which is a sandbox that + is discarded on stop -- the thing that silently ate every previous push). + + Purpose: capture a BEFORE snapshot so that after Rojo connects we can prove + the sync actually replaced the source, rather than assuming it did. Records + length and the two markers unique to the current fixes: + + SHELL_YAW_OVERRIDE -> facing fix for hatch_junk / hyper_x (NEW, x3 in src) + bestVol -> pre-existing var, NOT a freshness signal (x6 in src) + + Note bestVol existed in the old code too (git HEAD line 479), so it proves + nothing on its own -- SHELL_YAW_OVERRIDE is the real discriminator. + Disk source is 41650 bytes; anything at 30631 is the old pre-fix version. +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +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 + +log("=== EDIT datamodel: CarManager state ===") + +local found = {} +for _, d in ipairs(ServerScriptService:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + found[#found + 1] = d + end +end + +log("instances found: " .. #found) +if #found == 0 then + log(" !! no CarManager in ServerScriptService at all") + log(" Rojo sync will CREATE it under ServerScriptService.Server") +end + +for _, m in ipairs(found) do + local src = "" + pcall(function() + src = m.Source + end) + local hasOverride = src:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil + log(string.format(" %s", m:GetFullName())) + log(string.format(" len=%d (disk source = 41650, old = 30631)", #src)) + log(string.format(" SHELL_YAW_OVERRIDE=%s <-- the real freshness marker", tostring(hasOverride))) + log(string.format(" verdict: %s", hasOverride and "FIXED source present" or "OLD pre-fix source")) +end + +-- Also note whether the stale proof marker survived (it should NOT have -- +-- it only ever existed in the discarded Play sandbox). +local marker = nil +for _, d in ipairs(workspace:GetDescendants()) do + if d.Name == "HERMES_PROOF_MARKER" then + marker = d + break + end +end +log("") +log( + "HERMES_PROOF_MARKER in Edit: " + .. (marker and "present (unexpected)" or "absent (expected - Play sandbox discarded)") +) + +return table.concat(out, "\n") diff --git a/tools/diag_elevation.luau b/tools/diag_elevation.luau new file mode 100644 index 0000000..1ec022f --- /dev/null +++ b/tools/diag_elevation.luau @@ -0,0 +1,86 @@ +--[[ + diag_elevation.luau -- measure the vertical relationship between the wangan + mainline and the Daikoku PA, so the "elevate the highway over the PA" + rebuild can be designed from real numbers instead of guesses. + + Reports, for the stretch of mainline nearest the PA: + * the deck Y profile (is it flat? already climbing? in a tunnel?) + * which SIDE of the carriageway the PA sits on, per sample + * perpendicular distance from the mainline centreline to the PA centre + * terrain/ground height under the PA and under the mainline + + Read-only. +]] + +local RS = game:GetService("ReplicatedStorage") +local Workspace = game:GetService("Workspace") +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) + +local samples = Circuit.samples +local c = Circuit.PA_CENTER +local n = #samples + +-- Nearest non-tunnel sample = where planPA anchors the junctions. +local bestIdx, bestD = 1, math.huge +for i, s in ipairs(samples) do + local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude + if not s.tunnel and d < bestD then + bestIdx, bestD = i, d + end +end + +-- Ground probe: ray straight down, ignoring the built map so we hit terrain. +local map = Workspace:FindFirstChild("Map") +local rp = RaycastParams.new() +rp.FilterType = Enum.RaycastFilterType.Exclude +rp.FilterDescendantsInstances = map and { map } or {} + +local function groundY(x, z, fromY) + local hit = Workspace:Raycast(Vector3.new(x, fromY, z), Vector3.new(0, -4000, 0), rp) + return hit and math.floor(hit.Position.Y * 10) / 10 or nil +end + +local rows = {} +for k = -30, 30 do + local i = ((bestIdx + k - 1) % n) + 1 + local s = samples[i] + local nxt = samples[(i % n) + 1] + local fwd = (nxt.pos - s.pos) + fwd = Vector3.new(fwd.X, 0, fwd.Z) + if fwd.Magnitude < 0.01 then + continue + end + fwd = fwd.Unit + local right = Vector3.new(-fwd.Z, 0, fwd.X) + local toPA = Vector3.new(c.X - s.pos.X, 0, c.Z - s.pos.Z) + table.insert(rows, { + k = k, + i = i, + y = math.floor(s.pos.Y * 10) / 10, + tun = s.tunnel and 1 or 0, + -- +1 => PA is to the RIGHT of travel, -1 => LEFT + side = (toPA.Unit:Dot(right) >= 0) and 1 or -1, + -- signed perpendicular offset and along-track offset to the PA centre + perp = math.floor(toPA:Dot(right)), + along = math.floor(toPA:Dot(fwd)), + dist = math.floor(toPA.Magnitude), + }) +end + +local ys = {} +for _, r in ipairs(rows) do + table.insert(ys, r.y) +end +table.sort(ys) + +return game:GetService("HttpService"):JSONEncode({ + bestIdx = bestIdx, + bestDist = math.floor(bestD), + paY = c.Y, + mainYmin = ys[1], + mainYmax = ys[#ys], + groundUnderPA = groundY(c.X, c.Z, c.Y + 500), + groundUnderMain = groundY(samples[bestIdx].pos.X, samples[bestIdx].pos.Z, samples[bestIdx].pos.Y + 500), + ringRadius = (require(RS.Shared.Circuit) and Circuit.PA_RADIUS + 130) or 0, + rows = rows, +}) diff --git a/tools/diag_forward.luau b/tools/diag_forward.luau new file mode 100644 index 0000000..f07b557 --- /dev/null +++ b/tools/diag_forward.luau @@ -0,0 +1,182 @@ +--[[ + diag_forward.luau -- settle which end of the car is the NOSE. + + Contradiction to resolve: the chassis places its FRONT axle at local -Z + (WHEELBASE_HALF = 5.9, frontZ = -5.90) but Body.LookVector is +Z. Those + disagree: the part the code calls "front" is BEHIND the direction the body + looks. One of the two conventions is wrong, and the shell is being aligned + to the wrong one -- which is why the car renders backwards even though + shell and body agree (yawDelta 0). + + Ground truth used here, in order of reliability: + + 1. VehicleSeat.LookVector -- in Roblox a seated driver faces the seat's + +Z look direction and positive throttle drives that way. This is the + direction the car ACTUALLY moves, so it defines "forward" for gameplay. + 2. Steering: which wheels have a steering constraint. Only the FRONT + wheels steer, so a steered wheel's local Z tells us where the front is. + 3. Shell art extents: the visual mesh bounding box relative to its pivot. + + Reports each independently so a disagreement is visible rather than assumed. +]] + +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 cars = {} +for _, d in ipairs(workspace:GetDescendants()) do + if d:IsA("Model") and d.Name:match("^Car_") then + cars[#cars + 1] = d + end +end + +if #cars == 0 then + return "No live Car_* models." +end + +for _, model in ipairs(cars) do + log("=== " .. model.Name .. " ===") + + local body = model:FindFirstChild("Body") + if not (body and body:IsA("BasePart")) then + log(" no Body part") + continue + end + local bodyCF = body.CFrame + local inv = bodyCF:Inverse() + + log( + string.format( + " bodyLook (world) = (%.2f, %.2f, %.2f)", + bodyCF.LookVector.X, + bodyCF.LookVector.Y, + bodyCF.LookVector.Z + ) + ) + + -- 1. VehicleSeat: the direction the car actually drives. + local seat = nil + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("VehicleSeat") then + seat = d + break + end + end + if seat then + local seatLocalZ = (inv * seat.CFrame).Z + local dot = bodyCF.LookVector:Dot(seat.CFrame.LookVector) + log( + string.format( + " [1] VehicleSeat: localZ=%.2f, seatLook.dot(bodyLook)=%.2f -> seat faces %s body", + seatLocalZ, + dot, + (dot > 0) and "SAME as" or "OPPOSITE to" + ) + ) + log(string.format(" => car drives toward body %s Z", (dot > 0) and "+" or "-")) + else + log(" [1] no VehicleSeat found") + end + + -- 2. Which wheels steer? Only the front axle has a steering servo. + local steered, plain = {}, {} + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^Wheel_") then + local z = (inv * d.CFrame).Z + local hasServo = false + for _, c in ipairs(d:GetDescendants()) do + if c:IsA("HingeConstraint") and c.ActuatorType == Enum.ActuatorType.Servo then + hasServo = true + break + end + end + -- servo may live on the attachment's parent chain instead + if not hasServo and d.Parent then + for _, c in ipairs(d.Parent:GetChildren()) do + if c:IsA("HingeConstraint") and c.ActuatorType == Enum.ActuatorType.Servo then + local a1, a0 = c.Attachment1, c.Attachment0 + if (a1 and a1:IsDescendantOf(d)) or (a0 and a0:IsDescendantOf(d)) then + hasServo = true + break + end + end + end + end + table.insert(hasServo and steered or plain, { name = d.Name, z = z }) + end + end + + local function meanZ(t) + if #t == 0 then + return 0 / 0 + end + local s = 0 + for _, v in ipairs(t) do + s = s + v.z + end + return s / #t + end + + local sz, pz = meanZ(steered), meanZ(plain) + log(string.format(" [2] steered wheels=%d meanZ=%.2f | unsteered=%d meanZ=%.2f", #steered, sz, #plain, pz)) + if #steered > 0 and #plain > 0 then + log(string.format(" => FRONT (steered) axle is at body %s Z", (sz < pz) and "-" or "+")) + end + for _, v in ipairs(steered) do + log(string.format(" steered: %-14s z=%.2f", v.name, v.z)) + end + for _, v in ipairs(plain) do + log(string.format(" plain : %-14s z=%.2f", v.name, v.z)) + end + + -- 3. Shell art: where is the visual mass, and is it symmetric? + local shell = nil + for _, c in ipairs(model:GetChildren()) do + if c:IsA("Model") then + shell = c + break + end + end + if shell then + local cf, size = shell:GetBoundingBox() + local localCentre = (inv * cf.Position) + log( + string.format( + " [3] shell bbox size=(%.1f, %.1f, %.1f) centre localZ=%.2f", + size.X, + size.Y, + size.Z, + localCentre.Z + ) + ) + -- named bodywork gives an independent nose/tail cue + for _, d in ipairs(shell:GetDescendants()) do + local n = d.Name:lower() + if + d:IsA("BasePart") + and ( + n:match("hood") + or n:match("bonnet") + or n:match("boot") + or n:match("trunk") + or n:match("spoiler") + or n:match("windscreen") + or n:match("bumper") + ) + then + log(string.format(" %-16s localZ=%.2f", d.Name, (inv * d.CFrame).Z)) + end + end + else + log(" [3] no shell model child") + end + log("") +end + +return table.concat(out, "\n") diff --git a/tools/diag_frontjoint.luau b/tools/diag_frontjoint.luau new file mode 100644 index 0000000..983747b --- /dev/null +++ b/tools/diag_frontjoint.luau @@ -0,0 +1,109 @@ +--[[ + diag_frontjoint.luau -- is the front wheel still ATTACHED? + + My RWD change returns early before creating the motor hinge, which is the + ONLY joint that ever connected Wheel_F* to Knuckle_F*. If nothing else + joins them, the front wheels are now free-floating parts that merely start + in the right place -- which would explain both observations at once: + the servo turns perfectly (it has nothing to fight) AND the car generates + no lateral force (the front tyres are not attached to the chassis). + + The same doubt applies to the earlier V7 "front motors disabled" result: + Enabled=false may have freed the servo for the same wrong reason. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local clone = orig:Clone() +clone.Parent = orig.Parent +local CarManager = require(clone) + +local pad = Instance.new("Part") +pad.Size = Vector3.new(6000, 8, 6000) +pad.Position = Vector3.new(0, 5000, 0) +pad.Anchored = true +pad.Parent = workspace +local padTop = pad.Position.Y + 4 + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) +local model = CarManager._buildForDiag(car, tuning) +model.Parent = workspace +model:PivotTo(CFrame.new(0, padTop + 3, 0)) + +local function get(n) + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") and d.Name == n then + return d + end + end +end + +log("Joints touching each WHEEL part:") +for _, wn in ipairs({ "Wheel_FL", "Wheel_FR", "Wheel_RL", "Wheel_RR" }) do + local w = get(wn) + local found = {} + for _, d in ipairs(model:GetDescendants()) do + local p0, p1, kind + if d:IsA("WeldConstraint") then + p0, p1, kind = d.Part0, d.Part1, "Weld" + elseif d:IsA("Constraint") then + p0 = d.Attachment0 and d.Attachment0.Parent + p1 = d.Attachment1 and d.Attachment1.Parent + kind = d.ClassName .. "/" .. d.Name + end + if p0 and p1 and (p0 == w or p1 == w) then + found[#found + 1] = string.format("%s(%s<->%s)", kind, p0.Name, p1.Name) + end + end + log(string.format(" %-10s %s", wn, #found > 0 and table.concat(found, " ") or "*** NO JOINTS -- DETACHED ***")) +end + +-- Drop test: a detached wheel will not follow the body when the body is moved. +log("") +log("TELEPORT TEST (move body 300 studs; attached wheels follow):") +local body = model.PrimaryPart +for _ = 1, 60 do + RunService.Heartbeat:Wait() +end +local before = {} +for _, wn in ipairs({ "Wheel_FL", "Wheel_RL" }) do + before[wn] = get(wn).Position +end +model:PivotTo(model:GetPivot() * CFrame.new(0, 0, -300)) +for _ = 1, 60 do + RunService.Heartbeat:Wait() +end +for _, wn in ipairs({ "Wheel_FL", "Wheel_RL" }) do + local w = get(wn) + local moved = (w.Position - before[wn]).Magnitude + log( + string.format( + " %-10s moved %7.1f studs %s", + wn, + moved, + moved > 100 and "followed the car" or "*** LEFT BEHIND (detached) ***" + ) + ) +end + +model:Destroy() +pad:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_line277.luau b/tools/diag_line277.luau new file mode 100644 index 0000000..99f8c52 --- /dev/null +++ b/tools/diag_line277.luau @@ -0,0 +1,67 @@ +--[[ + diag_line277.luau -- the build harness died at CarManager:277 with + "attempt to concatenate string with nil", but line 277 of the LOCAL file is + a comment. So the code that ran is not the code on disk. + + Two candidates: + (a) the pushed Source is out of sync with disk + (b) require() handed back a STALE CACHED module from earlier this session + (the known Studio trap: editing Source does NOT invalidate the cache) + + This reads the Source text directly -- never require() -- and prints the + real lines around 277, plus the reverted-convention markers, so we can tell + (a) from (b) instead of guessing. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") + +local function find(name) + for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do + local m = root:FindFirstChild(name, true) + if m and m:IsA("ModuleScript") then + return m + end + end + return nil +end + +local cm = find("CarManager") +if not cm then + return "FATAL: CarManager not found" +end + +local src = cm.Source +local lines = {} +for l in (src .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, l) +end + +local out = {} +table.insert(out, string.format("%s %d bytes, %d lines", cm:GetFullName(), #src, #lines)) +table.insert(out, "") +table.insert(out, "--- Studio Source lines 270..284 ---") +for i = 270, math.min(284, #lines) do + table.insert(out, string.format("%4d | %s", i, lines[i])) +end + +table.insert(out, "") +table.insert(out, "--- markers ---") +table.insert(out, " isFront = zSign < 0 present : " .. tostring(src:find("isFront = zSign < 0", 1, true) ~= nil)) +table.insert(out, " _buildForDiag present : " .. tostring(src:find("_buildForDiag", 1, true) ~= nil)) + +-- Where does _buildForDiag get assigned, per the Studio copy? +for i, l in ipairs(lines) do + if l:find("_buildForDiag", 1, true) then + table.insert(out, string.format(" _buildForDiag at line %d: %s", i, l)) + end +end + +-- What does buildCarModel expect? Print its declaration line. +for i, l in ipairs(lines) do + if l:find("function buildCarModel", 1, true) or l:find("local function buildCarModel", 1, true) then + table.insert(out, string.format(" buildCarModel declared line %d: %s", i, l)) + end +end + +return table.concat(out, "\n") diff --git a/tools/diag_nosecue.luau b/tools/diag_nosecue.luau new file mode 100644 index 0000000..f74d342 --- /dev/null +++ b/tools/diag_nosecue.luau @@ -0,0 +1,116 @@ +--[[ + diag_nosecue.luau -- confirm WHICH cue is lying. + + diag_shellgeom (bodywork asymmetry, wheel-independent) says the built + fleet disagrees on facing: rx7_fc +0.550, silv_s15 +0.206, hatch_junk + +0.614 all land one way, while hyper_x -0.290 and gtr_r34 -0.362 land the + other. Exactly two cars wrong -- matching the user's report. + + The aligner derives facing from each shell's Wheels folder. If a shell's + F*/R* wheel labels are swapped relative to its bodywork, the aligner + confidently rotates it backwards. + + This cross-checks the wheel cue against two INDEPENDENT cues that cannot + both be wrong: + * named bodywork: Hood/Windshield sit at the nose, Boot/Spoiler at the tail + * bodywork asymmetry (as before) + If wheels disagree with the bodywork, the wheel labels are the bug. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +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 + +log(string.format("%-14s %10s %10s %10s %10s %s", "car", "wheelZ", "hoodZ", "bootZ", "asym", "wheels-vs-body")) +log(string.rep("-", 78)) + +for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local pivotInv = t:GetPivot():Inverse() + + -- cue 1: wheels + local wheelDz = nil + local wf = t:FindFirstChild("Wheels") + if wf then + local fS, fN, rS, rN = 0, 0, 0, 0 + for _, w in ipairs(wf:GetChildren()) do + local p = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + if p then + local z = (pivotInv * p.Position).Z + local h = w.Name:sub(1, 1):upper() + if h == "F" then + fS, fN = fS + z, fN + 1 + elseif h == "R" then + rS, rN = rS + z, rN + 1 + end + end + end + if fN > 0 and rN > 0 then + wheelDz = fS / fN - rS / rN + end + end + + -- cue 2: named bodywork + local function avgZ(patterns) + local s, n = 0, 0 + for _, p in ipairs(t:GetDescendants()) do + if p:IsA("BasePart") then + local nm = p.Name:lower() + for _, pat in ipairs(patterns) do + if nm == pat then + s += (pivotInv * p.Position).Z + n += 1 + break + end + end + end + end + return n > 0 and (s / n) or nil + end + local hoodZ = avgZ({ "hood", "windshield", "bonnet", "grille" }) + local bootZ = avgZ({ "boot", "spoiler", "trunk" }) + + -- cue 3: asymmetry + local sum, wsum, lo, hi = 0, 0, math.huge, -math.huge + for _, p in ipairs(t:GetDescendants()) do + if p:IsA("BasePart") then + local nm = p.Name:lower() + if not (nm:find("wheel") or nm:find("tyre") or nm:find("tire") or nm:find("rim")) then + local z = (pivotInv * p.Position).Z + local v = p.Size.X * p.Size.Y * p.Size.Z + sum, wsum = sum + z * v, wsum + v + lo, hi = math.min(lo, z), math.max(hi, z) + end + end + end + local asym = wsum > 0 and (sum / wsum - (lo + hi) / 2) or nil + + -- Wheels say nose is at sign(wheelDz). Bodywork says nose is where the + -- hood is, i.e. sign(hoodZ). They must agree. + local verdict = "?" + if wheelDz and hoodZ then + local wheelNose = wheelDz < 0 and -1 or 1 + local hoodNose = hoodZ < 0 and -1 or 1 + verdict = (wheelNose == hoodNose) and "agree" or "*** DISAGREE -- wheel labels swapped ***" + end + + log( + string.format( + "%-14s %10s %10s %10s %10s %s", + t.Name, + wheelDz and string.format("%+.2f", wheelDz) or "n/a", + hoodZ and string.format("%+.2f", hoodZ) or "n/a", + bootZ and string.format("%+.2f", bootZ) or "n/a", + asym and string.format("%+.2f", asym) or "n/a", + verdict + ) + ) +end + +return table.concat(out, "\n") diff --git a/tools/diag_nosecue2.luau b/tools/diag_nosecue2.luau new file mode 100644 index 0000000..dfd6f7a --- /dev/null +++ b/tools/diag_nosecue2.luau @@ -0,0 +1,200 @@ +--[[ + diag_nosecue2.luau -- settle which is lying on hatch_junk / hyper_x: the + wheel LABELS, or the shell MESH itself. + + Background: diag_shelllabels.luau showed those two shells label their F + axle toward +Z while rx7_fc / silv_s15 / gtr_r34 label F toward -Z. That + matches the reported symptom exactly, but it does NOT say which side is + wrong -- either the labels are swapped, or the whole mesh is authored + facing the other way and the labels are honest. + + So: gather cues that are INDEPENDENT of the wheel names, from the shell's + own parts. Every cue is read off part names/positions in the shell's local + frame; none is derived from a constant, and each can come out either way. + + headlights -- sit at the NOSE + taillights -- sit at the TAIL + *front* / *rear* named parts (bumpers, grille) -- self-describing + seat -- a VehicleSeat/Seat inside the shell looks FORWARD + + Interpretation: + * cues agree with labels -> the MESH is rotated; labels are honest + * cues oppose the labels -> the LABELS are swapped; mesh is fine + A shell where cues themselves disagree is reported as INCONCLUSIVE rather + than being forced into a verdict. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") +local SS = game:GetService("ServerStorage") + +local TARGETS = { rx7_fc = true, hatch_junk = true, silv_s15 = true, gtr_r34 = true, hyper_x = true } + +local out = {} +local function log(s) + table.insert(out, s) +end + +-- Collect the five target shells wherever they live. +local shells = {} +for _, root in ipairs({ RS, SSS, SS, workspace }) do + for _, d in ipairs(root:GetDescendants()) do + if d:IsA("Model") and TARGETS[d.Name] and d:FindFirstChild("Wheels") and not shells[d.Name] then + shells[d.Name] = d + end + end +end + +-- Mean local Z of every part whose lowercased name matches a pattern. +local function meanZ(shell, pivotInv, patterns) + local sum, n = 0, 0 + for _, d in ipairs(shell:GetDescendants()) do + if d:IsA("BasePart") then + local nm = d.Name:lower() + for _, pat in ipairs(patterns) do + if nm:find(pat, 1, true) then + sum += (pivotInv * d.Position).Z + n += 1 + break + end + end + end + end + if n == 0 then + return nil, 0 + end + return sum / n, n +end + +local order = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" } + +log( + string.format( + "%-11s %-14s %-14s %-14s %-14s %s", + "shell", + "labelF.Z", + "headlight.Z", + "taillight.Z", + "seatFwd.Z", + "verdict" + ) +) +log(string.rep("-", 104)) + +local seen = 0 +for _, name in ipairs(order) do + local shell = shells[name] + if not shell then + log(string.format("%-11s NOT FOUND", name)) + continue + end + seen += 1 + local pivotInv = shell:GetPivot():Inverse() + + -- Wheel-label cue: sign of (F centroid - R centroid).Z + local wheels = shell:FindFirstChild("Wheels") + local fSum, fN, rSum, rN = 0, 0, 0, 0 + for _, w in ipairs(wheels:GetChildren()) do + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then + local z = (pivotInv * (sum / n)).Z + local head = w.Name:sub(1, 1):upper() + if head == "F" then + fSum, fN = fSum + z, fN + 1 + elseif head == "R" then + rSum, rN = rSum + z, rN + 1 + end + end + end + local labelZ = (fN > 0 and rN > 0) and ((fSum / fN) - (rSum / rN)) or nil + + local headZ, headN = meanZ(shell, pivotInv, { "headlight", "headlamp", "grille", "grill" }) + local tailZ, tailN = meanZ(shell, pivotInv, { "taillight", "taillamp", "tailight", "brakelight" }) + + -- Seat cue: a seat's LookVector, expressed in the SHELL's frame. + local seatZ = nil + for _, d in ipairs(shell:GetDescendants()) do + if d:IsA("VehicleSeat") or d:IsA("Seat") then + -- Rotate (not translate) the seat's world look into shell-local axes. + seatZ = (shell:GetPivot():Inverse() * (d.CFrame.LookVector + shell:GetPivot().Position)).Z + break + end + end + + -- Each cue votes for which local Z direction is the NOSE. + -- Convention target: nose should be at -Z. + local votes = {} + if headZ then + votes[#votes + 1] = (headZ < 0) and -1 or 1 + end + if tailZ then + votes[#votes + 1] = (tailZ < 0) and 1 or -1 + end -- tail opposes nose + if seatZ then + votes[#votes + 1] = (seatZ < 0) and -1 or 1 + end + + local noseSign = nil + if #votes > 0 then + local s = 0 + for _, v in ipairs(votes) do + s += v + end + if s > 0 then + noseSign = 1 + elseif s < 0 then + noseSign = -1 + end + end + + local verdict + if not labelZ then + verdict = "no F/R labels" + elseif #votes == 0 then + verdict = "NO INDEPENDENT CUE (no lights/seat found)" + elseif noseSign == nil then + verdict = "INCONCLUSIVE (cues split)" + else + local labelSign = (labelZ < 0) and -1 or 1 + if labelSign == noseSign then + verdict = (noseSign < 0) and "consistent, nose -Z (fine)" or "LABELS HONEST -> MESH is rotated 180" + else + verdict = "LABELS SWAPPED -> mesh is fine" + end + end + + local function fmt(v, n) + return v and string.format("%+.1f(%d)", v, n or 0) or "--" + end + log( + string.format( + "%-11s %-14s %-14s %-14s %-14s %s", + name, + labelZ and string.format("%+.1f", labelZ) or "--", + fmt(headZ, headN), + fmt(tailZ, tailN), + seatZ and string.format("%+.2f", seatZ) or "--", + verdict + ) + ) +end + +log("") +if seen == 0 then + log(">>> NOTHING INSPECTED -- this is a FAILURE, not a pass.") +else + log(string.format(">>> inspected %d/5 shells", seen)) + log(" labelF.Z = (F centroid - R centroid).Z; negative means labels put F toward -Z") + log(" headlight.Z = mean local Z of headlight/grille parts (count); nose should be -Z") + log(" seatFwd.Z = seat LookVector Z in shell frame; negative = seat faces -Z") +end + +return table.concat(out, "\n") diff --git a/tools/diag_paexit.luau b/tools/diag_paexit.luau new file mode 100644 index 0000000..c505f69 --- /dev/null +++ b/tools/diag_paexit.luau @@ -0,0 +1,155 @@ +--[[ + diag_paexit.luau -- work out the real route from the SpawnPad out of the + Daikoku PA and onto the wangan mainline. + + Reports, in world coordinates: the spawn pad, the ring road, both ramps, + and -- crucially -- the COMPASS BEARING and distance from the pad to each + ramp, so the answer can be phrased as "drive X studs toward " + rather than a vague description. + + Bearings are given in Roblox world terms: -Z is treated as North. +]] + +local Workspace = game:GetService("Workspace") + +local out = {} +local function log(s) + table.insert(out, s) +end + +local map = Workspace:FindFirstChild("Map") +if not map then + return "FATAL: no Map in Workspace -- build the map first (Play once)" +end + +local function grab(name) + local hit = map:FindFirstChild(name, true) + return (hit and hit:IsA("BasePart")) and hit or nil +end + +local pad = grab("SpawnPad") +if not pad then + return "FATAL: SpawnPad not found under Map" +end + +log( + string.format( + "SpawnPad pos=(%.0f, %.0f, %.0f) size=(%.0f x %.0f)", + pad.Position.X, + pad.Position.Y, + pad.Position.Z, + pad.Size.X, + pad.Size.Z + ) +) + +-- Compass bearing from the pad to a target, with -Z = North. +local function bearing(from, to) + local d = Vector3.new(to.X - from.X, 0, to.Z - from.Z) + if d.Magnitude < 0.01 then + return "--", 0 + end + local deg = math.deg(math.atan2(d.X, -d.Z)) -- 0 = -Z (north), +90 = +X (east) + if deg < 0 then + deg += 360 + end + local names = { "North", "NE", "East", "SE", "South", "SW", "West", "NW" } + local idx = math.floor(((deg + 22.5) % 360) / 45) + 1 + return names[idx], d.Magnitude, deg +end + +log("") +log("--- ramps and ring ---") +for _, nm in ipairs({ "PAOnRamp", "PAOffRamp" }) do + local p = grab(nm) + if p then + local dir, dist, deg = bearing(pad.Position, p.Position) + log( + string.format( + "%-10s pos=(%.0f, %.0f, %.0f) %s of pad, %.0f studs, bearing %.0f deg", + nm, + p.Position.X, + p.Position.Y, + p.Position.Z, + dir, + dist, + deg or 0 + ) + ) + else + log(string.format("%-10s NOT FOUND", nm)) + end +end + +-- Ring segments: report the ring's radius and height vs the pad. +local ringParts = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and (d.Name:find("Ring") or d.Name == "PARing") then + table.insert(ringParts, d) + end +end +if #ringParts > 0 then + local minY, maxY = math.huge, -math.huge + local maxR = 0 + for _, p in ipairs(ringParts) do + minY = math.min(minY, p.Position.Y) + maxY = math.max(maxY, p.Position.Y) + local r = (Vector3.new(p.Position.X - pad.Position.X, 0, p.Position.Z - pad.Position.Z)).Magnitude + maxR = math.max(maxR, r) + end + log( + string.format( + "ring %d parts, radius up to %.0f studs, Y %.0f..%.0f (pad Y %.0f)", + #ringParts, + maxR, + minY, + maxY, + pad.Position.Y + ) + ) + log(string.format(" ring sits %.0f studs ABOVE the pad", minY - pad.Position.Y)) +end + +-- Where is the mainline relative to the pad? +log("") +log("--- mainline ---") +local roadFolder = map:FindFirstChild("Road") or map:FindFirstChild("Circuit") +if roadFolder then + local nearest, nearestD = nil, math.huge + for _, d in ipairs(roadFolder:GetDescendants()) do + if d:IsA("BasePart") then + local dist = (Vector3.new(d.Position.X - pad.Position.X, 0, d.Position.Z - pad.Position.Z)).Magnitude + if dist < nearestD then + nearestD, nearest = dist, d + end + end + end + if nearest then + local dir, dist = bearing(pad.Position, nearest.Position) + log( + string.format( + "nearest mainline part '%s' at (%.0f, %.0f, %.0f)", + nearest.Name, + nearest.Position.X, + nearest.Position.Y, + nearest.Position.Z + ) + ) + log( + string.format( + " -> %s of the pad, %.0f studs away, Y %+.0f vs pad", + dir, + dist, + nearest.Position.Y - pad.Position.Y + ) + ) + end +else + local names = {} + for _, d in ipairs(map:GetChildren()) do + table.insert(names, d.Name) + end + log("no Road/Circuit folder; Map children: " .. table.concat(names, ", ")) +end + +return table.concat(out, "\n") diff --git a/tools/diag_paexit2.luau b/tools/diag_paexit2.luau new file mode 100644 index 0000000..fd862c3 --- /dev/null +++ b/tools/diag_paexit2.luau @@ -0,0 +1,116 @@ +--[[ + diag_paexit2.luau -- follow-up: the first pass looked for a "Road" folder, + but the map's mainline lives under "Expressway". Also resolve which of the + two ramps is the way OUT (the off-ramp rejoins the mainline), and give the + pad-relative direction in terms the player can act on. +]] + +local Workspace = game:GetService("Workspace") + +local out = {} +local function log(s) + table.insert(out, s) +end + +local map = Workspace:FindFirstChild("Map") +if not map then + return "FATAL: no Map" +end +local pad = map:FindFirstChild("SpawnPad", true) +if not pad then + return "FATAL: no SpawnPad" +end + +local function bearing(from, to) + local d = Vector3.new(to.X - from.X, 0, to.Z - from.Z) + if d.Magnitude < 0.01 then + return "--", 0, 0 + end + local deg = math.deg(math.atan2(d.X, -d.Z)) + if deg < 0 then + deg += 360 + end + local names = { "North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West" } + return names[math.floor(((deg + 22.5) % 360) / 45) + 1], d.Magnitude, deg +end + +local expw = map:FindFirstChild("Expressway") +log(string.format("pad at (%.0f, %.0f, %.0f)", pad.Position.X, pad.Position.Y, pad.Position.Z)) +log("") + +if expw then + local nearest, nearestD + local count = 0 + for _, d in ipairs(expw:GetDescendants()) do + if d:IsA("BasePart") then + count += 1 + local dist = (Vector3.new(d.Position.X - pad.Position.X, 0, d.Position.Z - pad.Position.Z)).Magnitude + if not nearestD or dist < nearestD then + nearestD, nearest = dist, d + end + end + end + log(string.format("Expressway: %d parts", count)) + if nearest then + local dir, dist = bearing(pad.Position, nearest.Position) + log( + string.format( + " nearest mainline part '%s' (%.0f, %.0f, %.0f)", + nearest.Name, + nearest.Position.X, + nearest.Position.Y, + nearest.Position.Z + ) + ) + log(string.format(" -> %s of pad, %.0f studs, Y %+.0f vs pad", dir, dist, nearest.Position.Y - pad.Position.Y)) + end +end + +-- Ramp detail: where each ramp STARTS (ring end) and ENDS (mainline end). +log("") +log("--- ramp geometry (which way out) ---") +for _, nm in ipairs({ "PAOnRamp", "PAOffRamp" }) do + local p = map:FindFirstChild(nm, true) + if not p then + log(nm .. ": NOT FOUND") + continue + end + -- A ramp deck is built with lookAt(mid, road): its LookVector points at the + -- mainline end. Report both ends so the direction of travel is unambiguous. + local halfLen = p.Size.Z / 2 + local toRoad = p.CFrame * CFrame.new(0, 0, -halfLen) + local toRing = p.CFrame * CFrame.new(0, 0, halfLen) + local dir, dist = bearing(pad.Position, p.Position) + log(string.format("%s centre %s of pad, %.0f studs", nm, dir, dist)) + log(string.format(" ring end (%.0f, %.0f, %.0f)", toRing.Position.X, toRing.Position.Y, toRing.Position.Z)) + log(string.format(" mainline end (%.0f, %.0f, %.0f)", toRoad.Position.X, toRoad.Position.Y, toRoad.Position.Z)) + log(string.format(" length %.0f studs, width %.0f", p.Size.Z, p.Size.X)) +end + +-- How far from the pad edge to the ring, in each cardinal direction? +log("") +log("--- getting from pad to ring ---") +log(string.format("pad is %.0f x %.0f, so its edge is %.0f studs from centre", pad.Size.X, pad.Size.Z, pad.Size.X / 2)) +local ringParts = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:find("Ring") then + table.insert(ringParts, d) + end +end +if #ringParts > 0 then + local minR, maxR = math.huge, 0 + for _, p in ipairs(ringParts) do + local r = (Vector3.new(p.Position.X - pad.Position.X, 0, p.Position.Z - pad.Position.Z)).Magnitude + minR = math.min(minR, r) + maxR = math.max(maxR, r) + end + log( + string.format( + "ring road lies %.0f..%.0f studs from the pad centre, in every direction (it encircles the lot)", + minR, + maxR + ) + ) +end + +return table.concat(out, "\n") diff --git a/tools/diag_pawall.luau b/tools/diag_pawall.luau new file mode 100644 index 0000000..1b70b9b --- /dev/null +++ b/tools/diag_pawall.luau @@ -0,0 +1,101 @@ +--[[ + diag_pawall.luau -- is the parking area actually sealed? + + The user reports a continuous low wall around the lot with no way out. The + suspect: planPA builds PARingWall on BOTH edges of every ring segment + (for sgn in {1,-1}) with no opening carved where PAOnRamp / PAOffRamp meet + the ring -- so the INNER lip would form an unbroken fence around the apron. + + This does not reason about the code. It fires RAYS outward from the spawn + pad in 72 directions and reports what each one hits first, so a genuine gap + shows up as a ray that reaches the ring deck (or beyond) without hitting a + wall. If every ray hits PARingWall, the lot is sealed -- confirmed by + measurement, not inspection. +]] + +local Workspace = game:GetService("Workspace") + +local map = Workspace:FindFirstChild("Map") +if not map then + return "FATAL: no Map" +end +local pad = map:FindFirstChild("SpawnPad", true) +if not pad then + return "FATAL: no SpawnPad" +end + +local origin = pad.Position + Vector3.new(0, 2, 0) + +local params = RaycastParams.new() +params.FilterType = Enum.RaycastFilterType.Include +params.FilterDescendantsInstances = { map } + +local out = {} +local function log(s) + table.insert(out, s) +end + +local N = 72 +local hits = {} +local escapes = {} +local wallH = {} + +for i = 0, N - 1 do + local ang = (i / N) * math.pi * 2 + local dir = Vector3.new(math.cos(ang), 0, math.sin(ang)) + local res = Workspace:Raycast(origin, dir * 600, params) + local bearingDeg = math.deg(math.atan2(dir.X, -dir.Z)) + if bearingDeg < 0 then + bearingDeg += 360 + end + if res then + local nm = res.Instance.Name + hits[nm] = (hits[nm] or 0) + 1 + if nm:find("Wall") or nm:find("Barrier") or nm:find("Rail") then + -- how tall is the blocker, and how high above the apron does it reach? + local top = res.Instance.Position.Y + res.Instance.Size.Y / 2 + table.insert(wallH, top - pad.Position.Y) + else + table.insert(escapes, string.format("bearing %3.0f deg -> %s at %.0f studs", bearingDeg, nm, res.Distance)) + end + else + table.insert(escapes, string.format("bearing %3.0f deg -> CLEAR (no hit in 600 studs)", bearingDeg)) + end +end + +log(string.format("cast %d rays outward from the pad at Y+2", N)) +log("") +log("--- what the rays hit first ---") +for nm, n in pairs(hits) do + log(string.format(" %-16s %d rays", nm, n)) +end + +log("") +if #escapes == 0 then + log(">>> SEALED: every one of the " .. N .. " rays hit a wall/barrier.") + log(" There is NO gap in the ring wall -- the lot is fully enclosed.") +else + log(string.format(">>> %d ray(s) found a non-wall path out:", #escapes)) + for _, e in ipairs(escapes) do + log(" " .. e) + end +end + +if #wallH > 0 then + local mn, mx = math.huge, -math.huge + for _, h in ipairs(wallH) do + mn = math.min(mn, h) + mx = math.max(mx, h) + end + log("") + log(string.format("blocking wall top sits %.1f..%.1f studs above the apron", mn, mx)) + log("(a car can climb roughly 2-3 studs; anything above that is a hard barrier)") +end + +-- Distance from pad centre to the first wall, to size the enclosure. +local r = Workspace:Raycast(origin, Vector3.new(1, 0, 0) * 600, params) +if r then + log(string.format("first blocker due East is %.0f studs from pad centre", r.Distance)) +end + +return table.concat(out, "\n") diff --git a/tools/diag_seatdir.luau b/tools/diag_seatdir.luau new file mode 100644 index 0000000..7e57084 --- /dev/null +++ b/tools/diag_seatdir.luau @@ -0,0 +1,120 @@ +--[[ + diag_seatdir.luau -- decide, empirically, which way the car DRIVES. + + The contradiction: CarManager:158 says `isFront = zSign < 0 -- car forward + is -Z`, so the front axle is built at local -Z. But the VehicleSeat's + LookVector points +Z (measured: seatLook.dot(bodyLook) = 1.00). + + In Roblox a VehicleSeat applies throttle along its OWN +Z LookVector, so + positive throttle drives the car toward +Z -- i.e. toward the axle the code + labels REAR. If that holds, every car drives "backwards" and the shell, + which is aligned to the chassis convention, renders nose-first into the + direction of travel... backwards on screen. + + This does not guess: it reads the seat's actual Throttle/steer wiring and + compares the seat basis against the wheel labels, then reports which axle + leads under positive throttle. + + It also checks whether the steering servo lives on the axle that leads, + because a car steering with its TRAILING axle is the classic shopping-cart + castering symptom the user reported. +]] + +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 + +for _, model in ipairs(workspace:GetDescendants()) do + if not (model:IsA("Model") and model.Name:match("^Car_")) then + continue + end + + local body = model:FindFirstChild("Body") + local seat = nil + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("VehicleSeat") then + seat = d + break + end + end + if not (body and body:IsA("BasePart") and seat) then + continue + end + + log("=== " .. model.Name .. " ===") + local inv = body.CFrame:Inverse() + + -- Seat look expressed in BODY-LOCAL coords: which local axis does the car + -- drive along under positive throttle? + local seatLookLocal = inv:VectorToObjectSpace(seat.CFrame.LookVector) + log( + string.format( + " seat look in body-local = (%.2f, %.2f, %.2f)", + seatLookLocal.X, + seatLookLocal.Y, + seatLookLocal.Z + ) + ) + local drivesTowardPosZ = seatLookLocal.Z > 0 + log(string.format(" => positive throttle drives toward local %sZ", drivesTowardPosZ and "+" or "-")) + + -- Where are the labelled axles, and which one has the steering servo? + local fZ, fN, rZ, rN = 0, 0, 0, 0 + local steerAxleZ, steerCount = 0, 0 + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^Wheel_") then + local z = (inv * d.CFrame).Z + if d.Name:sub(7, 7):upper() == "F" then + fZ, fN = fZ + z, fN + 1 + else + rZ, rN = rZ + z, rN + 1 + end + end + -- servo is parented to the HUB, not the wheel -- find it by name + if d:IsA("HingeConstraint") and d.ActuatorType == Enum.ActuatorType.Servo then + local host = d.Parent + if host and host:IsA("BasePart") then + steerAxleZ = steerAxleZ + (inv * host.CFrame).Z + steerCount = steerCount + 1 + end + end + end + + if fN > 0 and rN > 0 then + local fm, rm = fZ / fN, rZ / rN + log(string.format(" labelled FRONT axle localZ=%.2f | REAR axle localZ=%.2f", fm, rm)) + local leadingIsFront = (drivesTowardPosZ and fm > rm) or (not drivesTowardPosZ and fm < rm) + log( + string.format( + " => the axle LEADING under throttle is the one labelled %s", + leadingIsFront and "FRONT (correct)" or "REAR (car drives backwards!)" + ) + ) + end + + if steerCount > 0 then + local sm = steerAxleZ / steerCount + log(string.format(" steering servos=%d, mean host localZ=%.2f", steerCount, sm)) + local steerLeads = (drivesTowardPosZ and sm > 0) or (not drivesTowardPosZ and sm < 0) + log( + string.format( + " => steered axle %s under throttle %s", + steerLeads and "LEADS" or "TRAILS", + steerLeads and "(normal car)" or "(SHOPPING-CART castering)" + ) + ) + else + log(" no steering servos found by name scan") + end + log("") +end + +if #out == 0 then + return "No live cars with a Body + VehicleSeat." +end +return table.concat(out, "\n") diff --git a/tools/diag_shellbuilt.luau b/tools/diag_shellbuilt.luau new file mode 100644 index 0000000..91f8ebf --- /dev/null +++ b/tools/diag_shellbuilt.luau @@ -0,0 +1,109 @@ +--[[ + diag_shellbuilt.luau -- does the solved yaw actually reach the built car? + + diag_shellyaw2 proved the aligner SEES the problem: hatch_junk and hyper_x + are authored nose-toward +Z and solve to yaw ~180, the other three to ~0. + That is exactly the "at least two cars are backwards" the user reports, so + the cue is right and the question is whether the rotation survives to the + built model. + + The earlier attempt measured -11.80 for every car -- that is the CHASSIS + wheelbase, i.e. it matched the chassis Wheel_* parts, not the shell's own + Wheels folder. This one scopes the search strictly INSIDE the Shell model + and skips anything named Wheel_* (the chassis convention), so it can only + ever read shell wheels. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local clone = orig:Clone() +clone.Parent = orig.Parent +local CarManager = require(clone) + +local pad = Instance.new("Part") +pad.Size = Vector3.new(400, 8, 400) +pad.Position = Vector3.new(0, 5000, 0) +pad.Anchored = true +pad.Parent = workspace + +log("chassis forward = -Z. Shell nose must land at NEGATIVE body-local Z.") +log("") + +for _, template in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local id = template.Name + local car = Cars.Get(id) + if not car then + log(string.format("%-14s ", id)) + else + local model = CarManager._buildForDiag(car, Chassis.tuningFor(car)) + model.Parent = workspace + model:PivotTo(CFrame.new(0, 5010, 0)) + for _ = 1, 5 do + RunService.Heartbeat:Wait() + end + + local body = model.PrimaryPart + local shell = model:FindFirstChild("Shell") + if not shell then + log(string.format("%-14s ", id)) + else + local inv = body.CFrame:Inverse() + -- Only look at wheels INSIDE the shell, and never at chassis Wheel_*. + local wf = shell:FindFirstChild("Wheels") + local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0 + if wf then + for _, w in ipairs(wf:GetChildren()) do + if w.Name:sub(1, 6) ~= "Wheel_" then + local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + if part then + local h = w.Name:sub(1, 1):upper() + local lp = inv * part.Position + if h == "F" then + fSum, fN = fSum + lp, fN + 1 + elseif h == "R" then + rSum, rN = rSum + lp, rN + 1 + end + end + end + end + end + if fN > 0 and rN > 0 then + local f, r = fSum / fN, rSum / rN + local dz = f.Z - r.Z + log( + string.format( + "%-14s shell wheels: frontZ %+6.2f rearZ %+6.2f dZ %+6.2f -> %s", + id, + f.Z, + r.Z, + dz, + dz < 0 and "CORRECT" or "*** STILL BACKWARDS ***" + ) + ) + else + log(string.format("%-14s ", id, fN, rN)) + end + end + model:Destroy() + end +end + +pad:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_shellgeom.luau b/tools/diag_shellgeom.luau new file mode 100644 index 0000000..1d0545b --- /dev/null +++ b/tools/diag_shellgeom.luau @@ -0,0 +1,136 @@ +--[[ + diag_shellgeom.luau -- measure the SHELL BODYWORK directly. + + Two prior attempts both returned dZ = -11.80 for every car, which is the + chassis wheelbase: the "shell wheels" being matched are in fact the + chassis Wheel_* parts, so those runs proved nothing about shell facing. + + Stop relying on wheel names. Instead: + 1. Dump the real child structure of one shell so we can see what is + actually in there after the build. + 2. Measure asymmetric BODYWORK mass: a car's cabin/rear sits behind its + nose, so the shell's own parts are not symmetric front-to-back. + Compare the shell part centroid against the shell AABB centre along + body-local Z -- the sign flips when the shell is rotated 180. + 3. Cross-check against the same measurement on the raw TEMPLATE, where + diag_shellyaw2 already established ground truth (hatch_junk and + hyper_x authored nose-toward +Z, the other three nose-toward -Z). + If the built car's sign matches the template's, the flip never happened. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local cloneMod = orig:Clone() +cloneMod.Parent = orig.Parent +local CarManager = require(cloneMod) + +-- 1. What is actually inside a built shell? +local car0 = Cars.Get("hyper_x") +local m0 = CarManager._buildForDiag(car0, Chassis.tuningFor(car0)) +m0.Parent = workspace +m0:PivotTo(CFrame.new(0, 5010, 0)) +local shell0 = m0:FindFirstChild("Shell") +log("=== STRUCTURE of built hyper_x Shell ===") +if shell0 then + for _, c in ipairs(shell0:GetChildren()) do + local extra = "" + if c:IsA("Folder") or c:IsA("Model") then + local kids = {} + for _, k in ipairs(c:GetChildren()) do + kids[#kids + 1] = k.Name + end + extra = " {" .. table.concat(kids, ",") .. "}" + end + log(string.format(" %-16s %s%s", c.Name, c.ClassName, extra)) + end +else + log(" ") +end +m0:Destroy() +log("") + +-- 2/3. Bodywork asymmetry, template vs built. +local function asymmetry(root: Instance, frame: CFrame): number? + -- Volume-weighted centroid of bodywork, minus AABB centre, along local Z. + local inv = frame:Inverse() + local sum, wsum = 0, 0 + local lo, hi = math.huge, -math.huge + for _, p in ipairs(root:GetDescendants()) do + if p:IsA("BasePart") then + local n = p.Name:lower() + -- exclude wheels/tyres of any naming convention + if not (n:find("wheel") or n:find("tyre") or n:find("tire") or n:find("rim")) then + local z = (inv * p.Position).Z + local vol = p.Size.X * p.Size.Y * p.Size.Z + sum += z * vol + wsum += vol + lo = math.min(lo, z) + hi = math.max(hi, z) + end + end + end + if wsum <= 0 then + return nil + end + local centroid = sum / wsum + local aabbCentre = (lo + hi) / 2 + return centroid - aabbCentre +end + +log("=== BODYWORK ASYMMETRY along local Z (centroid - aabb centre) ===") +log("A car has more mass toward the REAR (cabin/boot), so on a correctly") +log("faced shell this should be POSITIVE (mass behind centre, nose at -Z).") +log("") +log(string.format("%-14s %14s %14s %s", "car", "template", "built", "verdict")) + +for _, template in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local id = template.Name + local tA = asymmetry(template, template:GetPivot()) + local car = Cars.Get(id) + local bA = nil + if car then + local m = CarManager._buildForDiag(car, Chassis.tuningFor(car)) + m.Parent = workspace + m:PivotTo(CFrame.new(0, 5010, 0)) + for _ = 1, 3 do + RunService.Heartbeat:Wait() + end + local sh = m:FindFirstChild("Shell") + if sh then + bA = asymmetry(sh, m.PrimaryPart.CFrame) + end + m:Destroy() + end + local verdict = "?" + if tA and bA then + local flipped = (tA > 0) ~= (bA > 0) + verdict = flipped and "rotated by build" or "NOT rotated" + end + log( + string.format( + "%-14s %14s %14s %s", + id, + tA and string.format("%+.3f", tA) or "n/a", + bA and string.format("%+.3f", bA) or "n/a", + verdict + ) + ) +end + +cloneMod:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_shelllabels.luau b/tools/diag_shelllabels.luau new file mode 100644 index 0000000..cb13ad9 --- /dev/null +++ b/tools/diag_shelllabels.luau @@ -0,0 +1,190 @@ +--[[ + diag_shelllabels.luau -- compare the RAW shell assets, before any code + touches them. Purpose: find what is different about hatch_junk and hyper_x + (reported backwards) versus rx7_fc / silv_s15 / gtr_r34 (reported fine). + + A convention error would hit all five equally, so the defect must be + per-asset. The shell yaw solver derives "nose" from the F*/R* wheel NAMES, + so if those two shells label their axles opposite to the others, the solver + is fed a reversed cue and dutifully rotates them 180 degrees. + + IMPORTANT -- this reads the stored shell assets directly. It does NOT call + require() on CarManager, and it does NOT rely on any value derived from a + constant. Every number below is read off the asset's own geometry, so it + genuinely differs between a correctly-labelled and a mislabelled shell. + + Independent cue: cars are longer than they are wide, so the shell's LONG + horizontal axis is the longitudinal (nose-tail) axis. Comparing the wheel + label cue against the bounding-box long axis tells us whether the labels + agree with the geometry -- without trusting either one alone. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") + +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 + +-- Locate the folder holding the shell models. Search broadly rather than +-- assuming a path, and report what was found so a wrong guess is visible. +local function findShellRoot() + for _, root in ipairs({ RS, SSS, workspace, game:GetService("ServerStorage") }) do + for _, d in ipairs(root:GetDescendants()) do + if d:IsA("Folder") and (d.Name == "Shells" or d.Name == "CarShells" or d.Name == "Bodies") then + return d + end + end + end + return nil +end + +local shellRoot = findShellRoot() +if not shellRoot then + -- Fall back: find any Model that contains a "Wheels" child. + local found = {} + for _, root in ipairs({ RS, SSS, workspace, game:GetService("ServerStorage") }) do + for _, d in ipairs(root:GetDescendants()) do + if d:IsA("Model") and d:FindFirstChild("Wheels") then + table.insert(found, d) + end + end + end + if #found == 0 then + return "FATAL: no shell models found (no Shells folder, no Model with a Wheels child)" + end + log("no Shells folder; found shells by Wheels child:") + shellRoot = nil + local list = {} + for _, m in ipairs(found) do + table.insert(list, m) + end + -- Reuse the same reporting path below. + shellRoot = { + GetChildren = function() + return list + end, + } +end + +log( + string.format( + "%-11s %-26s %-22s %-18s %s", + "shell", + "F centroid (local)", + "R centroid", + "long axis", + "labels agree?" + ) +) +log(string.rep("-", 100)) + +local shells = shellRoot:GetChildren() +local reported = 0 + +for _, shell in ipairs(shells) do + if not shell:IsA("Model") then + continue + end + local wheels = shell:FindFirstChild("Wheels") + if not wheels then + continue + end + + local pivotInv = shell:GetPivot():Inverse() + + -- Centroid of each labelled axle, in the shell's own local frame. + local fSum, fN = Vector3.zero, 0 + local rSum, rN = Vector3.zero, 0 + for _, w in ipairs(wheels:GetChildren()) do + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then + local head = w.Name:sub(1, 1):upper() + local localPos = pivotInv * (sum / n) + if head == "F" then + fSum, fN = fSum + localPos, fN + 1 + elseif head == "R" then + rSum, rN = rSum + localPos, rN + 1 + end + end + end + + if fN == 0 or rN == 0 then + log(string.format("%-11s NO F/R WHEEL LABELS (F=%d R=%d)", shell.Name, fN, rN)) + reported += 1 + continue + end + + local fc = fSum / fN + local rc = rSum / rN + local nose = fc - rc + + -- Independent geometric cue: the shell's longest horizontal extent is the + -- nose-tail axis. If |nose.Z| dominates, labels lie along Z (expected). + local minV, maxV + for _, d in ipairs(shell:GetDescendants()) do + if d:IsA("BasePart") then + local p = pivotInv * d.Position + minV = minV and Vector3.new(math.min(minV.X, p.X), 0, math.min(minV.Z, p.Z)) or Vector3.new(p.X, 0, p.Z) + maxV = maxV and Vector3.new(math.max(maxV.X, p.X), 0, math.max(maxV.Z, p.Z)) or Vector3.new(p.X, 0, p.Z) + end + end + local spanX = maxV and (maxV.X - minV.X) or 0 + local spanZ = maxV and (maxV.Z - minV.Z) or 0 + local longAxis = (spanZ >= spanX) and "Z" or "X" + + -- The convention is: forward = -Z. So a correctly-labelled shell has its + -- FRONT axle at a MORE NEGATIVE local Z than its rear, i.e. nose.Z < 0. + local agrees = nose.Z < 0 + local verdict + if math.abs(nose.Z) < 0.05 then + verdict = "DEGENERATE (no Z separation)" + elseif agrees then + verdict = "OK (F is toward -Z)" + else + verdict = "*** REVERSED (F is toward +Z) ***" + end + + log( + string.format( + "%-11s F(%+.1f,%+.1f) %-11s R(%+.1f,%+.1f) %-6s %s(x%.0f z%.0f) %s", + shell.Name, + fc.X, + fc.Z, + "", + rc.X, + rc.Z, + "", + longAxis, + spanX, + spanZ, + verdict + ) + ) + reported += 1 +end + +log("") +if reported == 0 then + log(">>> NOTHING INSPECTED -- no shells with a Wheels child. This is a FAILURE, not a pass.") +else + log(string.format(">>> inspected %d shells", reported)) + log(" Convention: forward = -Z, so a correct shell has F wheels at more NEGATIVE Z than R.") + log(" Any shell marked REVERSED has mislabelled wheels -- the yaw solver's cue is inverted") + log(" for that car only, which is exactly the per-car symptom reported.") +end + +return table.concat(out, "\n") diff --git a/tools/diag_shellparts.luau b/tools/diag_shellparts.luau new file mode 100644 index 0000000..5ba6b32 --- /dev/null +++ b/tools/diag_shellparts.luau @@ -0,0 +1,108 @@ +--[[ + diag_shellparts.luau -- dump the actual part inventory of hatch_junk and + hyper_x. diag_nosecue2 found no headlight/taillight/seat on either, so the + naming convention must differ. Read-only: list what IS there, plus a purely + geometric asymmetry cue that needs no naming at all. + + Geometric cue (naming-independent): on a car, the passenger cabin / + windscreen sits BEHIND the nose, so the mass distribution along the + longitudinal axis is asymmetric. Cars are also taller at the cabin than at + the nose. Reporting mean height per Z-half lets the shape itself indicate + which end is the bonnet -- the tall half is the cabin/rear-biased end. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") +local SS = game:GetService("ServerStorage") + +local TARGETS = { hatch_junk = true, hyper_x = true, rx7_fc = true } + +local out = {} +local function log(s) + table.insert(out, s) +end + +local shells = {} +for _, root in ipairs({ RS, SSS, SS, workspace }) do + for _, d in ipairs(root:GetDescendants()) do + if d:IsA("Model") and TARGETS[d.Name] and d:FindFirstChild("Wheels") and not shells[d.Name] then + shells[d.Name] = d + end + end +end + +for _, name in ipairs({ "rx7_fc", "hatch_junk", "hyper_x" }) do + local shell = shells[name] + if not shell then + log(name .. ": NOT FOUND") + continue + end + local pivotInv = shell:GetPivot():Inverse() + + log("=== " .. name .. " ===") + + -- Part names outside the Wheels folder, with local Z, sorted front-to-back. + local rows = {} + for _, d in ipairs(shell:GetDescendants()) do + if d:IsA("BasePart") and not d:IsDescendantOf(shell.Wheels) then + local p = pivotInv * d.Position + table.insert(rows, { name = d.Name, z = p.Z, y = p.Y, cls = d.ClassName }) + end + end + table.sort(rows, function(a, b) + return a.z < b.z + end) + + log(string.format(" %d non-wheel parts (sorted by local Z, -Z first):", #rows)) + local shown = 0 + for _, r in ipairs(rows) do + if shown < 24 then + log(string.format(" z=%+7.2f y=%+6.2f %-28s %s", r.z, r.y, r.name, r.cls)) + shown += 1 + end + end + if #rows > 24 then + log(string.format(" ... %d more", #rows - 24)) + end + + -- Naming-independent shape cue: mean height of parts in each Z half. + local negY, negN, posY, posN = 0, 0, 0, 0 + local minZ, maxZ + for _, r in ipairs(rows) do + minZ = minZ and math.min(minZ, r.z) or r.z + maxZ = maxZ and math.max(maxZ, r.z) or r.z + end + local midZ = (minZ and maxZ) and ((minZ + maxZ) / 2) or 0 + for _, r in ipairs(rows) do + if r.z < midZ then + negY, negN = negY + r.y, negN + 1 + else + posY, posN = posY + r.y, posN + 1 + end + end + if negN > 0 and posN > 0 then + local negMean, posMean = negY / negN, posY / posN + log( + string.format( + " shape cue: mean height -Z half = %+.2f (n=%d) +Z half = %+.2f (n=%d)", + negMean, + negN, + posMean, + posN + ) + ) + local tallEnd = (negMean > posMean) and "-Z" or "+Z" + log( + string.format( + " taller (cabin) end = %s => nose is likely %s", + tallEnd, + (tallEnd == "-Z") and "+Z" or "-Z" + ) + ) + else + log(" shape cue: unavailable") + end + log("") +end + +return table.concat(out, "\n") diff --git a/tools/diag_shellyaw.luau b/tools/diag_shellyaw.luau new file mode 100644 index 0000000..b960de2 --- /dev/null +++ b/tools/diag_shellyaw.luau @@ -0,0 +1,140 @@ +--[[ + diag_shellyaw.luau -- measure the ACTUAL shell orientation on LIVE cars. + + Why this exists: the previous harness measured Wheel_* positions, which come + from the constant Chassis.WHEEL_MOTOR/WHEELBASE_HALF (5.9). Those are + identical for every car whether or not any fix applied, so the test passed + vacuously. The visible "backwards" car is the COSMETIC SHELL, which is a + separate model attached to the chassis -- that is what must be measured. + + Runs against the running game (Server datamodel), inspecting cars that the + real spawn path actually produced. Reports, per live car: + + bodyLook : chassis Body forward (should be the car's true nose direction) + shellLook : shell model forward + yawDelta : signed angle between them, degrees. ~0 = aligned, + ~180 = shell rendering backwards relative to chassis. + + Also dumps the shell's own internal cue (mean Z of its F*/R* wheel labels) + so we can see WHY the solver chose what it chose, and whether + SHELL_YAW_OVERRIDE actually took effect on this car. +]] + +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 function yawBetween(a: Vector3, b: Vector3): number + local av = Vector3.new(a.X, 0, a.Z) + local bv = Vector3.new(b.X, 0, b.Z) + if av.Magnitude < 1e-4 or bv.Magnitude < 1e-4 then + return 0 / 0 + end + av, bv = av.Unit, bv.Unit + local dot = math.clamp(av:Dot(bv), -1, 1) + local ang = math.deg(math.acos(dot)) + local cross = av:Cross(bv).Y + return (cross < 0) and -ang or ang +end + +-- Find live cars anywhere in workspace (folder name varies). +local cars = {} +for _, d in ipairs(workspace:GetDescendants()) do + if d:IsA("Model") and d.Name:match("^Car_") then + cars[#cars + 1] = d + end +end + +log(string.format("live Car_* models found: %d", #cars)) +if #cars == 0 then + log("") + log("No spawned cars. Drive/spawn a car, or this is the wrong datamodel.") + return table.concat(out, "\n") +end + +for _, model in ipairs(cars) do + log("") + log("=== " .. model.Name .. " ===") + + local body = model:FindFirstChild("Body") + if not body or not body:IsA("BasePart") then + log(" no Body part -- cannot establish chassis frame") + continue + end + + local bodyLook = body.CFrame.LookVector + log(string.format(" bodyLook = (%.2f, %.2f, %.2f)", bodyLook.X, bodyLook.Y, bodyLook.Z)) + + -- The shell: a Model child that is not the chassis scaffolding. + local shell = nil + for _, c in ipairs(model:GetChildren()) do + if c:IsA("Model") then + shell = c + break + end + end + + if not shell then + log(" NO SHELL MODEL as a child -- cosmetic mesh may be parented elsewhere") + local kids = {} + for _, c in ipairs(model:GetChildren()) do + kids[#kids + 1] = c.ClassName .. ":" .. c.Name + end + log(" children: " .. table.concat(kids, ", ")) + continue + end + + local shellLook = shell:GetPivot().LookVector + log(string.format(" shell = %s", shell.Name)) + log(string.format(" shellLook = (%.2f, %.2f, %.2f)", shellLook.X, shellLook.Y, shellLook.Z)) + + local delta = yawBetween(bodyLook, shellLook) + log( + string.format( + " yawDelta = %.1f deg --> %s", + delta, + (math.abs(delta) < 30) and "ALIGNED" + or (math.abs(math.abs(delta) - 180) < 30) and "BACKWARDS (180 flip)" + or "SKEWED" + ) + ) + + -- Why: the shell's own wheel-label cue, in the shell's local frame. + local wf = shell:FindFirstChild("Wheels") + if wf then + local pivotInv = shell:GetPivot():Inverse() + local fS, fN, rS, rN = 0, 0, 0, 0 + for _, d in ipairs(wf:GetDescendants()) do + if d:IsA("BasePart") then + local nm = d.Name:upper() + local z = (pivotInv * d.CFrame).Z + if nm:match("^F") then + fS, fN = fS + z, fN + 1 + elseif nm:match("^R") then + rS, rN = rS + z, rN + 1 + end + end + end + if fN > 0 and rN > 0 then + log( + string.format( + " shell wheel cue: frontZ=%.2f rearZ=%.2f (labels say nose at %s)", + fS / fN, + rS / rN, + (fS / fN < rS / rN) and "-Z" or "+Z" + ) + ) + else + log(string.format(" shell wheel cue: unusable (F parts=%d, R parts=%d)", fN, rN)) + end + else + log(" shell has no Wheels folder -- no cue available") + end +end + +return table.concat(out, "\n") diff --git a/tools/diag_shellyaw2.luau b/tools/diag_shellyaw2.luau new file mode 100644 index 0000000..727d650 --- /dev/null +++ b/tools/diag_shellyaw2.luau @@ -0,0 +1,95 @@ +--[[ + diag_shellyaw2.luau -- corrected. The first attempt looked in the wrong + place (CarShells/Shells/Cars) and then measured the CHASSIS wheels, so it + reported an identical -11.80 wheelbase for every car and proved nothing. + + Shells actually live in ReplicatedStorage.CarModels[car.Id] (CarManager + line 347). This inspects those templates directly and reports the exact + cue the auto-aligner uses, plus the resulting yaw. +]] + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +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 models = ReplicatedStorage:FindFirstChild("CarModels") +if not models then + log("ReplicatedStorage.CarModels: MISSING -> every car uses the procedural") + log("CarBody shell, which is authored nose-toward -Z and cannot be backwards.") + return table.concat(out, "\n") +end + +log("ReplicatedStorage.CarModels children:") +for _, t in ipairs(models:GetChildren()) do + log(string.format(" %-14s (%s)", t.Name, t.ClassName)) +end +log("") + +for _, template in ipairs(models:GetChildren()) do + if template:IsA("Model") then + local wf = template:FindFirstChild("Wheels") + if not wf then + log( + string.format( + "%-14s Wheels folder MISSING -> shellYaw = 0 (fails soft, keeps authored yaw)", + template.Name + ) + ) + else + local pivotInv = template:GetPivot():Inverse() + local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0 + local names = {} + for _, w in ipairs(wf:GetChildren()) do + table.insert(names, w.Name) + local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + if part then + local h = w.Name:sub(1, 1):upper() + local lp = pivotInv * part.Position + if h == "F" then + fSum, fN = fSum + lp, fN + 1 + elseif h == "R" then + rSum, rN = rSum + lp, rN + 1 + end + end + end + if fN > 0 and rN > 0 then + local nose = (fSum / fN) - (rSum / rN) + local usable = (math.abs(nose.X) + math.abs(nose.Z)) > 0.05 + local yaw = usable and -math.atan2(nose.X, -nose.Z) or 0 + log( + string.format( + "%-14s [%s] F=%d R=%d nose=(%.2f,%.2f,%.2f) -> yaw %+.1f deg %s", + template.Name, + table.concat(names, ","), + fN, + rN, + nose.X, + nose.Y, + nose.Z, + math.deg(yaw), + usable and "" or "*** DEGENERATE -> yaw 0 ***" + ) + ) + else + log( + string.format( + "%-14s [%s] F=%d R=%d -> *** NO F/R CUE, shellYaw = 0, stays as authored ***", + template.Name, + table.concat(names, ","), + fN, + rN + ) + ) + end + end + end +end + +return table.concat(out, "\n") diff --git a/tools/diag_solveryaw.luau b/tools/diag_solveryaw.luau new file mode 100644 index 0000000..acee787 --- /dev/null +++ b/tools/diag_solveryaw.luau @@ -0,0 +1,144 @@ +--[[ + diag_solveryaw.luau -- does the shell yaw solver actually fire for + hatch_junk / hyper_x, and what does it produce? + + Hypothesis under test: the solver derives "nose" from the F/R wheel labels, + so for a shell whose F wheels sit at +Z it should compute a 180-degree yaw + and rotate the shell to face -Z. If that is happening, those two cars would + look CORRECT -- but they look backwards. So either the solver is not being + reached, or it computes something other than 180. + + CACHE GUARD FIRST. require() caches for the whole Studio session, so a + build test can silently run OLD code. Before trusting anything below, the + script re-reads Chassis.DRIVE_SIGN via require() and compares it to the + .Source text. If they disagree, the cache is stale and the script ABORTS + rather than reporting a number that means nothing. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") + +local out = {} +local function log(s) + table.insert(out, s) +end + +local function find(name) + for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do + local m = root:FindFirstChild(name, true) + if m and m:IsA("ModuleScript") then + return m + end + end + return nil +end + +-- ---- cache guard ------------------------------------------------------- +local chMod = find("Chassis") +if not chMod then + return "FATAL: Chassis not found" +end +local srcHasNeg1 = chMod.Source:find("DRIVE_SIGN = -1", 1, true) ~= nil +local okReq, Chassis = pcall(require, chMod) +if not okReq then + return "FATAL: require(Chassis) failed: " .. tostring(Chassis) +end +local liveNeg1 = (Chassis.DRIVE_SIGN == -1) + +log( + string.format( + "cache guard: source DRIVE_SIGN=-1? %s require() DRIVE_SIGN=%s", + tostring(srcHasNeg1), + tostring(Chassis.DRIVE_SIGN) + ) +) +if srcHasNeg1 ~= liveNeg1 then + log("") + log(">>> ABORT: require() cache disagrees with source. Any build measured now") + log(" would use STALE code. Restart Studio (or reopen the place) and re-run.") + return table.concat(out, "\n") +end +log("cache guard: PASS (require matches source)") +log("") + +-- ---- what does the solver compute? ------------------------------------- +-- Recompute the solver's own input the same way the source does: nose is the +-- F-centroid minus R-centroid, in shell-local space; yaw = -atan2(nose.X, -nose.Z). +local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" } +local shells = {} +for _, root in ipairs({ RS, SSS, game:GetService("ServerStorage"), workspace }) do + for _, d in ipairs(root:GetDescendants()) do + if d:IsA("Model") and d:FindFirstChild("Wheels") and not shells[d.Name] then + for _, t in ipairs(TARGETS) do + if d.Name == t then + shells[d.Name] = d + end + end + end + end +end + +log(string.format("%-11s %-12s %-14s %s", "shell", "nose.Z", "solver yaw", "meaning")) +log(string.rep("-", 74)) + +for _, name in ipairs(TARGETS) do + local shell = shells[name] + if not shell then + log(string.format("%-11s NOT FOUND", name)) + continue + end + local pivotInv = shell:GetPivot():Inverse() + local wheels = shell:FindFirstChild("Wheels") + local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0 + for _, w in ipairs(wheels:GetChildren()) do + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then + local lp = pivotInv * (sum / n) + local head = w.Name:sub(1, 1):upper() + if head == "F" then + fSum, fN = fSum + lp, fN + 1 + elseif head == "R" then + rSum, rN = rSum + lp, rN + 1 + end + end + end + if fN == 0 or rN == 0 then + log(string.format("%-11s no F/R labels", name)) + continue + end + local nose = (fSum / fN) - (rSum / rN) + local yaw = -math.atan2(nose.X, -nose.Z) + local deg = math.deg(yaw) + local meaning + if math.abs(deg) < 15 then + meaning = "no rotation applied" + elseif math.abs(math.abs(deg) - 180) < 15 then + meaning = "180 flip -> solver DOES correct this shell" + else + meaning = "partial rotation" + end + log( + string.format( + "%-11s %-12s %-14s %s", + name, + string.format("%+.1f", nose.Z), + string.format("%+.1f deg", deg), + meaning + ) + ) +end + +log("") +log("If the two reported-backwards shells show a 180 flip here, the solver is") +log("already correcting them in theory -- so the fault is that this code path") +log("is not reached at build time, NOT the maths. That distinction decides the fix.") + +return table.concat(out, "\n") diff --git a/tools/diag_stale.luau b/tools/diag_stale.luau new file mode 100644 index 0000000..b8a59c6 --- /dev/null +++ b/tools/diag_stale.luau @@ -0,0 +1,101 @@ +--[[ + diag_stale.luau -- is the live CarManager module the source I pushed? + + diag_wheelroot2 failed with "_buildForDiag is nil" even though the file on + disk defines it at line 988 and push_module reported + assembled=39591 expected=39591 match=true. + + So either (a) the ModuleScript I am cloning is not the one I pushed to, or + (b) there is more than one CarManager, or (c) the clone-and-require trick + is returning a cached table from the ORIGINAL require rather than + re-executing the clone's source. + + (c) is the important one: if require(clone) hands back the cached original + table, then every "built car" measurement in this session came from the + OLD module, and all of those results are void. + + Checks, with no assumptions: + 1. Every ModuleScript named CarManager anywhere in the DataModel, with + its source length and whether its text contains _buildForDiag. + 2. What require() actually returns for the original and for a fresh + clone -- key list and identity comparison. +]] + +local ServerScriptService = game:GetService("ServerScriptService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +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 + +log("=== every CarManager ModuleScript in the DataModel ===") +local found = {} +for _, svc in ipairs({ ServerScriptService, ReplicatedStorage, game:GetService("ServerStorage"), workspace }) do + for _, d in ipairs(svc:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + found[#found + 1] = d + local src = "" + local okSrc = pcall(function() + src = d.Source + end) + log( + string.format( + " %-58s len=%s has _buildForDiag=%s", + d:GetFullName(), + okSrc and tostring(#src) or "", + okSrc and tostring(src:find("_buildForDiag") ~= nil) or "?" + ) + ) + end + end +end +log(string.format(" total found: %d", #found)) + +if #found == 0 then + return table.concat(out, "\n") +end + +local target = found[1] + +log("") +log("=== what require() returns ===") +local okA, A = pcall(require, target) +log(" require(original): ok=" .. tostring(okA)) +if okA and typeof(A) == "table" then + local keys = {} + for k in pairs(A) do + keys[#keys + 1] = tostring(k) + end + table.sort(keys) + log(" keys: " .. table.concat(keys, ", ")) + log(" _buildForDiag = " .. typeof(A._buildForDiag)) +end + +local clone = target:Clone() +clone.Parent = target.Parent +local okB, B = pcall(require, clone) +log(" require(clone): ok=" .. tostring(okB)) +if okB and typeof(B) == "table" then + local keys = {} + for k in pairs(B) do + keys[#keys + 1] = tostring(k) + end + table.sort(keys) + log(" keys: " .. table.concat(keys, ", ")) + log(" _buildForDiag = " .. typeof(B._buildForDiag)) +end +if okA and okB then + log( + " clone table IS original table: " + .. tostring(rawequal(A, B)) + .. " <- if true, the clone trick does NOT reload source" + ) +end +clone:Destroy() + +return table.concat(out, "\n") diff --git a/tools/diag_steer2.luau b/tools/diag_steer2.luau new file mode 100644 index 0000000..e850738 --- /dev/null +++ b/tools/diag_steer2.luau @@ -0,0 +1,204 @@ +--[[ + diag_steer2.luau -- WHY does the steering servo not rotate? + + diag_steering showed TargetAngle -14..-26 deg with CurrentAngle stuck near + 0.3 deg. This isolates the cause by removing one load at a time: + + A. HOISTED -- body anchored, wheels in free air. No tyre load at all. + If the servo still will not turn, the fault is the + constraint itself, not grip. + B. GROUND, STATIC -- resting on the pad, no throttle. + C. GROUND, ROLLING -- coasting, no drive torque (drive is what saturates + the friction circle). + D. TORQUE SWEEP -- is 1e6 actually enough? +]] + +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) + +local pad = Instance.new("Part") +pad.Name = "DiagPad2" +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 + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) + +local function build() + local m = CarManager._buildForDiag(car, tuning) + m.Parent = workspace + m:PivotTo(CFrame.new(0, padTop + 3, 0)) + return m +end + +local function partsOf(model) + local servos, motors = {}, {} + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("HingeConstraint") then + if d.Name == Chassis.STEER_SERVO_NAME then + table.insert(servos, d) + elseif d.Name == Chassis.WHEEL_MOTOR_NAME then + table.insert(motors, d) + end + end + end + return servos, motors +end + +local LOCK = tuning.MaxSteerAngle +log(string.format("MaxSteerAngle %.1f deg | ServoMaxTorque 1e6 | AngularSpeed %.1f rad/s", LOCK, tuning.SteerSpeed)) +log("") + +-- Report the hinge axes once: a mis-set axis would make the servo yaw about +-- the wrong vector and look exactly like "no steering". +do + local m = build() + local servos = partsOf(m) + for i, s in ipairs(servos) do + local a0 = s.Attachment0 + local a1 = s.Attachment1 + log( + string.format( + "servo %d axis0 (%.2f,%.2f,%.2f) axis1 (%.2f,%.2f,%.2f) | A0 on %s, A1 on %s", + i, + a0.WorldAxis.X, + a0.WorldAxis.Y, + a0.WorldAxis.Z, + a1.WorldAxis.X, + a1.WorldAxis.Y, + a1.WorldAxis.Z, + a0.Parent.Name, + a1.Parent.Name + ) + ) + end + m:Destroy() + log("") +end + +-- A. HOISTED --------------------------------------------------------------- +do + local m = build() + local body = m.PrimaryPart + m:PivotTo(CFrame.new(0, padTop + 40, 0)) -- well clear of the pad + body.Anchored = true + local servos = partsOf(m) + for _ = 1, 30 do + RunService.Heartbeat:Wait() + end + for _, s in ipairs(servos) do + s.TargetAngle = -LOCK + end + local trace = {} + for step = 1, 120 do + if step % 20 == 0 then + table.insert(trace, string.format("%.1f", servos[1].CurrentAngle)) + end + RunService.Heartbeat:Wait() + end + log(string.format("A. HOISTED (no tyre load): target %.1f -> CurrentAngle %s", -LOCK, table.concat(trace, ", "))) + log(string.format(" final err %.2f deg", math.abs(servos[1].CurrentAngle - -LOCK))) + m:Destroy() +end + +-- B. GROUND, STATIC -------------------------------------------------------- +do + local m = build() + local servos = partsOf(m) + for _ = 1, 60 do + RunService.Heartbeat:Wait() + end + for _, s in ipairs(servos) do + s.TargetAngle = -LOCK + end + local trace = {} + for step = 1, 120 do + if step % 20 == 0 then + table.insert(trace, string.format("%.1f", servos[1].CurrentAngle)) + end + RunService.Heartbeat:Wait() + end + log(string.format("B. GROUND STATIC: target %.1f -> CurrentAngle %s", -LOCK, table.concat(trace, ", "))) + log(string.format(" final err %.2f deg", math.abs(servos[1].CurrentAngle - -LOCK))) + m:Destroy() +end + +-- C. GROUND, COASTING (no drive torque) ------------------------------------ +do + local m = build() + local body = m.PrimaryPart + local servos, motors = partsOf(m) + for _ = 1, 60 do + RunService.Heartbeat:Wait() + end + -- Push to a moderate speed, then cut drive entirely and steer. + body.AssemblyLinearVelocity = body.CFrame.LookVector * 60 + for _, mo in ipairs(motors) do + mo.MotorMaxTorque = 0 -- freewheel: friction circle fully available for cornering + mo.AngularVelocity = 0 + end + for _, s in ipairs(servos) do + s.TargetAngle = -LOCK + end + local trace, yawTrace = {}, {} + for step = 1, 150 do + if step % 30 == 0 then + table.insert(trace, string.format("%.1f", servos[1].CurrentAngle)) + table.insert(yawTrace, string.format("%.3f", body.AssemblyAngularVelocity.Y)) + end + RunService.Heartbeat:Wait() + end + log(string.format("C. GROUND COASTING @60: CurrentAngle %s", table.concat(trace, ", "))) + log(string.format(" yawRate %s", table.concat(yawTrace, ", "))) + m:Destroy() +end + +-- D. TORQUE SWEEP (ground, static) ----------------------------------------- +log("") +log("D. ServoMaxTorque sweep (ground, static, full lock, 1.5 s):") +for _, tq in ipairs({ 1e5, 1e6, 1e7, 1e8, 1e9 }) do + local m = build() + local servos = partsOf(m) + for _ = 1, 45 do + RunService.Heartbeat:Wait() + end + for _, s in ipairs(servos) do + s.ServoMaxTorque = tq + s.TargetAngle = -LOCK + end + for _ = 1, 90 do + RunService.Heartbeat:Wait() + end + log( + string.format( + " torque %10.0e -> CurrentAngle %6.1f (err %5.1f)", + tq, + servos[1].CurrentAngle, + math.abs(servos[1].CurrentAngle + LOCK) + ) + ) + m:Destroy() +end + +pad:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_steer3.luau b/tools/diag_steer3.luau new file mode 100644 index 0000000..cafbe71 --- /dev/null +++ b/tools/diag_steer3.luau @@ -0,0 +1,188 @@ +--[[ + diag_steer3.luau -- WHAT is holding the knuckle rigid? + + diag_steer2 proved the servo will not rotate even hoisted with zero tyre + load and ServoMaxTorque up to 1e9, with both hinge axes correctly +Y. + That means the knuckle is rigidly bonded to the hub by something other + than the servo. + + Decisive test: parts joined by a WELD share an AssemblyRootPart. Parts + joined by a moving CONSTRAINT do not. So if Hub_FL and Knuckle_FL report + the same AssemblyRootPart, a weld is bypassing the servo. + + Also builds an isolated 2-part servo to confirm the engine/API itself + behaves, ruling out a bad assumption about HingeConstraint. +]] + +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 + +-- 1. ISOLATED SERVO -------------------------------------------------------- +-- Two free parts, one servo, nothing else. If this turns, the API is fine +-- and the fault is in how the car is assembled. +do + local folder = Instance.new("Folder") + folder.Name = "IsolatedServoTest" + folder.Parent = workspace + + local base = Instance.new("Part") + base.Size = Vector3.new(4, 1, 4) + base.Position = Vector3.new(0, 5100, 0) + base.Anchored = true + base.Parent = folder + + local arm = Instance.new("Part") + arm.Size = Vector3.new(1, 1, 6) + arm.Position = Vector3.new(0, 5102, 0) + arm.Parent = folder + + local VERT = CFrame.Angles(0, 0, math.rad(90)) + local a0 = Instance.new("Attachment") + a0.CFrame = VERT + a0.Parent = base + local a1 = Instance.new("Attachment") + a1.CFrame = VERT + a1.Parent = arm + + local s = Instance.new("HingeConstraint") + s.Attachment0 = a0 + s.Attachment1 = a1 + s.ActuatorType = Enum.ActuatorType.Servo + s.ServoMaxTorque = 1e6 + s.AngularSpeed = 6 + s.LimitsEnabled = true + s.LowerAngle = -35 + s.UpperAngle = 35 + s.TargetAngle = -30 + s.Parent = base + + for _ = 1, 90 do + RunService.Heartbeat:Wait() + end + log( + string.format( + "1. ISOLATED servo: target -30 -> CurrentAngle %.2f (%s)", + s.CurrentAngle, + math.abs(s.CurrentAngle + 30) < 5 and "WORKS" or "BROKEN" + ) + ) + folder:Destroy() +end + +-- 2. ASSEMBLY TOPOLOGY OF A REAL CAR --------------------------------------- +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) + +local pad = Instance.new("Part") +pad.Size = Vector3.new(2000, 8, 2000) +pad.Position = Vector3.new(0, 5000, 0) +pad.Anchored = true +pad.Parent = workspace + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) +local model = CarManager._buildForDiag(car, tuning) +model.Parent = workspace +model:PivotTo(CFrame.new(0, 5007, 0)) + +for _ = 1, 60 do + RunService.Heartbeat:Wait() +end + +local function get(name) + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") and d.Name == name then + return d + end + end + return nil +end + +local body = model.PrimaryPart +local hubFL = get("Hub_FL") +local knuckleFL = get("Knuckle_FL") +local wheelFL = get("Wheel_FL") + +log("") +log("2. ASSEMBLY ROOTS (same root == rigidly welded together):") +local function rootName(p) + if not p then + return "" + end + local r = p.AssemblyRootPart + return r and r:GetFullName():gsub("^.*%.", "") or "" +end +log(" Body root:", rootName(body)) +log(" Hub_FL root:", rootName(hubFL)) +log(" Knuckle_FL root:", rootName(knuckleFL)) +log(" Wheel_FL root:", rootName(wheelFL)) +if hubFL and knuckleFL then + local same = hubFL.AssemblyRootPart == knuckleFL.AssemblyRootPart + log( + " >>> Hub and Knuckle share an assembly:", + tostring(same), + same and "<-- WELDED, SERVO BYPASSED" or "(ok, servo is free)" + ) +end + +-- 3. EVERY joint touching those parts -------------------------------------- +log("") +log("3. ALL joints touching Hub_FL / Knuckle_FL / Wheel_FL:") +local targets = { [hubFL] = "Hub_FL", [knuckleFL] = "Knuckle_FL", [wheelFL] = "Wheel_FL" } +for _, d in ipairs(model:GetDescendants()) do + local p0, p1, kind + if d:IsA("WeldConstraint") then + p0, p1, kind = d.Part0, d.Part1, "WeldConstraint" + elseif d:IsA("Weld") or d:IsA("Motor6D") then + p0, p1, kind = d.Part0, d.Part1, d.ClassName + elseif d:IsA("Constraint") then + local a0, a1 = d.Attachment0, d.Attachment1 + p0 = a0 and a0.Parent or nil + p1 = a1 and a1.Parent or nil + kind = d.ClassName .. "/" .. d.Name + end + if p0 and p1 and (targets[p0] or targets[p1]) then + log(string.format(" %-28s %s <-> %s", kind, p0.Name, p1.Name)) + end +end + +-- 4. Servo runtime state --------------------------------------------------- +log("") +log("4. SERVO RUNTIME STATE:") +for _, d in ipairs(model:GetDescendants()) do + if d:IsA("HingeConstraint") and d.Name == Chassis.STEER_SERVO_NAME then + log( + string.format( + " %s Enabled=%s Active=%s Actuator=%s Torque=%.0e Speed=%.1f Limits=%s [%.1f..%.1f] Target=%.1f Current=%.2f", + d.Parent.Name, + tostring(d.Enabled), + tostring(d.Active), + d.ActuatorType.Name, + d.ServoMaxTorque, + d.AngularSpeed, + tostring(d.LimitsEnabled), + d.LowerAngle, + d.UpperAngle, + d.TargetAngle, + d.CurrentAngle + ) + ) + end +end + +model:Destroy() +pad:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_steer4.luau b/tools/diag_steer4.luau new file mode 100644 index 0000000..16863dc --- /dev/null +++ b/tools/diag_steer4.luau @@ -0,0 +1,153 @@ +--[[ + diag_steer4.luau -- isolate the real constraint on steering. + + Prior runs had a CONFOUND: every hoisted/static test commanded exactly + -MaxSteerAngle, which is exactly the servo's LowerAngle limit. Sitting a + servo precisely on its own limit can deadlock it. The isolated 2-part + servo (target -30, limits +/-35) worked perfectly, so the API is fine. + + This matrix commands -20 deg (well INSIDE the +/-35.08 limits) and varies + one thing at a time. +]] + +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) + +local pad = Instance.new("Part") +pad.Size = Vector3.new(3000, 8, 3000) +pad.Position = Vector3.new(0, 5000, 0) +pad.Anchored = true +pad.Material = Enum.Material.Concrete +pad.Parent = workspace +local padTop = pad.Position.Y + 4 + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) +local LIMIT = tuning.MaxSteerAngle +local INSIDE = -20 -- comfortably inside +/-35.08 + +local function build(y) + local m = CarManager._buildForDiag(car, tuning) + m.Parent = workspace + m:PivotTo(CFrame.new(0, y, 0)) + return m +end + +local function servosOf(model) + local servos, motors = {}, {} + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("HingeConstraint") then + if d.Name == Chassis.STEER_SERVO_NAME then + table.insert(servos, d) + elseif d.Name == Chassis.WHEEL_MOTOR_NAME then + table.insert(motors, d) + end + end + end + return servos, motors +end + +-- Run one case: returns the CurrentAngle trace. +local function runCase(label, opts) + local m = build(opts.hoist and (padTop + 40) or (padTop + 3)) + local body = m.PrimaryPart + local servos, motors = servosOf(m) + + if opts.hoist then + body.Anchored = true + end + if opts.noWheel then + for _, d in ipairs(m:GetDescendants()) do + if d:IsA("BasePart") and d.Name:sub(1, 6) == "Wheel_" then + d:Destroy() + end + end + end + if opts.motorOff then + for _, mo in ipairs(motors) do + mo.Enabled = false + end + end + if opts.noLimits then + for _, s in ipairs(servos) do + s.LimitsEnabled = false + end + end + + for _ = 1, 60 do + RunService.Heartbeat:Wait() + end + + if opts.speed then + body.AssemblyLinearVelocity = body.CFrame.LookVector * opts.speed + for _, mo in ipairs(motors) do + mo.MotorMaxTorque = 0 + end + end + + for _, s in ipairs(servos) do + s.TargetAngle = opts.target + end + + local trace = {} + for step = 1, 120 do + if opts.speed then + for _, s in ipairs(servos) do + s.TargetAngle = opts.target + end + end + if step % 24 == 0 then + trace[#trace + 1] = string.format("%.1f", servos[1].CurrentAngle) + end + RunService.Heartbeat:Wait() + end + local final = servos[1].CurrentAngle + local ok = math.abs(final - opts.target) < 5 + log( + string.format( + "%-42s target %6.1f -> [%s] final %6.2f %s", + label, + opts.target, + table.concat(trace, " "), + final, + ok and "*** TURNS ***" or "stuck" + ) + ) + m:Destroy() +end + +log( + string.format("limits +/-%.2f deg | INSIDE target %.1f | AngularSpeed %.1f rad/s", LIMIT, INSIDE, tuning.SteerSpeed) +) +log("") + +runCase("A1 hoisted, target INSIDE limits", { hoist = true, target = INSIDE }) +runCase("A2 hoisted, target AT limit", { hoist = true, target = -LIMIT }) +runCase("A3 hoisted, LimitsEnabled=false", { hoist = true, target = INSIDE, noLimits = true }) +runCase("A4 hoisted, wheels removed", { hoist = true, target = INSIDE, noWheel = true }) +runCase("A5 hoisted, wheel motors disabled", { hoist = true, target = INSIDE, motorOff = true }) +log("") +runCase("B1 ground static, target INSIDE", { target = INSIDE }) +runCase("B2 ground static, motors disabled", { target = INSIDE, motorOff = true }) +log("") +runCase("C1 ground coasting @60, INSIDE", { target = INSIDE, speed = 60 }) +runCase("C2 ground coasting @120, INSIDE", { target = INSIDE, speed = 120 }) + +pad:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_steer5.luau b/tools/diag_steer5.luau new file mode 100644 index 0000000..64fe82c --- /dev/null +++ b/tools/diag_steer5.luau @@ -0,0 +1,179 @@ +--[[ + diag_steer5.luau -- find a FIX for the servo/motor conflict. + + Established by diag_steer4: an ENABLED WheelMotor hinge locks the knuckle + so the steer servo cannot yaw it. Disabling the motor (or deleting the + wheel) restores perfect tracking (-19.99 of -20.0). MotorMaxTorque is 0 at + build time, so this is NOT a torque contest -- it is a degenerate + constraint configuration. + + Suspicion: Hub, Knuckle and Wheel are all built at the SAME CFrame, and + the motor's two attachments are both identity CFrames, so every attachment + sits at the same world point. Two hinges sharing a point with perpendicular + axes is ill-conditioned for the solver. + + Each variant below changes ONE thing and re-measures. +]] + +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) + +local pad = Instance.new("Part") +pad.Size = Vector3.new(3000, 8, 3000) +pad.Position = Vector3.new(0, 5000, 0) +pad.Anchored = true +pad.Material = Enum.Material.Concrete +pad.Parent = workspace +local padTop = pad.Position.Y + 4 + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) +local TARGET = -20 + +local function parts(model) + local servos, motors, wheels, knuckles = {}, {}, {}, {} + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("HingeConstraint") then + if d.Name == Chassis.STEER_SERVO_NAME then + table.insert(servos, d) + elseif d.Name == Chassis.WHEEL_MOTOR_NAME then + table.insert(motors, d) + end + elseif d:IsA("BasePart") then + if d.Name:sub(1, 6) == "Wheel_" then + table.insert(wheels, d) + elseif d.Name:sub(1, 8) == "Knuckle_" then + table.insert(knuckles, d) + end + end + end + return servos, motors, wheels, knuckles +end + +-- mutate: applied after build, before settling. drive: applied after settling. +local function variant(label, mutate, drive) + local m = CarManager._buildForDiag(car, tuning) + m.Parent = workspace + m:PivotTo(CFrame.new(0, padTop + 3, 0)) + local servos, motors, wheels, knuckles = parts(m) + if mutate then + mutate(servos, motors, wheels, knuckles, m) + end + for _ = 1, 60 do + RunService.Heartbeat:Wait() + end + local body = m.PrimaryPart + body.AssemblyLinearVelocity = body.CFrame.LookVector * 80 + if drive then + drive(servos, motors, wheels, knuckles, m) + end + for _, s in ipairs(servos) do + s.TargetAngle = TARGET + end + local trace, yaw = {}, {} + for step = 1, 120 do + for _, s in ipairs(servos) do + s.TargetAngle = TARGET + end + if step % 24 == 0 then + trace[#trace + 1] = string.format("%.1f", servos[1].CurrentAngle) + yaw[#yaw + 1] = string.format("%.2f", body.AssemblyAngularVelocity.Y) + end + RunService.Heartbeat:Wait() + end + local final = servos[1].CurrentAngle + local ok = math.abs(final - TARGET) < 5 + log( + string.format( + "%-40s [%s] final %6.2f yaw[%s] %s", + label, + table.concat(trace, " "), + final, + table.concat(yaw, " "), + ok and "*** STEERS ***" or "stuck" + ) + ) + m:Destroy() +end + +log(string.format("target %.1f deg, rolling at 80 studs/s, motors at build-default torque", TARGET)) +log("") + +variant("V0 baseline (unmodified)", nil, nil) + +variant("V1 motor ActuatorType = None", function(_, motors) + for _, mo in ipairs(motors) do + mo.ActuatorType = Enum.ActuatorType.None + end +end) + +variant("V2 motor attachments offset 0.01 X", function(_, motors) + for _, mo in ipairs(motors) do + mo.Attachment0.CFrame = mo.Attachment0.CFrame * CFrame.new(0.01, 0, 0) + end +end) + +variant("V3 knuckle mass x20 (density)", function(_, _, _, knuckles) + for _, k in ipairs(knuckles) do + k.CustomPhysicalProperties = PhysicalProperties.new(20, 0.3, 0.5, 1, 1) + end +end) + +variant("V4 wheel Massless=true", function(_, _, wheels) + for _, w in ipairs(wheels) do + w.Massless = true + end +end) + +variant("V5 servo torque 1e9 + speed 30", function(servos) + for _, s in ipairs(servos) do + s.ServoMaxTorque = 1e9 + s.AngularSpeed = 30 + end +end) + +variant("V6 motor torque 0 held every frame", nil, function(_, motors) + for _, mo in ipairs(motors) do + mo.MotorMaxTorque = 0 + mo.AngularVelocity = 0 + end +end) + +variant("V7 front motors disabled only", function(_, motors) + -- rear-wheel drive: only the steered corners freewheel + for _, mo in ipairs(motors) do + local n = mo.Parent and mo.Parent.Name or "" + if n:find("F") then + mo.Enabled = false + end + end +end) + +variant("V8 V1 + V5 combined", function(servos, motors) + for _, mo in ipairs(motors) do + mo.ActuatorType = Enum.ActuatorType.None + end + for _, s in ipairs(servos) do + s.ServoMaxTorque = 1e9 + s.AngularSpeed = 30 + end +end) + +pad:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_steering.luau b/tools/diag_steering.luau new file mode 100644 index 0000000..433f22c --- /dev/null +++ b/tools/diag_steering.luau @@ -0,0 +1,203 @@ +--[[ + 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") diff --git a/tools/diag_wallmap.luau b/tools/diag_wallmap.luau new file mode 100644 index 0000000..99464f1 --- /dev/null +++ b/tools/diag_wallmap.luau @@ -0,0 +1,92 @@ +--[[ + diag_wallmap.luau -- export every wall/barrier part near the PA as JSON so + it can be plotted top-down on the workstation. + + Screen capture of the Studio viewport is unavailable on this machine + (Vinegar/Wine on a nested X display, DXVK renders the viewport so XGetImage + returns black), so instead of a screenshot we dump geometry and draw the + map ourselves. For "are there gaps / is it jumbled" questions a top-down + plot is strictly better than a screenshot anyway: it is to scale and every + part is labelled. + + Read-only. Builds nothing, changes nothing. +]] + +local Workspace = game:GetService("Workspace") + +local WALL_NAMES = { + Barrier = true, + EdgeRail = true, + KillWall = true, + PARingWall = true, + PARingRail = true, + Median = true, + MedianFence = true, +} + +local function classify(name) + if WALL_NAMES[name] then + return name + end + -- Median_12 / Road_37 style suffixed names + local base = name:match("^([%a]+)_%d+$") + if base and WALL_NAMES[base] then + return base + end + if name:find("^Road") or name:find("^PARing$") or name == "PARing" then + return "Road" + end + if name == "PAOnRamp" or name == "PAOffRamp" or name == "PARampFlare" then + return "Ramp" + end + if name == "PAApron" or name:find("Apron") then + return "Apron" + end + return nil +end + +local out = {} +local counts = {} + +-- The bridge truncates payloads at ~100k chars, so only export parts near the +-- PA -- that is the region under investigation anyway. Counts are still taken +-- over the WHOLE map so nothing is hidden by the filter. +local PA = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared"):WaitForChild("Circuit")).PA_CENTER +local NEAR = 900 + +for _, d in ipairs(Workspace:GetDescendants()) do + if d:IsA("BasePart") then + local kind = classify(d.Name) + if kind then + counts[kind] = (counts[kind] or 0) + 1 + local p = d.Position + local dist = math.sqrt((p.X - PA.X) ^ 2 + (p.Z - PA.Z) ^ 2) + if dist <= NEAR then + table.insert(out, { + k = kind, + x = math.floor(p.X * 10) / 10, + y = math.floor(p.Y * 10) / 10, + z = math.floor(p.Z * 10) / 10, + sx = math.floor(d.Size.X * 10) / 10, + sz = math.floor(d.Size.Z * 10) / 10, + r = math.floor(math.deg(select(2, d.CFrame:ToEulerAnglesYXZ())) * 10) / 10, + }) + end + end + end +end + +local summary = {} +for k, v in pairs(counts) do + table.insert(summary, string.format("%s=%d", k, v)) +end +table.sort(summary) + +-- execute_luau returns the RETURN VALUE, not stdout -- prints are discarded by +-- the bridge, so hand the payload back directly. +local HttpService = game:GetService("HttpService") +return HttpService:JSONEncode({ + counts = table.concat(summary, " "), + total = #out, + parts = out, +}) diff --git a/tools/diag_whatami.luau b/tools/diag_whatami.luau new file mode 100644 index 0000000..5b7be07 --- /dev/null +++ b/tools/diag_whatami.luau @@ -0,0 +1,139 @@ +--[[ + diag_whatami.luau -- answer three questions the user's report raises, with + no assumptions: + + Q1. Is the module in THIS DataModel the source I edited on disk? + (compare length + marker strings; disk file is 41650 bytes and + contains SHELL_YAW_OVERRIDE and bestVol) + + Q2. Is the require() cache still pinned to pre-push code? + (_buildForDiag present in source but nil after require == pinned, + meaning NO fix can be in effect yet this session) + + Q3. Are the cars the user is looking at STALE STAGED PROPS? + workspace.ShellReview was populated by tools/shot_shells.luau in an + earlier run. Those models are a frozen snapshot -- they are inert + Instances, not live rebuilds, so they will look identical forever + regardless of what CarManager says. Report whether ShellReview + exists, what is in it, and how each car is oriented RIGHT NOW so we + can tell "stale props" from "fix genuinely not working". + + For Q3 the facing is read from the staged model directly: compare the mean + Z of the F* wheel groups against the R* groups in the model's own frame. + Front should sit at NEGATIVE Z (chassis convention: front axle built at + Z = -WHEELBASE/2). If front is at +Z the car is backwards. +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +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 + +-- ------------------------------------------------------------------ Q1 + Q2 +log("=== Q1: is the live module my edited source? ===") +local mods = {} +for _, d in ipairs(ServerScriptService:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + mods[#mods + 1] = d + end +end +log(string.format(" instances: %d", #mods)) +for _, m in ipairs(mods) do + local src = "" + pcall(function() + src = m.Source + end) + log( + string.format( + " %s len=%d (disk 41650) SHELL_YAW_OVERRIDE=%s bestVol=%s", + m:GetFullName(), + #src, + tostring(src:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil), + tostring(src:find("bestVol", 1, true) ~= nil) + ) + ) +end + +log("") +log("=== Q2: is require() still pinned to old code? ===") +if #mods == 1 then + local ok, mod = pcall(require, mods[1]) + if ok and typeof(mod) == "table" then + local fresh = typeof(mod._buildForDiag) == "function" + log(" _buildForDiag after require: " .. typeof(mod._buildForDiag)) + log( + fresh and " => NEW code is live; fixes CAN take effect" + or " => STILL PINNED to pre-push code; NO fix can be in effect yet" + ) + end +end + +-- ---------------------------------------------------------------------- Q3 +log("") +log("=== Q3: what is actually sitting in workspace.ShellReview? ===") +local review = workspace:FindFirstChild("ShellReview") +if not review then + log(" ShellReview does NOT exist -- user must be looking at something else") + log(" (spawned cars in-game, or the templates in ReplicatedStorage)") +else + log(" ShellReview EXISTS with " .. #review:GetChildren() .. " children.") + log(" These are FROZEN PROPS from an earlier shot_shells.luau run.") + log(" They do not rebuild themselves -- code fixes cannot change them.") + log("") + log(string.format(" %-14s %10s %10s %s", "model", "frontZ", "rearZ", "verdict")) + for _, m in ipairs(review:GetChildren()) do + if m:IsA("Model") then + local shell = m:FindFirstChild("Shell") + local wg = shell and shell:FindFirstChild("Wheels") + if not wg then + local f = m:FindFirstChild("Wheels", true) + wg = f + end + if wg then + local pivot = m:GetPivot() + local inv = pivot:Inverse() + local fS, fN, rS, rN = 0, 0, 0, 0 + for _, w in ipairs(wg:GetChildren()) do + local sum, n = Vector3.zero, 0 + if w:IsA("BasePart") then + sum, n = w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + sum, n = sum + d.Position, n + 1 + end + end + if n > 0 then + local z = (inv * (sum / n)).Z + local head = w.Name:sub(1, 1):upper() + if head == "F" then + fS, fN = fS + z, fN + 1 + elseif head == "R" then + rS, rN = rS + z, rN + 1 + end + end + end + if fN > 0 and rN > 0 then + local fz, rz = fS / fN, rS / rN + log( + string.format( + " %-14s %10.3f %10.3f %s", + m.Name, + fz, + rz, + fz < rz and "front at -Z OK" or "front at +Z BACKWARDS" + ) + ) + end + end + end + end +end + +return table.concat(out, "\n") diff --git a/tools/diag_wheelroot.luau b/tools/diag_wheelroot.luau new file mode 100644 index 0000000..12ea597 --- /dev/null +++ b/tools/diag_wheelroot.luau @@ -0,0 +1,167 @@ +--[[ + diag_wheelroot.luau -- two suspected bugs in the shell wheel re-hang + (CarManager ~line 585-641). + + (1) ROOT CHOICE. Line 589 picks + root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + For a wheel group that is a Model, FindFirstChildWhichIsA returns an + ARBITRARY first BasePart -- a rotor, caliper or hub, not necessarily + the tyre. Line 621 then does delta = phys.Position - root.Position and + shifts the whole group, making THAT part concentric with the axle. If + root is not the tyre centre, the visible tyre is displaced by the + root-to-tyre offset. User reports rx7_fc wheels "sticking out from the + side of the car", which is this signature. + + (2) SURVIVING OUTSIDE WELDS. Line 605 only scans shell:GetDescendants() + for welds to cut. Any weld parented OUTSIDE the shell (on the model, + on body, etc.) that ties a wheel part to the bodywork survives, which + would rigidly glue the wheel to the chassis and pin the knuckle -- + the measured steering lock. + + Reports, per wheel group: which part was chosen as root, how far that root + sits from the group's true centre, and any surviving weld from the group + to a part outside it. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local cm = orig:Clone() +cm.Parent = orig.Parent +local CarManager = require(cm) + +-- ---------------------------------------------------------------- template +log("=== TEMPLATE wheel groups: what does line 589 pick as root? ===") +log(string.format("%-12s %-5s %-16s %8s %8s", "car", "grp", "root picked", "off|X|", "nParts")) +for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local wg = t:FindFirstChild("Wheels") + if wg then + for _, w in ipairs(wg:GetChildren()) do + local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + -- true centre of the group = AABB centre of all its parts + local lo = Vector3.new(math.huge, math.huge, math.huge) + local hi = -lo + local n = 0 + if w:IsA("BasePart") then + lo, hi, n = w.Position, w.Position, 1 + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + lo = Vector3.new( + math.min(lo.X, d.Position.X), + math.min(lo.Y, d.Position.Y), + math.min(lo.Z, d.Position.Z) + ) + hi = Vector3.new( + math.max(hi.X, d.Position.X), + math.max(hi.Y, d.Position.Y), + math.max(hi.Z, d.Position.Z) + ) + n += 1 + end + end + if root and n > 0 then + local centre = (lo + hi) / 2 + local off = root.Position - centre + log(string.format("%-12s %-5s %-16s %8.3f %8d", t.Name, w.Name, root.Name, math.abs(off.X), n)) + end + end + end +end + +-- ---------------------------------------------------------------- built +log("") +log("=== BUILT cars: tyre alignment + surviving outside welds ===") +log("tyreErrX = how far the widest shell wheel part sits from its physics") +log("cylinder along the car's lateral axis. Should be ~0 on every corner.") +log("") +log(string.format("%-12s %-5s %9s %s", "car", "grp", "tyreErrX", "welds tying group to OUTSIDE")) + +for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local car = Cars.Get(t.Name) + if car then + local m = CarManager._buildForDiag(car, Chassis.tuningFor(car)) + m.Parent = workspace + m:PivotTo(CFrame.new(0, 5010, 0)) + for _ = 1, 4 do + RunService.Heartbeat:Wait() + end + local body = m.PrimaryPart + local shell = m:FindFirstChild("Shell") + local wg = shell and shell:FindFirstChild("Wheels") + if wg then + local inv = body.CFrame:Inverse() + for _, w in ipairs(wg:GetChildren()) do + local phys = m:FindFirstChild("Wheel_" .. w.Name:upper()) + local group, inGroup = {}, {} + if w:IsA("BasePart") then + group[1], inGroup[w] = w, true + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + group[#group + 1] = d + inGroup[d] = true + end + end + if phys and #group > 0 then + -- widest part = the tyre; compare its |X| to the cylinder's + local physX = (inv * phys.Position).X + local best, bestW = nil, -1 + for _, p in ipairs(group) do + local vol = p.Size.X * p.Size.Y * p.Size.Z + if vol > bestW then + best, bestW = p, vol + end + end + local errX = (inv * best.Position).X - physX + + -- any weld from this group to something outside it, + -- searched across the WHOLE model, not just the shell + local bad = {} + for _, c in ipairs(m:GetDescendants()) do + if c:IsA("WeldConstraint") or c:IsA("Weld") then + local a, b = c.Part0, c.Part1 + local aIn = a ~= nil and inGroup[a] or false + local bIn = b ~= nil and inGroup[b] or false + if (aIn or bIn) and not (aIn and bIn) then + local other = aIn and b or a + local oname = other and other.Name or "nil" + -- weld to its own physics cylinder is CORRECT + if oname ~= ("Wheel_" .. w.Name:upper()) then + bad[#bad + 1] = oname .. "(" .. c.Parent.Name .. ")" + end + end + end + end + log( + string.format( + "%-12s %-5s %9.3f %s", + t.Name, + w.Name, + errX, + #bad > 0 and table.concat(bad, ", ") or "-none-" + ) + ) + end + end + end + m:Destroy() + end +end + +cm:Destroy() +return table.concat(out, "\n") diff --git a/tools/diag_wheelroot2.luau b/tools/diag_wheelroot2.luau new file mode 100644 index 0000000..7a55993 --- /dev/null +++ b/tools/diag_wheelroot2.luau @@ -0,0 +1,184 @@ +--[[ + diag_wheelroot2.luau -- same investigation as diag_wheelroot, but wrapped + so a failure reports WHERE. The first attempt died with a bare "attempt to + call a nil value" and no line, which tells us nothing; both _buildForDiag + (CarManager:988) and Chassis.tuningFor (Chassis:158) are confirmed to + exist, so the nil is something else in the script. + + Runs each stage under pcall + traceback and prints progress markers, so + the last marker printed localises the fault even if the traceback is + swallowed by the executor. +]] + +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 function stage(name, fn) + local ok, err = xpcall(fn, function(e) + return tostring(e) .. "\n" .. debug.traceback("", 2) + end) + if not ok then + log("!! FAILED at stage: " .. name) + log(err) + return false + end + log(" ok: " .. name) + return true +end + +local Shared, Cars, Chassis, CarManager, cm + +stage("require shared", function() + Shared = ReplicatedStorage:FindFirstChild("Shared") + Cars = require(Shared.Cars) + Chassis = require(Shared.Chassis) +end) + +stage("clone+require CarManager", function() + local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") + cm = orig:Clone() + cm.Parent = orig.Parent + CarManager = require(cm) + assert(typeof(CarManager._buildForDiag) == "function", "_buildForDiag is " .. typeof(CarManager._buildForDiag)) + assert(typeof(Chassis.tuningFor) == "function", "tuningFor is " .. typeof(Chassis.tuningFor)) + assert(typeof(Cars.Get) == "function", "Cars.Get is " .. typeof(Cars.Get)) +end) + +-- ------------------------------------------------- stage 1: template roots +stage("template root pick", function() + log("") + log("=== TEMPLATE: what does line 589 pick as root? ===") + log(string.format("%-12s %-5s %-18s %8s %7s", "car", "grp", "root picked", "offX", "nParts")) + for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local wg = t:FindFirstChild("Wheels") + if wg then + for _, w in ipairs(wg:GetChildren()) do + local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true) + local parts = {} + if w:IsA("BasePart") then + parts[1] = w + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + parts[#parts + 1] = d + end + end + if root and #parts > 0 then + local lo, hi = parts[1].Position, parts[1].Position + for _, p in ipairs(parts) do + lo = Vector3.new( + math.min(lo.X, p.Position.X), + math.min(lo.Y, p.Position.Y), + math.min(lo.Z, p.Position.Z) + ) + hi = Vector3.new( + math.max(hi.X, p.Position.X), + math.max(hi.Y, p.Position.Y), + math.max(hi.Z, p.Position.Z) + ) + end + local centre = (lo + hi) / 2 + log( + string.format( + "%-12s %-5s %-18s %8.3f %7d", + t.Name, + w.Name, + root.Name, + math.abs(root.Position.X - centre.X), + #parts + ) + ) + end + end + end + end +end) + +-- ------------------------------------------------- stage 2: built cars +stage("built car check", function() + log("") + log("=== BUILT: tyre offset from its physics cylinder + stray welds ===") + log(string.format("%-12s %-5s %9s %s", "car", "grp", "tyreErrX", "welds to OUTSIDE the group")) + for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + local car = Cars.Get(t.Name) + if car then + local m = CarManager._buildForDiag(car, Chassis.tuningFor(car)) + m.Parent = workspace + m:PivotTo(CFrame.new(0, 5010, 0)) + for _ = 1, 4 do + RunService.Heartbeat:Wait() + end + local body = m.PrimaryPart + local shell = m:FindFirstChild("Shell") + local wg = shell and shell:FindFirstChild("Wheels") + if wg and body then + local inv = body.CFrame:Inverse() + for _, w in ipairs(wg:GetChildren()) do + local physName = "Wheel_" .. w.Name:upper() + local phys = m:FindFirstChild(physName) + local group, inGroup = {}, {} + if w:IsA("BasePart") then + group[1], inGroup[w] = w, true + end + for _, d in ipairs(w:GetDescendants()) do + if d:IsA("BasePart") then + group[#group + 1] = d + inGroup[d] = true + end + end + if phys and #group > 0 then + local physX = (inv * phys.Position).X + local best, bestV = group[1], -1 + for _, p in ipairs(group) do + local v = p.Size.X * p.Size.Y * p.Size.Z + if v > bestV then + best, bestV = p, v + end + end + local errX = (inv * best.Position).X - physX + local bad = {} + for _, c in ipairs(m:GetDescendants()) do + if c:IsA("WeldConstraint") or c:IsA("Weld") then + local a, b = c.Part0, c.Part1 + local aIn = (a ~= nil) and inGroup[a] or false + local bIn = (b ~= nil) and inGroup[b] or false + if (aIn or bIn) and not (aIn and bIn) then + local other = aIn and b or a + local oname = other and other.Name or "nil" + if oname ~= physName then + bad[#bad + 1] = oname + end + end + end + end + log( + string.format( + "%-12s %-5s %9.3f %s", + t.Name, + w.Name, + errX, + #bad > 0 and table.concat(bad, ", ") or "-none-" + ) + ) + end + end + end + m:Destroy() + end + end +end) + +if cm then + cm:Destroy() +end +return table.concat(out, "\n") diff --git a/tools/fix_dupes.luau b/tools/fix_dupes.luau new file mode 100644 index 0000000..dc6bb51 --- /dev/null +++ b/tools/fix_dupes.luau @@ -0,0 +1,92 @@ +--[[ + fix_dupes.luau -- remove leftover CarManager clones and report the truth. + + diag_stale found TWO ModuleScripts at ServerScriptService.Server.CarManager, + both len=30631 with no _buildForDiag, while the file on disk is 39591 bytes + and defines it. My diagnostic scripts cloned the module and did not always + destroy the clone, so duplicates accumulated; require() may bind to either. + + Deletes every CarManager except the one that is a genuine child of the + Server folder (keeping the first by index), then reports what remains so + the next push has a single unambiguous target. +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +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 server = ServerScriptService:FindFirstChild("Server", true) +if not server then + return "no Server folder" +end + +local mods = {} +for _, d in ipairs(ServerScriptService:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + mods[#mods + 1] = d + end +end + +log(string.format("found %d CarManager module(s)", #mods)) +for i, m in ipairs(mods) do + local len = "?" + pcall(function() + len = tostring(#m.Source) + end) + log( + string.format( + " [%d] %s len=%s parent=%s", + i, + m:GetFullName(), + len, + m.Parent and m.Parent:GetFullName() or "nil" + ) + ) +end + +-- Keep exactly one: prefer a direct child of the Server folder. +local keep = nil +for _, m in ipairs(mods) do + if m.Parent == server then + keep = m + break + end +end +keep = keep or mods[1] + +local removed = 0 +for _, m in ipairs(mods) do + if m ~= keep then + m:Destroy() + removed += 1 + end +end + +log("") +log(string.format("kept: %s", keep and keep:GetFullName() or "nil")) +log(string.format("removed: %d duplicate(s)", removed)) + +-- Confirm the survivor +local after = 0 +for _, d in ipairs(ServerScriptService:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + after += 1 + end +end +log(string.format("remaining CarManager count: %d", after)) +if keep then + local len = "?" + pcall(function() + len = tostring(#keep.Source) + end) + log("survivor source length: " .. len .. " (disk file is 39591)") +end + +return table.concat(out, "\n") diff --git a/tools/plot_daikoku.py b/tools/plot_daikoku.py new file mode 100644 index 0000000..95eb564 --- /dev/null +++ b/tools/plot_daikoku.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Render the Daikoku PA junction from Circuit.luau geometry. + +MEASURE FIRST: code reasoning about elevation misleads (see project notes). +This parses the ACTUAL baked samples + the lift profile out of Circuit.luau +and draws what the builder will produce, so it can be compared against the +real aerial photographs before pushing to Studio. + +Outputs a 3-panel figure: + 1. top-down -- mainline vs PA ring footprint (shows the overlap) + 2. elevation -- deck height through the junction (shows the clearance) + 3. section -- cross-section at the PA, mainline flying over the apron +""" +import math +import re + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.patches import Circle, Rectangle + +SRC = "/home/jesse/roblox/tokyo-wangan-racing/src/shared/Circuit.luau" +OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/daikoku_check.png" + +src = open(SRC).read() +PA_C = tuple( + float(x) + for x in re.search( + r"PA_CENTER = Vector3\.new\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)", src + ).groups() +) +PA_R = float(re.search(r"PA_RADIUS\s*=\s*([\d.]+)", src).group(1)) +LIFT = float(re.search(r"PA_LIFT\s*=\s*(\d+)", src).group(1)) +FLAT = int(re.search(r"LIFT_FLAT\s*=\s*(\d+)", src).group(1)) +RAMPN = int(re.search(r"LIFT_RAMP\s*=\s*(\d+)", src).group(1)) + +raw = re.findall( + r'\{\s*(-?[\d.]+),\s*(-?[\d.]+),\s*(-?[\d.]+),\s*(\d),\s*(\d),\s*"([^"]+)"\s*\}', src +) +S = [ + {"x": float(a), "y": float(b), "z": float(c), "tun": int(d) == 1} + for a, b, c, d, e, f in raw +] +n = len(S) + +best, bd = 0, 1e18 +for i, s in enumerate(S): + d = math.hypot(s["x"] - PA_C[0], s["z"] - PA_C[2]) + if not s["tun"] and d < bd: + best, bd = i, d + + +def circd(i, j): + d = abs(i - j) + return min(d, n - d) + + +def lift_t(i): + d = circd(i, best) + if d <= FLAT: + return 1.0 + if d >= FLAT + RAMPN: + return 0.0 + u = (d - FLAT) / RAMPN + return 1 - (u * u * (3 - 2 * u)) + + +for i, s in enumerate(S): + s["ly"] = s["y"] + LIFT * lift_t(i) + +RING_R = PA_R + 130 +RING_W = 44 +ringY = PA_C[1] +entry, exitI = (best - 14) % n, (best + 14) % n + +fig = plt.figure(figsize=(17, 6.2)) +fig.suptitle( + "Daikoku PA junction — rebuilt from reference photos (expressway flies OVER the apron)", + fontsize=13, + fontweight="bold", +) + +# ---- 1. TOP-DOWN ----------------------------------------------------------- +ax = fig.add_subplot(1, 3, 1) +ax.set_title("Top-down: footprints overlap by design", fontsize=10) +win = [i for i in range(n) if circd(i, best) <= 55] +win.sort(key=lambda i: circd(i, best)) +seq = sorted(win, key=lambda i: (i - best) % n if (i - best) % n < n / 2 else (i - best) % n - n) +ax.plot([S[i]["x"] for i in seq], [S[i]["z"] for i in seq], "-", color="#333", lw=7, + solid_capstyle="round", label="mainline deck (elevated)") +ax.add_patch(Rectangle((PA_C[0] - PA_R, PA_C[2] - PA_R), PA_R * 2, PA_R * 2, + fc="#9a9a9a", ec="#555", lw=1, alpha=.55, label="parking apron (ground)")) +ax.add_patch(Circle((PA_C[0], PA_C[2]), RING_R + RING_W / 2, fill=False, ec="#c44", lw=2)) +ax.add_patch(Circle((PA_C[0], PA_C[2]), RING_R - RING_W / 2, fill=False, ec="#c44", lw=2, + label="PA ring road (ground)")) +for nm, idx, col in (("entry", entry, "#1a7"), ("exit", exitI, "#17a")): + r = S[idx] + tox, toz = PA_C[0] - r["x"], PA_C[2] - r["z"] + m = math.hypot(tox, toz) + rp = (PA_C[0] - tox / m * RING_R, PA_C[2] - toz / m * RING_R) + ax.plot([r["x"], rp[0]], [r["z"], rp[1]], color=col, lw=5, solid_capstyle="round", + label=f"{nm} ramp (graded)") +ax.set_aspect("equal") +ax.legend(fontsize=7, loc="upper right") +ax.set_xlabel("X (studs)") +ax.set_ylabel("Z (studs)") +ax.grid(alpha=.25) + +# ---- 2. ELEVATION PROFILE -------------------------------------------------- +ax2 = fig.add_subplot(1, 3, 2) +ax2.set_title("Elevation through junction: 38-stud clearance", fontsize=10) +rng = [(best + k) % n for k in range(-60, 61)] +xs = list(range(-60, 61)) +ax2.plot(xs, [S[i]["y"] for i in rng], "--", color="#b00", lw=1.6, + label="BEFORE — deck at apron level (wall in road)") +ax2.plot(xs, [S[i]["ly"] for i in rng], "-", color="#0a0", lw=2.4, label="AFTER — lifted deck") +ax2.axhline(ringY, color="#777", lw=1.4, ls=":", label="PA ring / apron level") +ax2.fill_between(xs, [S[i]["ly"] for i in rng], ringY, color="#0a0", alpha=.10) +for idx, lb, col in ((entry - best, "entry", "#1a7"), (exitI - best, "exit", "#17a")): + ax2.axvline(idx, color=col, lw=1.3, ls="-.") + ax2.text(idx, ringY - 7, lb, ha="center", fontsize=7, color=col) +ax2.annotate("", xy=(0, ringY), xytext=(0, ringY + LIFT), + arrowprops=dict(arrowstyle="<->", color="#333", lw=1.4)) +ax2.text(2.5, ringY + LIFT / 2, f"{LIFT:.0f} studs\nclearance", fontsize=8, va="center") +ax2.set_xlabel("sample offset from PA centre") +ax2.set_ylabel("deck height Y (studs)") +ax2.legend(fontsize=7, loc="lower left") +ax2.grid(alpha=.25) + +# ---- 3. CROSS-SECTION ------------------------------------------------------ +ax3 = fig.add_subplot(1, 3, 3) +ax3.set_title("Section at PA — matches reference photos", fontsize=10) +ax3.add_patch(Rectangle((-PA_R, ringY - 3), PA_R * 2, 3, fc="#9a9a9a", ec="#555")) +ax3.text(0, ringY - 9, "parking apron (ground level)", ha="center", fontsize=8) +for sgn in (-1, 1): + ax3.add_patch(Rectangle((sgn * RING_R - RING_W / 2, ringY - 3), RING_W, 4, + fc="#c44", ec="#822")) +ax3.text(RING_R, ringY + 6, "ring road", ha="center", fontsize=7, color="#822") +deckY = ringY + LIFT +ax3.add_patch(Rectangle((-bd - 60, deckY), 120, 5, fc="#333", ec="#000")) +ax3.text(-bd, deckY + 12, "elevated mainline", ha="center", fontsize=8, fontweight="bold") +for px in (-bd - 40, -bd + 40): + ax3.add_patch(Rectangle((px - 4, ringY - 2), 8, LIFT + 2, fc="#bbb", ec="#777")) +for cx in (-160, -60, 40, 140, 240): + ax3.add_patch(Rectangle((cx, ringY, ), 26, 9, fc="#4a7fb5", ec="#245")) +ax3.annotate("", xy=(-bd + 70, ringY), xytext=(-bd + 70, deckY), + arrowprops=dict(arrowstyle="<->", color="#0a0", lw=1.6)) +ax3.text(-bd + 78, ringY + LIFT / 2, f"{LIFT:.0f} studs\n(cars pass under)", + fontsize=8, va="center", color="#070") +ax3.set_xlim(-PA_R - 220, PA_R + 220) +ax3.set_ylim(ringY - 22, deckY + 30) +ax3.set_xlabel("distance from PA centre (studs)") +ax3.set_ylabel("Y (studs)") +ax3.grid(alpha=.25) + +fig.tight_layout(rect=[0, 0, 1, .94]) +fig.savefig(OUT, dpi=118) +print("wrote", OUT) +print(f"nearest sample={best} dist={bd:.1f} ring outer={RING_R + RING_W / 2:.1f}" + f" overlap={RING_R + RING_W / 2 - bd:+.1f} studs") diff --git a/tools/plot_wallmap.py b/tools/plot_wallmap.py new file mode 100644 index 0000000..d023a91 --- /dev/null +++ b/tools/plot_wallmap.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +plot_wallmap.py -- render the JSON dumped by tools/diag_wallmap.luau as a +top-down PNG. + +WHY THIS EXISTS: the Studio viewport cannot be screenshotted on this machine +(Vinegar/Wine on nested X display :1, DXVK-rendered -> XGetImage returns +black). For "is there a gap / does it look jumbled / which way does it face" +questions, plotting the datamodel geometry top-down is both possible and more +precise than a screenshot. + +Usage: + python3 tools/plot_wallmap.py /tmp/wallmap_raw.txt /tmp/wallmap.png ["Title"] + +The input file is whatever rbx.call('execute_luau', ...) returned for +diag_wallmap.luau -- it may be JSON or a Python-repr dict, both are handled. +""" + +import ast +import json +import math +import subprocess +import sys + +STYLE = { + "Road": ("#3a3a44", 0.5), + "Ramp": ("#4a4a58", 0.5), + "Apron": ("#2e2e36", 0.5), + "Barrier": ("#ffffff", 0.95), + "EdgeRail": ("#8a8a9a", 0.95), + "KillWall": ("#ff4444", 0.95), + "PARingWall": ("#00e5ff", 0.95), + "PARingRail": ("#0090a0", 0.95), + "Median": ("#ffd400", 0.95), + "MedianFence": ("#c0a000", 0.95), +} +# Draw decks first so walls land on top of them. +ORDER = [ + "Road", "Ramp", "Apron", "Median", "MedianFence", + "KillWall", "EdgeRail", "Barrier", "PARingWall", "PARingRail", +] +LEGEND = [ + ("PARingWall (ring parapet)", "#00e5ff"), + ("Barrier (mainline)", "#ffffff"), + ("KillWall", "#ff4444"), + ("Median", "#ffd400"), + ("Road/Ramp deck", "#3a3a44"), +] + + +def load(path): + raw = open(path).read() + start = raw.find("{") + if start < 0: + raise SystemExit(f"no payload found in {path}") + body = raw[start:] + try: + return json.loads(body) + except json.JSONDecodeError: + # The bridge often hands back a Python repr (single quotes, True/None). + return ast.literal_eval(body) + + +def analyse(parts): + """Print the numbers that answer 'is it sealed' before drawing anything.""" + ring = [p for p in parts if p["k"] == "PARingWall"] + if not ring: + return None, None + cx = sum(p["x"] for p in ring) / len(ring) + cz = sum(p["z"] for p in ring) / len(ring) + rad = [math.hypot(p["x"] - cx, p["z"] - cz) for p in ring] + split = (min(rad) + max(rad)) / 2 + inner = [p for p in ring if math.hypot(p["x"] - cx, p["z"] - cz) < split] + outer = [p for p in ring if math.hypot(p["x"] - cx, p["z"] - cz) >= split] + print(f"PA centre ~({cx:.0f},{cz:.0f}) radii {min(rad):.0f}..{max(rad):.0f}") + print(f"inner-edge walls {len(inner)}/72 outer-edge walls {len(outer)}/72") + + angs = sorted(math.degrees(math.atan2(p["z"] - cz, p["x"] - cx)) % 360 for p in inner) + gaps = [] + for i, a in enumerate(angs): + nxt = angs[(i + 1) % len(angs)] + d = (nxt - a) % 360 + if d > 7.5: # one segment is 5 deg; >7.5 means at least one is absent + gaps.append((round(a, 1), round(nxt, 1), round(d, 1))) + print("INNER-wall gaps >7.5deg (a usable exit shows here):", gaps or "NONE - sealed") + + road = sum(1 for p in parts if p["k"] == "Road") + bar = sum(1 for p in parts if p["k"] == "Barrier") + if road: + # One barrier per side of every deck segment => healthy ratio ~2.0. + print(f"mainline Road={road} Barrier={bar} ratio={bar/road:.2f} (healthy ~2.0)") + return cx, cz + + +def render(parts, cx, cz, out_png, title, span=1000.0, size=1400): + def sx(x): + return (x - cx) / span * (size / 2) + size / 2 + + def sy(z): + return (z - cz) / span * (size / 2) + size / 2 + + svg = [ + f'' + f'' + ] + for kind in ORDER: + col, op = STYLE.get(kind, ("#888888", 0.9)) + for p in parts: + if p["k"] != kind: + continue + w = max(p["sx"] / span * (size / 2), 1.5) + h = max(p["sz"] / span * (size / 2), 1.5) + # Negate yaw: world +Z runs down the screen in this projection. + svg.append( + f'' + f'' + ) + svg.append(f'') + svg.append( + f'{title}' + ) + for i, (lab, col) in enumerate(LEGEND): + yy = 64 + i * 25 + svg.append( + f'' + f'{lab}' + ) + svg.append("") + + svg_path = out_png.rsplit(".", 1)[0] + ".svg" + open(svg_path, "w").write("\n".join(svg)) + # rsvg-convert gives cleaner output but is often not installed; fall back to + # ImageMagick. FileNotFoundError (binary absent) must be caught too -- a + # returncode check alone does not cover it. + try: + rc = subprocess.run( + ["rsvg-convert", "-w", str(size), svg_path, "-o", out_png], + capture_output=True, + ).returncode + except FileNotFoundError: + rc = 1 + if rc != 0: + subprocess.run(["convert", "-density", "96", svg_path, out_png], check=True) + print("wrote", out_png) + + +def main(): + src = sys.argv[1] if len(sys.argv) > 1 else "/tmp/wallmap_raw.txt" + dst = sys.argv[2] if len(sys.argv) > 2 else "/tmp/wallmap.png" + title = sys.argv[3] if len(sys.argv) > 3 else "Daikoku PA -- wall map" + data = load(src) + parts = data["parts"] + print("counts (whole map):", data.get("counts")) + print("plotted parts:", len(parts)) + cx, cz = analyse(parts) + if cx is None: + raise SystemExit("no PARingWall parts found -- nothing to centre on") + render(parts, cx, cz, dst, title) + + +if __name__ == "__main__": + main() diff --git a/tools/proof_marker.luau b/tools/proof_marker.luau new file mode 100644 index 0000000..8ed8e71 --- /dev/null +++ b/tools/proof_marker.luau @@ -0,0 +1,93 @@ +--[[ + proof_marker.luau -- prove, visually, that the bridge is driving the SAME + Studio the user is looking at. + + Every claim I have made so far has been a number I read back myself, which + is exactly the kind of evidence that failed us: I verified a push at 41650 + bytes three times and the cars still never changed. So this puts something + in the user's viewport that they can confirm with their own eyes. + + Creates a large, bright, obviously-artificial marker high above the spawn + area, named with a fixed token so it is unmistakable in Explorer, plus a + floating billboard label. If the user sees it, the bridge and their Studio + are the same place, and the remaining problem is purely about WHERE source + is written (Edit vs Play datamodel). If they do NOT see it, the bridge is + attached to a different place/instance and nothing else matters until that + is fixed. + + Deliberately not subtle. Cleaned up later by name. +]] + +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 TOKEN = "HERMES_PROOF_MARKER" + +-- remove any previous marker so we never confuse an old one for a new one +for _, d in ipairs(workspace:GetDescendants()) do + if d.Name == TOKEN then + d:Destroy() + end +end + +-- Anchor near whatever the player/camera is likely looking at: use the first +-- player's character if present, else the spawn, else world origin. +local anchor = Vector3.new(0, 0, 0) +local Players = game:GetService("Players") +local plr = Players:GetPlayers()[1] +if plr and plr.Character and plr.Character.PrimaryPart then + anchor = plr.Character.PrimaryPart.Position + log("anchored to player character: " .. plr.Name) +else + local sp = workspace:FindFirstChildWhichIsA("SpawnLocation", true) + if sp then + anchor = sp.Position + log("anchored to SpawnLocation") + else + log("anchored to world origin") + end +end + +local part = Instance.new("Part") +part.Name = TOKEN +part.Size = Vector3.new(40, 40, 40) +part.Position = anchor + Vector3.new(0, 60, 0) +part.Anchored = true +part.CanCollide = false +part.Material = Enum.Material.Neon +part.Color = Color3.fromRGB(255, 0, 255) +part.Transparency = 0.15 +part.Parent = workspace + +local gui = Instance.new("BillboardGui") +gui.Name = TOKEN .. "_GUI" +gui.Size = UDim2.new(0, 600, 0, 160) +gui.StudsOffset = Vector3.new(0, 30, 0) +gui.AlwaysOnTop = true +gui.Parent = part + +local label = Instance.new("TextLabel") +label.Size = UDim2.new(1, 0, 1, 0) +label.BackgroundTransparency = 0.25 +label.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +label.TextColor3 = Color3.fromRGB(255, 255, 0) +label.TextScaled = true +label.Font = Enum.Font.SourceSansBold +label.Text = "HERMES IS HERE\nif you can read this we are\nin the SAME Studio place" +label.Parent = gui + +log("") +log("created " .. TOKEN .. " at " .. tostring(part.Position)) +log("size 40x40x40, magenta neon, billboard label, AlwaysOnTop") +log("") +log("USER CHECK: look up near your car / spawn, or search Explorer for") +log(" " .. TOKEN) +log("If it is visible -> bridge and your Studio are the same place.") + +return table.concat(out, "\n") diff --git a/tools/push_module.py b/tools/push_module.py new file mode 100644 index 0000000..9a50842 --- /dev/null +++ b/tools/push_module.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Push MapBuilder.luau source into a LIVE (Play-mode) Studio via the MCP bridge. + +Rojo cannot reach the running server datamodel in Play mode, so the source is +shipped as ~9000-char chunks into named StringValue children (p0, p1, ...) under +ReplicatedStorage, then concatenated and assigned to the ModuleScript's .Source. + +Named children matter: a retried chunk overwrites its slot instead of appending +a duplicate. Bridge calls fail intermittently ("Unable to find an active Studio +instance") even while Studio is up, so every call is retried. +""" +import sys, time + +sys.path.insert(0, "/tmp") +import rbx + +SRC_PATH = sys.argv[1] +MODULE_NAME = sys.argv[2] +# Which datamodel to write into. "Server" is the PLAY-mode sandbox: Studio +# DISCARDS it when Play stops, so pushes there are silently lost on the next +# stop/start (this burned an entire debugging session -- pushes verified at the +# correct byte length, then vanished). "Edit" is the persistent datamodel and +# is what you almost always want; it survives into the next Play session. +# Edit is only reachable while Studio is actually in Edit mode. +DATAMODEL = sys.argv[3] if len(sys.argv) > 3 else "Edit" +MODULE = 'game:GetService("ServerScriptService"):FindFirstChild("Server", true)' +CHUNK = 9000 + + +def call(code, tries=4): + last = None + for a in range(tries): + try: + r = rbx.call( + "execute_luau", + {"code": code, "datamodel_type": DATAMODEL}, + warmup=6, + wait=45, + timeout=300, + ) + if r and "Unable to find an active Studio" not in str(r): + return r + last = r + except Exception as e: # noqa: BLE001 + last = e + time.sleep(5) + raise RuntimeError(f"bridge failed after {tries} tries: {last}") + + +src = open(SRC_PATH, encoding="utf-8").read() +nbytes = len(src.encode()) +print(f"source: {len(src)} chars, {nbytes} utf-8 bytes") + +# Pick a long-bracket level that does not occur in the source. +lvl = 0 +while ("]" + "=" * lvl + "]") in src or ("[" + "=" * lvl + "[") in src: + lvl += 1 +eq = "=" * lvl +print(f"long-bracket level: {lvl}") + +chunks = [src[i : i + CHUNK] for i in range(0, len(src), CHUNK)] +print(f"chunks: {len(chunks)}") + +print(call('local rs=game:GetService("ReplicatedStorage")\n' + 'local b=rs:FindFirstChild("MBBuf") if b then b:Destroy() end\n' + 'b=Instance.new("Folder") b.Name="MBBuf" b.Parent=rs\n' + 'return "buffer reset"')) + +for i, c in enumerate(chunks): + # Lua long-bracket literals SWALLOW a newline that immediately follows the + # opening bracket. When a chunk happened to start with "\n" that byte was + # silently lost, giving assembled=65158 vs expected=65159. Emit a deliberate + # leading newline for Lua to eat so the payload always survives intact. + code = ( + 'local rs=game:GetService("ReplicatedStorage")\n' + 'local b=rs:WaitForChild("MBBuf")\n' + f'local n="p{i}"\n' + "local sv=b:FindFirstChild(n) or Instance.new(\"StringValue\")\n" + "sv.Name=n sv.Parent=b\n" + f"sv.Value=[{eq}[\n{c}]{eq}]\n" + f'return "p{i} "..#sv.Value' + ) + print(f" chunk {i+1}/{len(chunks)}: {call(code)}") + +assemble = f""" +local rs=game:GetService("ReplicatedStorage") +local b=rs:WaitForChild("MBBuf") +local parts={{}} +for i=0,{len(chunks)-1} do + local sv=b:FindFirstChild("p"..i) + if not sv then return "MISSING CHUNK p"..i end + parts[#parts+1]=sv.Value +end +local s=table.concat(parts) +-- Search the WHOLE game, not just ServerScriptService: shared modules live in +-- ReplicatedStorage and client ones in StarterPlayerScripts. Searching only +-- SSS silently reported "NOT FOUND" for Chassis while the wrapper still +-- exited 0, so a half-applied push looked like a success. +local roots={{ + game:GetService("ServerScriptService"), + game:GetService("ReplicatedStorage"), + game:GetService("StarterPlayer"), + game:GetService("Workspace"), +}} +local m=nil +for _,r in ipairs(roots) do + local hit=r:FindFirstChild("{MODULE_NAME}", true) + if hit and hit:IsA("ModuleScript") then m=hit break end +end +if not m then return "{MODULE_NAME} ModuleScript NOT FOUND" end +m.Source=s +return ("assembled=%d expected={nbytes} match=%s class=%s path=%s"):format( + #s, tostring(#s=={nbytes}), m.ClassName, m:GetFullName()) +""" +result = call(assemble) +print(result) +# Exit non-zero on any failure. Previously this always exited 0, so a +# "NOT FOUND" or byte-count mismatch was reported as success by the caller. +if "match=true" not in str(result): + print("PUSH FAILED -- module not updated", file=sys.stderr) + sys.exit(1) diff --git a/tools/rebuild_cachebust.luau b/tools/rebuild_cachebust.luau new file mode 100644 index 0000000..4ed788b --- /dev/null +++ b/tools/rebuild_cachebust.luau @@ -0,0 +1,83 @@ +-- Rebuild the map with a BUSTED require() cache. +-- +-- PITFALL (cost a build cycle on 2026-08-02): require() caches per Studio +-- session and keyed by INSTANCE. Pushing new .Source into the existing +-- ModuleScript does NOT invalidate that cache, so `require(MapBuilder)` +-- happily returns the OLD compiled module and the map rebuilds from stale +-- code -- the diagnostic then reports failures that the source has already +-- fixed (legacy PARingPillar present, no ramp segments). +-- +-- Fix: swap in CLONES. A clone is a different instance, so it misses the +-- cache and is compiled fresh. Circuit must be swapped too, because +-- MapBuilder resolves it BY PATH -- putting the clone at the same path is +-- what makes the fresh MapBuilder pick up the fresh Circuit. +local SSS = game:GetService("ServerScriptService") +local RS = game:GetService("ReplicatedStorage") + +local mbOrig = SSS.Server.MapBuilder +local ciOrig = RS.Shared.Circuit + +-- Clone both, park the originals under a holder so the paths are free. +local holder = game:GetService("ServerStorage"):FindFirstChild("_cachebust") +if holder then + holder:Destroy() +end +holder = Instance.new("Folder") +holder.Name = "_cachebust" +holder.Parent = game:GetService("ServerStorage") + +local ciClone = ciOrig:Clone() +local mbClone = mbOrig:Clone() + +ciOrig.Parent = holder +ciOrig.Name = "Circuit_old" +mbOrig.Parent = holder +mbOrig.Name = "MapBuilder_old" + +ciClone.Name = "Circuit" +ciClone.Parent = RS.Shared +mbClone.Name = "MapBuilder" +mbClone.Parent = SSS.Server + +local out = {} +local function say(s) + table.insert(out, s) +end +say("swapped in fresh clones (require cache bypassed)") + +local old = workspace:FindFirstChild("Map") or workspace:FindFirstChild("Circuit") +if old then + old:Destroy() + say("destroyed previous Map") +end + +local ok, err = pcall(function() + local MB = require(mbClone) + MB.build() +end) +if not ok then + say("BUILD_ERROR: " .. tostring(err)) + return table.concat(out, "\n") +end + +local m = workspace:FindFirstChild("Map") or workspace:FindFirstChild("Circuit") +local n = 0 +if m then + n = #m:GetDescendants() +end +say("BUILD_OK parts=" .. tostring(n)) + +-- Quick confirmation that the NEW code path actually ran. +local counts = { PARingPillar = 0, PAEntryRamp = 0, PAExitRamp = 0, PARampPillar = 0, PARampKerb = 0 } +for _, d in ipairs(workspace:GetDescendants()) do + for k in pairs(counts) do + if string.sub(d.Name, 1, #k) == k then + counts[k] += 1 + end + end +end +for k, v in pairs(counts) do + say((" %-14s %d"):format(k, v)) +end + +return table.concat(out, "\n") diff --git a/tools/shot_shells.luau b/tools/shot_shells.luau new file mode 100644 index 0000000..e6e12a6 --- /dev/null +++ b/tools/shot_shells.luau @@ -0,0 +1,108 @@ +--[[ + shot_shells.luau -- render the built cars for visual confirmation. + + Numeric cues have now been wrong twice in this investigation (wheel-name + probes silently matched the chassis; the "asym" proxy gave verdicts that + contradicted the boot cue). The boot check is self-consistent but cannot + judge hatch_junk or hyper_x -- which are precisely the two cars in + question. So look at them. + + Places all five cars in a row, all facing the SAME chassis direction + (forward = -Z), and points a camera down the row from the side. On a + correct fleet every nose points the same way; a backwards shell is + obvious. Leaves the cars in Workspace under a folder so a screenshot can + be taken from Studio. +]] + +local ServerScriptService = game:GetService("ServerScriptService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local Shared = ReplicatedStorage:FindFirstChild("Shared") +local Cars = require(Shared.Cars) +local Chassis = require(Shared.Chassis) +local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local cm = orig:Clone() +cm.Parent = orig.Parent +local CarManager = require(cm) + +local old = workspace:FindFirstChild("ShellReview") +if old then + old:Destroy() +end +local folder = Instance.new("Folder") +folder.Name = "ShellReview" +folder.Parent = workspace + +local pad = Instance.new("Part") +pad.Size = Vector3.new(200, 4, 120) +pad.Position = Vector3.new(0, 4998, 0) +pad.Anchored = true +pad.Color = Color3.fromRGB(70, 70, 75) +pad.Material = Enum.Material.SmoothPlastic +pad.Parent = folder + +local out = {} +local i = 0 +local ids = {} +for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do + table.insert(ids, t.Name) +end +table.sort(ids) + +for _, id in ipairs(ids) do + local car = Cars.Get(id) + if car then + local m = CarManager._buildForDiag(car, Chassis.tuningFor(car)) + m.Name = "car_" .. id + m.Parent = folder + -- All cars share one orientation: forward = -Z, i.e. identity rotation. + m:PivotTo(CFrame.new(-60 + i * 30, 5003, 0)) + for _, p in ipairs(m:GetDescendants()) do + if p:IsA("BasePart") then + p.Anchored = true + end + end + -- Label above each car so the screenshot is self-describing. + local sign = Instance.new("Part") + sign.Size = Vector3.new(0.2, 0.2, 0.2) + sign.Transparency = 1 + sign.Anchored = true + sign.CanCollide = false + sign.Position = Vector3.new(-60 + i * 30, 5010, 0) + sign.Parent = folder + local gui = Instance.new("BillboardGui") + gui.Size = UDim2.new(0, 260, 0, 60) + gui.AlwaysOnTop = true + gui.Parent = sign + local lbl = Instance.new("TextLabel") + lbl.Size = UDim2.fromScale(1, 1) + lbl.BackgroundTransparency = 0.3 + lbl.BackgroundColor3 = Color3.new(0, 0, 0) + lbl.TextColor3 = Color3.new(1, 1, 1) + lbl.TextScaled = true + lbl.Font = Enum.Font.GothamBold + lbl.Text = id + lbl.Parent = gui + table.insert(out, id) + i += 1 + end +end + +for _ = 1, 10 do + RunService.Heartbeat:Wait() +end + +-- Side-on camera: looking along +X at the row, so every nose should point +-- to the SAME side of frame (screen left = -Z). +local cam = workspace.CurrentCamera +if cam then + cam.CameraType = Enum.CameraType.Scriptable + cam.CFrame = CFrame.lookAt(Vector3.new(0, 5022, 78), Vector3.new(0, 5004, 0)) +end + +cm:Destroy() +return "placed: " + .. table.concat(out, ", ") + .. "\nAll cars share forward = -Z (screen LEFT in this view)." + .. "\nCamera set side-on. Every nose should point the same way." diff --git a/tools/verify_convention.luau b/tools/verify_convention.luau new file mode 100644 index 0000000..d3e658e --- /dev/null +++ b/tools/verify_convention.luau @@ -0,0 +1,161 @@ +--[[ + verify_convention.luau -- prove the forward convention is now consistent. + + Deliberately NOT measuring anything derived from a constant. The previous + harness read Wheel_* positions, which come straight from + Chassis.WHEELBASE_HALF (5.9) -- identical for every car whether or not any + fix applied, so it passed vacuously. Here every assertion is a RELATIONSHIP + that genuinely differs between the broken and fixed builds: + + 1. The steering servo must sit on the axle that LEADS under throttle. + Broken build: servos on the trailing axle (shopping-cart caster). + 2. The shell nose must point the same way the seat drives. + Broken build: 180 degrees opposed. + 3. DRIVE_SIGN must spin the wheels toward the direction of travel. + + Builds each car via the real path (Chassis.tuningFor + _buildForDiag) in + the Edit datamodel, where require() is fresh. +]] + +local SSS = game:GetService("ServerScriptService") +local RS = game:GetService("ReplicatedStorage") + +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 function findModule(name: string): ModuleScript? + for _, root in ipairs({ SSS, RS }) do + local hit = root:FindFirstChild(name, true) + if hit and hit:IsA("ModuleScript") then + return hit + end + end + return nil +end + +local cmMod = findModule("CarManager") +local carsMod = findModule("Cars") +local chassisMod = findModule("Chassis") +if not (cmMod and carsMod and chassisMod) then + return "FATAL: missing module(s)" +end + +local CarManager = require(cmMod) +local Cars = require(carsMod) +local Chassis = require(chassisMod) + +if typeof(CarManager._buildForDiag) ~= "function" then + return "FATAL: _buildForDiag absent -- stale CarManager, refusing to report" +end + +log(string.format("Chassis.DRIVE_SIGN = %s", tostring(Chassis.DRIVE_SIGN))) +log("") + +local carList = Cars.List or Cars.All +if typeof(carList) ~= "table" or #carList == 0 then + return "FATAL: empty car list" +end + +log(string.format("%-11s %-22s %-24s %s", "car", "steered axle", "shell nose vs drive", "verdict")) +log(string.rep("-", 78)) + +local tested, failed = 0, 0 + +for _, car in ipairs(carList) do + if typeof(car) ~= "table" or not car.Id then + continue + end + + local tuning = Chassis.tuningFor(car) + local ok, model = pcall(CarManager._buildForDiag, car, tuning) + if not ok or typeof(model) ~= "Instance" then + log(string.format("%-11s BUILD FAILED: %s", car.Id, tostring(model))) + failed += 1 + continue + end + + local body = model:FindFirstChild("Body") + local seat + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("VehicleSeat") then + seat = d + break + end + end + + if not (body and body:IsA("BasePart") and seat) then + log(string.format("%-11s NO BODY/SEAT", car.Id)) + failed += 1 + model:Destroy() + continue + end + + tested += 1 + local inv = body.CFrame:Inverse() + -- Direction of travel under positive throttle, in body-local space. + local driveZ = inv:VectorToObjectSpace(seat.CFrame.LookVector).Z + local drivePos = driveZ > 0 + + -- (1) Which axle carries the steering servos? + local servoZ, servoN = 0, 0 + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("HingeConstraint") and d.ActuatorType == Enum.ActuatorType.Servo then + local host = d.Parent + if host and host:IsA("BasePart") then + servoZ += (inv * host.CFrame).Z + servoN += 1 + end + end + end + local steerLeads, steerTxt = false, "no servos" + if servoN > 0 then + local sm = servoZ / servoN + steerLeads = (drivePos and sm > 0) or (not drivePos and sm < 0) + steerTxt = string.format("z=%+.2f %s", sm, steerLeads and "LEADS" or "TRAILS") + end + + -- (2) Shell nose vs direction of travel. + local shell + for _, c in ipairs(model:GetChildren()) do + if c:IsA("Model") then + shell = c + break + end + end + local shellOk, shellTxt = true, "no shell" + if shell then + local shellLook = shell:GetPivot().LookVector + local seatLook = seat.CFrame.LookVector + local a = Vector3.new(shellLook.X, 0, shellLook.Z) + local b = Vector3.new(seatLook.X, 0, seatLook.Z) + if a.Magnitude > 1e-4 and b.Magnitude > 1e-4 then + local deg = math.deg(math.acos(math.clamp(a.Unit:Dot(b.Unit), -1, 1))) + shellOk = deg < 30 + shellTxt = string.format("%.1f deg %s", deg, shellOk and "OK" or "FLIPPED") + end + end + + local pass = (servoN == 0 or steerLeads) and shellOk + if not pass then + failed += 1 + end + log(string.format("%-11s %-22s %-24s %s", car.Id, steerTxt, shellTxt, pass and "PASS" or "FAIL")) + model:Destroy() +end + +log("") +if tested == 0 then + log(">>> NOTHING TESTED -- this is a FAILURE, not a pass.") +elseif failed > 0 then + log(string.format(">>> %d/%d FAILED", failed, tested)) +else + log(string.format(">>> ALL %d CARS PASS: steered axle leads, shell faces travel", tested)) +end + +return table.concat(out, "\n") diff --git a/tools/verify_fixes.luau b/tools/verify_fixes.luau new file mode 100644 index 0000000..ad5169f --- /dev/null +++ b/tools/verify_fixes.luau @@ -0,0 +1,185 @@ +--[[ + verify_fixes.luau -- build all five cars with the NEWLY PUSHED code and + measure whether the two fixes actually work. Runs in the EDIT datamodel, + where require() is fresh (no Play-session cache pin), so this is the first + honest test of these fixes. + + Checks, per car: + + FIX 1 -- FACING (hatch_junk, hyper_x rendered backwards) + Compare mean Z of F* wheel groups vs R* groups in the model's own frame. + Chassis convention: front axle is built at NEGATIVE Z. So frontZ < rearZ + means the car faces the right way. hatch_junk / hyper_x previously came + out reversed because their shell wheel LABELS are swapped, which fooled + the auto-solver into a 180 degree rotation; SHELL_YAW_OVERRIDE pins them + to yaw 0. + + FIX 2 -- WHEEL STANCE (rx7_fc wheels protruding from the bodywork) + For each wheel, measure |X| of the wheel's visual centre against |X| of + the axle it hangs from. The old code picked an arbitrary BasePart as the + wheel root (often a rotor/caliper ~0.96 studs inboard of the tyre) and + made THAT concentric with the axle, shoving the tyre outboard by the + difference. Now it picks the largest-volume part (the tyre). Residual + offset should be near zero; anything > 0.35 studs is a visible protrusion. + + Everything is measured from the built model, not inferred from source. + Models are destroyed afterwards so Edit is left clean. +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +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 serverFolder = ServerScriptService:FindFirstChild("Server", true) +local cmMod = serverFolder and serverFolder:FindFirstChild("CarManager") +if not cmMod then + return "FATAL: no CarManager module found" +end + +log( + string.format( + "CarManager source: %d bytes, SHELL_YAW_OVERRIDE=%s", + #cmMod.Source, + tostring(cmMod.Source:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil) + ) +) + +local okCM, CarManager = pcall(require, cmMod) +if not okCM then + return "FATAL: require(CarManager) failed: " .. tostring(CarManager) +end +if typeof(CarManager._buildForDiag) ~= "function" then + return "FATAL: _buildForDiag is " + .. typeof(CarManager._buildForDiag) + .. " -- require() served STALE code, results would be meaningless" +end +log("_buildForDiag present -> NEW code is genuinely running") + +-- find Cars + Tuning modules +local function findModule(name) + for _, svc in ipairs({ game:GetService("ReplicatedStorage"), ServerScriptService }) do + local m = svc:FindFirstChild(name, true) + if m and m:IsA("ModuleScript") then + return m + end + end + return nil +end + +local carsMod = findModule("Cars") +local chassisMod = findModule("Chassis") +if not carsMod then + return "FATAL: Cars module not found" +end +if not chassisMod then + return "FATAL: Chassis module not found (needed for tuningFor)" +end +local Cars = require(carsMod) +local Chassis = require(chassisMod) +if typeof(Chassis.tuningFor) ~= "function" then + return "FATAL: Chassis.tuningFor is " .. typeof(Chassis.tuningFor) +end + +local FRONT_NEG_Z = true -- chassis builds front axle at -Z +local PROTRUDE_TOL = 0.35 + +log("") +log(string.format("%-10s %8s %8s %-12s | %8s %-10s", "car", "frontZ", "rearZ", "FACING", "maxOff", "STANCE")) +log(string.rep("-", 74)) + +local built = {} +local anyFail = false + +local carList = Cars.List or Cars.All +if typeof(carList) ~= "table" or #carList == 0 then + return "FATAL: could not find a non-empty car list (Cars.List). " .. "Refusing to report a vacuous pass." +end +log(string.format("car list: %d entries", #carList)) + +for _, car in ipairs(carList) do + if typeof(car) == "table" and car.Id then + local tuning = Chassis.tuningFor(car) + local ok, model = pcall(CarManager._buildForDiag, car, tuning) + if not ok or typeof(model) ~= "Instance" then + log(string.format("%-10s BUILD FAILED: %s", car.Id, tostring(model))) + anyFail = true + else + model.Parent = workspace + built[#built + 1] = model + local pivotInv = model:GetPivot():Inverse() + + -- ---- FACING: mean Z of front vs rear wheel groups + local fS, fN, rS, rN = 0, 0, 0, 0 + -- ---- STANCE: wheel centre |X| vs its axle |X| + local maxOff = 0 + + for _, d in ipairs(model:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^Wheel_") then + local lbl = d.Name:sub(7, 7):upper() + local z = (pivotInv * d.CFrame).Z + if lbl == "F" then + fS, fN = fS + z, fN + 1 + elseif lbl == "R" then + rS, rN = rS + z, rN + 1 + end + + -- visual tyre centre = centroid of the attached shell parts + local axleX = (pivotInv * d.CFrame).X + local sum, n = Vector3.zero, 0 + for _, c in ipairs(d:GetDescendants()) do + if c:IsA("BasePart") then + sum, n = sum + c.Position, n + 1 + end + end + if n > 0 then + local visX = (pivotInv * CFrame.new(sum / n)).X + local off = math.abs(math.abs(visX) - math.abs(axleX)) + if off > maxOff then + maxOff = off + end + end + end + end + + local facing, stance = "n/a", "n/a" + if fN > 0 and rN > 0 then + local fz, rz = fS / fN, rS / rN + local good = FRONT_NEG_Z and (fz < rz) or (fz > rz) + facing = good and "OK" or "BACKWARDS" + if not good then + anyFail = true + end + stance = (maxOff <= PROTRUDE_TOL) and "OK" or "PROTRUDING" + if maxOff > PROTRUDE_TOL then + anyFail = true + end + log(string.format("%-10s %8.2f %8.2f %-12s | %8.3f %-10s", car.Id, fz, rz, facing, maxOff, stance)) + else + log(string.format("%-10s (no Wheel_* parts found)", car.Id)) + anyFail = true + end + end + end +end + +for _, m in ipairs(built) do + m:Destroy() +end + +local tested = #built +log("") +if tested == 0 then + log(">>> NOTHING WAS TESTED -- no cars built. This is a FAILURE, not a pass.") +elseif anyFail then + log(string.format(">>> SOME CHECKS FAILED (%d cars tested)", tested)) +else + log(string.format(">>> ALL CHECKS PASSED (%d cars tested)", tested)) +end +return table.concat(out, "\n") diff --git a/tools/verify_push.luau b/tools/verify_push.luau new file mode 100644 index 0000000..b815a4a --- /dev/null +++ b/tools/verify_push.luau @@ -0,0 +1,90 @@ +--[[ + verify_push.luau -- verify a push by INSPECTING THE DATAMODEL, not by + trusting push_module.py's self-report. + + push_module.py reported "assembled=39591 expected=39591 match=true" while + the live module was in fact 30631 bytes with no _buildForDiag. That check + only proves the script reassembled its own chunks correctly client-side; + it does not prove the DataModel accepted the write. This closes that gap. + + Reports, for every CarManager in ServerScriptService: + * instance count (must be 1 -- diagnostic clones caused duplicates) + * Source length vs the expected 39591 + * presence of marker strings that only exist in the new source + * the module's exported key set after a fresh require +]] + +local ServerScriptService = game:GetService("ServerScriptService") + +local EXPECTED_LEN = 41650 +local MARKERS = { + "SHELL_YAW_OVERRIDE", + "bestVol", + "_buildForDiag", +} + +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 mods = {} +for _, d in ipairs(ServerScriptService:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "CarManager" then + mods[#mods + 1] = d + end +end + +log(string.format("CarManager instances: %d %s", #mods, #mods == 1 and "OK" or "*** should be exactly 1 ***")) + +for i, m in ipairs(mods) do + local src = nil + pcall(function() + src = m.Source + end) + if not src then + log(string.format(" [%d] %s ", i, m:GetFullName())) + else + log(string.format(" [%d] %s", i, m:GetFullName())) + log( + string.format( + " length %d vs expected %d %s", + #src, + EXPECTED_LEN, + #src == EXPECTED_LEN and "MATCH" or "*** STALE / MISMATCH ***" + ) + ) + for _, mk in ipairs(MARKERS) do + log(string.format(" marker %-26s %s", mk, src:find(mk, 1, true) and "present" or "*** MISSING ***")) + end + end +end + +if #mods == 1 then + log("") + local ok, mod = pcall(require, mods[1]) + if ok and typeof(mod) == "table" then + local keys = {} + for k in pairs(mod) do + keys[#keys + 1] = tostring(k) + end + table.sort(keys) + log("require() keys: " .. table.concat(keys, ", ")) + log( + "_buildForDiag: " + .. typeof(mod._buildForDiag) + .. ( + typeof(mod._buildForDiag) == "function" and " <- new source IS live" + or " <- STILL RUNNING OLD CODE (require cache from Play start)" + ) + ) + else + log("require failed: " .. tostring(mod)) + end +end + +return table.concat(out, "\n") diff --git a/tools/verify_revert.luau b/tools/verify_revert.luau new file mode 100644 index 0000000..a759661 --- /dev/null +++ b/tools/verify_revert.luau @@ -0,0 +1,63 @@ +--[[ + verify_revert.luau -- confirm the reverted convention is in Edit source. + Reads .Source text (never require(), which caches for the Studio session). + Asserts the NEW-BAD tokens are ABSENT and the ORIGINAL-GOOD tokens PRESENT, + so a stale copy fails loudly instead of passing. +]] + +local RS = game:GetService("ReplicatedStorage") +local SSS = game:GetService("ServerScriptService") + +local function find(name) + for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do + local m = root:FindFirstChild(name, true) + if m and m:IsA("ModuleScript") then + return m + end + end + return nil +end + +local out = {} +local pass, fail = 0, 0 +local function check(label, cond, detail) + if cond then + pass += 1 + table.insert(out, string.format(" %-34s OK %s", label, detail or "")) + else + fail += 1 + table.insert(out, string.format(" %-34s FAIL %s", label, detail or "")) + end +end + +local cm = find("CarManager") +local ch = find("Chassis") +if not cm or not ch then + return "FATAL: CarManager or Chassis not found in Edit" +end + +local cmSrc = cm.Source +local chSrc = ch.Source + +table.insert(out, string.format("CarManager %d bytes %s", #cmSrc, cm:GetFullName())) +check("isFront reverted to zSign < 0", cmSrc:find("isFront = zSign < 0", 1, true) ~= nil, "") +check("bad zSign > 0 GONE", cmSrc:find("isFront = zSign > 0", 1, true) == nil, "") +check("shell solver back on -Z", cmSrc:find("atan2(nose.X, -nose.Z)", 1, true) ~= nil, "") +check("bad solver GONE", cmSrc:find("atan2(-nose.X, nose.Z)", 1, true) == nil, "") +check("regression warning recorded", cmSrc:find("made things strictly worse", 1, true) ~= nil, "") + +table.insert(out, string.format("Chassis %d bytes %s", #chSrc, ch:GetFullName())) +check("DRIVE_SIGN reverted to -1", chSrc:find("DRIVE_SIGN = -1", 1, true) ~= nil, "") +check("bad DRIVE_SIGN = 1 GONE", chSrc:find("DRIVE_SIGN = 1 ", 1, true) == nil, "") + +table.insert(out, "") +table.insert(out, string.format(">>> %d passed, %d failed", pass, fail)) +if fail > 0 then + table.insert(out, ">>> REVERT INCOMPLETE -- do not press Play") +else + table.insert(out, ">>> Revert confirmed in Edit source. Save (Ctrl+S) before Play.") + table.insert(out, " NOTE: this proves the SOURCE is reverted. It does NOT prove the") + table.insert(out, " cars render correctly -- only your eyes in Play can show that.") +end + +return table.concat(out, "\n") diff --git a/tools/verify_rwd.luau b/tools/verify_rwd.luau new file mode 100644 index 0000000..b91cf03 --- /dev/null +++ b/tools/verify_rwd.luau @@ -0,0 +1,138 @@ +--[[ + verify_rwd.luau -- verify the RWD steering fix, bypassing the require cache. + + check_live_source proved the pushed source IS in the DataModel (40307 + bytes, RWD marker present, single copy). But diag_steering still saw + 4 motors because require() caches per module INSTANCE for the life of the + Play session, and CarManager was first required before the push -- so the + old build closure was still running. + + Clone() produces a NEW instance, so require(clone) re-executes the current + source. That gives a true read of the fix without restarting Play mode. +]] + +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 orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager") +local clone = orig:Clone() +clone.Parent = orig.Parent +local CarManager = require(clone) +log("required a FRESH clone of CarManager (" .. #clone.Source .. " bytes)") + +local pad = Instance.new("Part") +pad.Size = Vector3.new(6000, 8, 6000) +pad.Position = Vector3.new(0, 5000, 0) +pad.Anchored = true +pad.Material = Enum.Material.Concrete +pad.Parent = workspace +local padTop = pad.Position.Y + 4 + +local car = Cars.Get("gtr_r34") +local tuning = Chassis.tuningFor(car) + +local model = CarManager._buildForDiag(car, tuning) +model.Parent = workspace +model:PivotTo(CFrame.new(0, padTop + 3, 0)) + +local servos, motors = {}, {} +for _, d in ipairs(model:GetDescendants()) do + if d:IsA("HingeConstraint") then + if d.Name == Chassis.STEER_SERVO_NAME then + table.insert(servos, d) + elseif d.Name == Chassis.WHEEL_MOTOR_NAME then + table.insert(motors, d) + end + end +end + +local motorCorners = {} +for _, mo in ipairs(motors) do + table.insert(motorCorners, mo.Parent and mo.Parent.Name or "?") +end +log(string.format("servos %d | motors %d [%s]", #servos, #motors, table.concat(motorCorners, ", "))) +log( + string.format( + "expected: 2 servos, 2 motors on REAR corners only -> %s", + (#motors == 2 and #servos == 2) and "OK" or "WRONG" + ) +) +log("") + +local body = model.PrimaryPart +for _ = 1, 90 do + RunService.Heartbeat:Wait() +end + +-- Drive the rears and hold a steady steer angle, exactly like diag_steering. +local TARGET = -14 +local wheelR = Chassis.WHEEL_RADIUS +local topW = tuning.MaxWheelAngularVelocity + +log("t(s) speed yawRate targetAng curAng radius") +local samples = 0 +local errSum, errMax = 0, 0 +local startLook = body.CFrame.LookVector + +for step = 1, 300 do + for _, mo in ipairs(motors) do + mo.MotorMaxTorque = tuning.MotorMaxTorque + mo.AngularVelocity = -topW + end + for _, s in ipairs(servos) do + s.TargetAngle = TARGET + end + if step % 30 == 0 then + local speed = body.AssemblyLinearVelocity.Magnitude + local yaw = body.AssemblyAngularVelocity.Y + local cur = servos[1].CurrentAngle + local radius = (math.abs(yaw) > 1e-4) and (speed / math.abs(yaw)) or math.huge + log(string.format("%4.1f %6.1f %7.3f %8.1f %8.1f %9.1f", step / 60, speed, yaw, TARGET, cur, radius)) + samples += 1 + local e = math.abs(cur - TARGET) + errSum += e + errMax = math.max(errMax, e) + end + RunService.Heartbeat:Wait() +end + +local finalLook = body.CFrame.LookVector +local heading = math.deg(math.acos(math.clamp(startLook:Dot(finalLook), -1, 1))) + +log("") +log(string.format("SERVO tracking: mean err %.2f deg, max %.2f deg", errSum / samples, errMax)) +log(string.format("heading change over run: %.1f deg", heading)) + +local speed = body.AssemblyLinearVelocity.Magnitude +local yaw = math.abs(body.AssemblyAngularVelocity.Y) +local actualR = (yaw > 1e-4) and (speed / yaw) or math.huge +local wheelbase = Chassis.WHEELBASE or 11.8 +local ackR = wheelbase / math.tan(math.rad(math.abs(servos[1].CurrentAngle))) +log( + string.format( + "Ackermann radius at %.1f deg = %.1f | ACTUAL %.1f | understeer %.2fx", + servos[1].CurrentAngle, + ackR, + actualR, + actualR / ackR + ) +) + +model:Destroy() +pad:Destroy() +clone:Destroy() +return table.concat(out, "\n") diff --git a/tools/verify_source.luau b/tools/verify_source.luau new file mode 100644 index 0000000..83c5383 --- /dev/null +++ b/tools/verify_source.luau @@ -0,0 +1,92 @@ +--[[ + verify_source.luau -- read the module SOURCE TEXT in Edit, bypassing require(). + + Why: require() caches a ModuleScript's return value for the whole Studio + session. Both modules were pushed successfully (match=true), but an earlier + require() in this same session means require() still hands back the OLD + table -- so a harness that builds cars via require() measures stale code and + can report a false PASS. Reading .Source is cache-proof. + + This only proves the SOURCE is correct on disk-in-Studio. It cannot prove + runtime behaviour -- that needs a fresh datamodel (press Play), which + rebuilds the require cache from this source. +]] + +local SSS = game:GetService("ServerScriptService") +local RS = game:GetService("ReplicatedStorage") + +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 function find(name: string): ModuleScript? + for _, root in ipairs({ SSS, RS }) do + local hit = root:FindFirstChild(name, true) + if hit and hit:IsA("ModuleScript") then + return hit + end + end + return nil +end + +local checks = { + { + module = "CarManager", + label = "isFront convention", + want = "local isFront = zSign > 0", + bad = "local isFront = zSign < 0", + }, + { + module = "CarManager", + label = "shell nose solver", + want = "shellYaw = -math.atan2(-nose.X, nose.Z)", + bad = "shellYaw = -math.atan2(nose.X, -nose.Z)", + }, + { + module = "CarManager", + label = "SHELL_YAW_OVERRIDE removed", + want = nil, + bad = "local SHELL_YAW_OVERRIDE", + }, + { + module = "Chassis", + label = "DRIVE_SIGN", + want = "Chassis.DRIVE_SIGN = 1", + bad = "Chassis.DRIVE_SIGN = -1", + }, +} + +local allGood = true +for _, c in ipairs(checks) do + local m = find(c.module) + if not m then + log(string.format("%-28s MODULE NOT FOUND (%s)", c.label, c.module)) + allGood = false + continue + end + local src = m.Source + local hasWant = (c.want == nil) or (string.find(src, c.want, 1, true) ~= nil) + local hasBad = (c.bad ~= nil) and (string.find(src, c.bad, 1, true) ~= nil) + local ok = hasWant and not hasBad + if not ok then + allGood = false + end + log(string.format("%-28s %-6s [%s: %d bytes]", c.label, ok and "OK" or "STALE", m:GetFullName(), #src)) +end + +log("") +log( + allGood and ">>> SOURCE IS CORRECT in Edit. require() is still caching the old" + or ">>> SOURCE IS STALE -- the push did not land." +) +if allGood then + log(" copies this session, so only a FRESH Play session can prove runtime") + log(" behaviour. Save (Ctrl+S), then press Play.") +end + +return table.concat(out, "\n")