Compare commits

...
33 Commits
Author SHA1 Message Date
jesse ae5b26b3eb fix: rojo died on SSH exit - launch via Task Scheduler, not Start-Process
CI / quality (push) Failing after 47s
Root cause: Windows OpenSSH kills the whole process tree when the SSH
session exits, so rojo started over SSH died seconds later. Proven:
1 proc inside the session, 0 from a fresh session.

Guard now calls 'schtasks /Run /TN RojoTWR' instead of Start-Process.
Verified unattended: killed rojo, scheduler restored it (Last Result: 0).
2026-08-06 22:05:36 -04:00
jesse 25d1e37750 docs: rojo watchdog (RojoTWR-Guard) — rojo does not stay up on its own
CI / quality (push) Failing after 1m4s
Verified by killing rojo and confirming the guard restored it with 34872
owned by 'rojo'. Guard refuses to start if the port is held by anything
else (sshd tunnel) rather than masking that bug.
2026-08-06 21:59:48 -04:00
jesse 83b3bf80c3 docs: rojo must run natively on windows; ServerPackages gitignored gotcha
CI / quality (push) Failing after 58s
Two real failures diagnosed 2026-08-06:
- a leftover 'ssh -N -R 34872' reverse tunnel made port 34872 answer as sshd
  while serving the LINUX tree; /api/rojo looked healthy the whole time
- ServerPackages is gitignored, so the windows clone had no wally deps and
  rojo exited on startup, which presents as 'rojo will not connect'
2026-08-06 10:33:36 -04:00
jesse 74899a7197 docs: PROJECT-BRIEF.md — game/design/resource orientation for Claude
CI / quality (push) Failing after 51s
CLAUDE.md covers tool operation; this covers what the game IS, where it
sits in the roadmap, and where reference assets actually live.

Also fixes a stale NAS path in README (RX-7 reference dir is
001-original-images/001-1993-Mazda-RX-7, not a top-level spaced name).
2026-08-06 09:35:05 -04:00
jesse dc0df9122e docs: project CLAUDE.md for native-Windows Claude Code handoff
CI / quality (push) Failing after 46s
Pure ASCII (PS 5.1 reads non-ASCII as ANSI and desyncs the parser).
Records the aux-lane suppression invariant, the verified 6e50f97 baseline,
the read/write channel split, and the probe scripts that check each metric.
2026-08-06 08:45:27 -04:00
jesse 6e50f9718d Aux-lane suppression + PA geometry fixes; probe/verify tooling
CI / quality (push) Failing after 1m9s
MapBuilder.luau
  - Suppress the aux lane across samples 525-550 (AUX_SUPPRESS_FROM/TO),
    honoring the user's hand deletion in Studio so a rebuild no longer
    regenerates it.
  - Smoothstep the width ramp over AUX_BLEND=5 samples on each side. A hard
    cut produced a 26-stud lateral fence jog; the blend removes it.
  - DO NOT remove this suppression. Re-enabling it resurrects geometry the
    user deleted by hand.

rokit.toml
  - rojo 7.5.1 -> 7.7.0 to match the auto-updating Studio plugin.
    Server older than plugin fails the handshake with
    "attempt to index number with 'protocolVersion'".

tools/
  - 34 read-only probe/verify/capture scripts used to measure the datamodel
    instead of reasoning from source.

Verified-good baseline at this commit:
  total parts       16788
  PAWestSlip        60 segs, 34.4 deg total bend, worst 1.4, 0 kinks >5
  PAEastSlip        60 segs, 30.7 deg total bend, worst 1.3, 0 kinks >5
  AuxLane parts     30 (samples 525-550 deliberately absent)
  barrier coverage  823 samples, 0 unguarded
  fence jogs >10st  0
2026-08-06 08:27:47 -04:00
jesse c297b20aed Disable PA on/off ramps; restore mainline to pre-ramp state
Three iterations of the Daikoku PA ramps never read as a real
interchange. Measured why, rather than tuning further:

  * the entry and exit decks INTERSECTED -- 2547 sample pairs within
    30 studs horizontally at <12 studs dY, worst case 0.5 studs apart
    at dY=0.0. On screen that is the bowtie tangle of white rails.
  * both real Daikoku ramps turn the SAME way (clockwise; the guide
    video says "right, right"). This build mirrored them into each
    other.
  * the real arc wraps OUTSIDE the lot and forms its north/east
    boundary. This build cut across the apron interior.

Gated behind BUILD_PA_RAMPS=false rather than deleted -- the radius
was already correct (470 studs vs an OSM-measured mean of 469) and
the merge lands tangent at 0.1-0.2 deg divergence. Both worth keeping.

Also removed two leftovers that would have outlived the ramps:

  * rampOpen punched holes in the mainline's outer parapet, railing
    and kill wall for ramps that no longer exist -- a barrier gap with
    a drop behind it. Sealed via RAMP_EDGE_OPENINGS=false. Verified:
    Barrier/EdgeRail/KillWall = 1646 each = 823 samples x 2 edges,
    0 road samples without a guard within 90 studs.
  * gore zebra hatching painted 1024 bars + 64 boundary lines marking
    a junction that is not there. Sealed via RAMP_GORE_MARKINGS=false.

Verified after a cache-busting fresh rebuild (Studio's require() cache
otherwise serves the previous map and reports a false pass):
  parts 22628 -> 15988
  all ramp part classes = 0
  Road = 823 (unchanged), spawn pad + PASpawn intact
  clip probe TRUSTWORTHY (control +11/-0), CLIPPING=0

Research and rebuild spec kept for when this is revisited:
reference/ramps/BUILD_SPEC.md, INDEX.md (10 CC-licensed Commons
photos), docs/daikoku-reference-spec.md (OSM circle fits).
2026-08-03 21:15:25 -04:00
jesse 1d23c187ad Daikoku PA: two perimeter ramps, far side served by the median opening
Rework the PA junction to match the reference drawing: yellow = PA -> highway
(entry), red = highway -> PA (exit). Entry and exit swap sides (entry now
EAST, exit WEST) and both trunks hug the PA perimeter instead of running
concentric spirals.

The simplification: both carriageways sit at the same deck level, adjacent,
so the median opening IS the far-side access path. The under-deck crossing
and merge runs are deleted outright -- 8 runs collapse to 4, and the
girder-soffit ceiling logic that constrained the crossing goes with them.
Part count 24,210 -> 21,691.

The median opening is now load-bearing rather than cosmetic (it is the only
route from the far carriageway to the ramps), so it widens from 6 to 9
samples per junction.

Orientation is a single flip-able constant (ORIENT) so a mirrored result is
a one-line correction, not a rewrite.

Measured on the built map (not asserted):
  - 600 entry + 600 exit ramp parts
  - max grade 8.00% / 8.27%, under the 9% limit
  - 0 collisions with piers/masts/girders (558 structural parts)
  - segment continuity <= 2.5 studs; trunk->merge handoff <= 1.7 studs
  - merge lands at dY 0.0 (exact deck level), 33 studs lateral
  - median open at junctions: 0 parts vs. control ~7 on plain mainline

Two traps worth recording, both caught by controls:

1. Studio's require() cache silently rebuilt STALE code. The first "passing"
   build reported 17,575 parts and zero ramps, and the map contained
   PAOffRamp/PAOnRamp parts whose names exist nowhere in the current source.
   Every measurement taken before this was describing an old map. Added
   tools/rebuild_fresh.luau, which clones the module to bust the cache; use
   it rather than requiring MapBuilder directly.

2. A reported "median never opens" bug was a bad probe, not a defect. It
   counted `Barrier` (the outer edge parapet, which must NEVER open) instead
   of `Median_<i>`. Re-run against the right name: control 7, junctions 0.

Both were only visible because the probes carry controls -- a 0-hit result
with no control is vacuous. The verification harnesses are kept in tools/
with their controls built in.
2026-08-03 11:53:40 -04:00
jesse badba0f7a4 Fix support columns: cylinder axis transposition + floating ramp piers
Two structural bugs found by measuring built parts, not by eye.

1. PierColumn (pre-existing, all 80 instances). Enum.PartType.Cylinder
   puts the circular faces on the local X axis, so Size.X is the length
   ALONG the axis and Size.Y/Z are the diameter. The code passed
   Size = (8, colH, 8) and then rotated 90deg about Z -- building a disc
   8 studs long and colH wide. Measured: world vertical extent 8.0 studs
   for a column that should stand 35.4, leaving every viaduct pier
   floating with a 13.7-stud gap down to the ground. Only the Size
   components were transposed; the rotation was already right.
   After: extent 35.4, base Y=0.0.

2. PARampPillar. Two problems.
   a) Built as 6x6 square blocks. The underside footage (honmoku_jct
      sheet3 -- mislabeled, it is actually shot beneath Daikoku itself)
      shows every column carrying the ramp decks is a smooth cylindrical
      concrete pier. Now Cylinder, same axis convention as PierColumn.
   b) Height was always segMid.Y - (ringY - 1), cutting the column off
      at Y=45, the underside of the PA apron slab. Correct only while
      the ramp is over the slab; the measured helix climbs up to 1182
      studs beyond the slab edge, where nothing exists at Y=45. Base is
      now chosen per-pillar: apron level over the slab, ground (Y=-1)
      beyond it, matching the established viaduct Pillar convention.
      After: all bases at Y=-1, 0 pancakes, min height/width ratio 1.7.

Ramp geometry, chevron alignment and gore zebra all unchanged:
0 misaligned panels (worst 0.06/0.08 studs), 832 gore bars, merge
mouths 24.0/23.8 studs from the nearest bar.
2026-08-03 06:33:59 -04:00
jesse 6c753505f0 Daikoku ramps: chevrons are barrier panels, not deck paint
Corrected against 4K dusk onboard footage of the junction
(jct_tsubasa_dusk, t=110..140s), which shows the previous pass got the
chevrons wrong in kind, not just in placement.

What the footage actually shows: red-and-white chevron delineators are
VERTICAL PANELS mounted on the outside face of the concrete barrier,
running continuously along the outside of each curve. The first pass
built them as flat red slabs lying on the road surface. On the deck they
were nearly invisible from the driver's seat, which defeats the point --
these panels are the main thing that makes a helix legible from inside
the car. Now modelled as 0.5 x 2.0 x segLen panels on the barrier face,
alternating red/white per segment (Roblox part granularity can't put a
diagonal stripe inside a single panel).

Also fixes a placement bug found by measurement. Offsetting the panel
along the raw radial (segMid - O) is correct on the circular arc but
wrong on the straight tangent tail, where the radial is no longer
perpendicular to travel: the panel line walked diagonally across the
deck instead of tracking its edge, up to 33 studs of along-travel error
over the last ~9 segments. Panels now always offset along the true
perpendicular (rightV); the radial only picks which side is outboard.

Measured on the built geometry:

  PAOnRamp   84 segs   0 misaligned   worst along-travel 0.06 studs
  PAOffRamp  64 segs   0 misaligned   worst along-travel 0.08 studs

(excluding the last 3 segments of each ramp, the merge mouth, which
carries no kerb or chevron by design so the merge stays open)

144 panels total, 72 red / 72 white. Ramp geometry is unchanged: grades
still 1.48% / 1.95%, rotation 344 / 282 deg.

Barrier colour was checked at the same time and left alone -- the green
cast in the night footage is sodium-vapour lighting on pale concrete,
not paint. C_PARAPET stays as is.
2026-08-03 05:45:17 -04:00
jesse f4f95ec9cc Daikoku PA: replace straight ramps with measured helix ramps
The PA on/off ramps were straight sloped decks running directly from the
ring gate to the mainline. Two measured problems:

  1. Grade. Straight ramps came out at 10.6% (entry) and 13.6% (exit).
     Japanese expressway ramps are held to roughly 6% maximum.
  2. Shape. The real Daikoku ramps are helixes -- they spiral down around
     a vertical axis. That is the interchange's single most recognisable
     feature and it was entirely absent.

Radii are measured, not chosen: an OSM Overpass pull of the interchange's
curved ways, each fitted with a Kasa circle fit. The two cleanest fits
(rms < 1.5 m, i.e. genuinely circular rather than merely curved) are
147 m and 124 m -> 525 and 443 studs at 3.571 studs/m.

Construction leaves the mainline tangentially (no step change in heading
at speed), wraps the circle while descending, then runs the tangent line
from circle to ring gate. Junction resolution now also captures the
mainline heading, which the tangential join requires.

Measured on the built geometry in Studio:

           segs   path            drop   grade   rotation
  entry     84    3775 studs      56     1.48%   344 deg
  exit      64    2866 studs      56     1.95%   282 deg

Grade drops 10.6% -> 1.48% and 13.6% -> 1.95%. Continuity is clean
(max Y step 0.99 / 1.31 studs across seams). Verified collision-free
against all mainline samples and the PA ring: closest approach to the
PA centre is 414 / 406 studs against PA_RADIUS 260.

Also adds red shoulder chevrons on the outside of each curve (72 parts),
which is what makes a helix legible from inside the car, and fixes the
kerb loop's merge-mouth test, which referenced the now-removed STEPS.
2026-08-03 03:11:06 -04:00
Hermes 4a702a4fdb docs(daikoku): measured reference spec for PA entrance geometry
Real-world numbers gathered from OSM (Overpass), Esri aerial imagery, and
frame-sampled onboard video. Replaces estimation with measurement.

Key findings:
  * Loop radii MEASURED via Kasa circle fit on OSM ways: 27.5-204 m,
    mean 131 m (469 studs). Two cleanest fits (rms<1.5) are the true
    helixes at 147 m (525 studs) and 124 m (441 studs).
  * 6 of 8 curved ramps turn CLOCKWISE - matches the video captions
    'right, right and you will reach the PA'.
  * FIVE deck layers in the interchange (OSM layer 1-5). Current
    PA_LIFT=56 models only one; faithful build wants 3+ overhead.
  * Real PA footprint 181x174 m (parking) / 203x227 m (rest_area).
    Current APRON 210x200 m already matches well - the divergence is
    SHAPE (square vs rounded wedge), not extent.
  * Scale clarified: PA work uses M=3.571 studs/m, NOT the 4.3 in
    c1_georeference.json (different mapping, 366 m median error).

Video reference archived to NAS 004-video-reference/ (364 MB, 4 routes).
2026-08-03 02:44:46 -04:00
jesse 16cba4ac32 feat(pa): clear generated PA dressing for Creator Store assets
Three changes, all behind flags rather than deletions so the surveyed
geometry is recoverable:

1. Stall paint off (DRAW_STALL_PAINT=false). Row placement math and the
   PACapacity record still compute; they just emit no parts.
2. PARing deck removed. It laid a 44-wide circular band of road deck around
   the PA, measured flush with the apron (both tops y=47.00) and entirely
   inside the apron footprint -- redundant pavement reading as a leftover
   ring where the old barriers were. The arc loop still runs; junctions,
   ramps and flares are still positioned off it.
3. Generated PA structures off (DRAW_PA_STRUCTURES=false): PAHonkan,
   PANibankan, PAToiletNorth, PAKioskWest, PAHutWest + glazing, and the
   props (Lawson, toilet block, fountain, smoking shelters, motorcycle bay,
   EV chargers) and their two signs. OSM survey positions preserved in code
   as the placement reference for the store assets.

Kept: PALowCarBump (gameplay, the 段差 anti-車高短 bumps) and the main
大黒PA sign.

Verified in live Edit datamodel after rebuild: all 13 removed part names
plus *Glazing report absent; PAFloor=1, PAPerimeter=78, PALowCarBump=3,
PARampFlare=2 remain; apron continuity 361/361 raycast sample points hit
solid ground, holes=0 -- no gap left where the ring deck was. 17385 -> 17277.
2026-08-03 01:23:46 -04:00
jesse 3a74e15f6e fix(pa): square perimeter barrier instead of circular ring edge
The remaining barrier was emitted per ring-road ARC segment, so it inherited
the ring's circle (radius ~412 about PA centre). The parking area is a SQUARE
828x828 apron, so an inscribed circle bulges past the lot at the four corners
and cuts inside it at the four edge midpoints -- it sliced across the parking
area rather than following its perimeter, which is the 'second ring'.

PARingEdge/PARingKerb are gone. Replaced with PAPerimeter: four straight runs
along the apron edge, 40-stud segments, 3.0 tall, with gaps opened where the
ramps cross the boundary so the lot is not sealed.

Verified in live Edit datamodel: PAPerimeter n=78, X -4536..-3711 (span 825),
Z -7882..-7057 (span 825) against the 828-stud slab -- square, matching the
apron. PARingEdge ABSENT, PARingKerb ABSENT.
2026-08-03 00:42:48 -04:00
jesse fed0c0ed20 fix(pa): single ring barrier - drop the inner kerb too
The previous commit only swapped the outer parapet+rail for a low edge and
KEPT the inner kerb, leaving two concentric barriers (69 outer + 64 inner)
still ringing the lot. From the driver's seat that is the same ring barrier
the user asked to ditch, and reporting it as 'gone' was wrong.

Inner edge now gets nothing: the parking area and its circulation road are
one continuous surface. Only the outer edge keeps a barrier, since behind
it is a drop off the structure.

Verified in live Edit datamodel: PARingKerb absent from the barrier
inventory (was 64), PARingEdge 69 @ 3.0 studs, parts 17874 -> 17810.
2026-08-03 00:33:41 -04:00
jesse e997f0f73d fix(pa): flush stall paint, drop ring parapet, complete ramp kerbs
Stall markings were 2.2-stud-tall slabs at DECK_Y+0.1, standing 1.2 studs
proud of the tarmac -- 434 concrete ridges across the parking rows. They
are paint, not kerbs: now 0.12 thick, sunk to sit 0.06 proud (verified
against PAFloor at y=47.0). Under tyre resolution.

Ring barrier: the outer edge was a 2.2-wide parapet + metal rail ringing
the whole lot. Replaced with a single 3.0-stud concrete edge (69 parts);
inner stays a 1.2 kerb. PARingWall/PARingRail are gone.

Ramp kerbs: the guard was still a height test (h > 2) after the earlier
h > 8 fix, and segment 0 of both ramps sits at h = 0.5..0.8 -- so the
barrier still began one segment up the ramp and visibly ended in mid-air
at the foot. Per-side probe confirmed seg0 had no kerb on EITHER side.
Height test removed entirely; only the merge mouth is now left open.

Verified in live Edit datamodel: PAOnRamp 13 segs / PAOffRamp 10 segs,
uncovered = merge segment only (seg12, seg9).
2026-08-03 00:23:03 -04:00
jesse 1e41de92fd fix(map): clear PA_LIFT over Honkan; complete ramp kerbs; open the merge
PA_LIFT 38 -> 56. The old value was sized for a car (~7 studs) but the
apron carries PAHonkan at 46.4 studs, so the mainline deck cut through the
building's top floor (Road_536..539). Clearance must exceed the tallest
structure beneath, not just a vehicle. LIFT_RAMP 18 -> 26 so the longer
climb keeps the same grade. Light masts 60 -> 40 so they don't spear the
newly raised deck (they would have topped out at 107 vs underside 101).

Ramp kerbs: the guard was 'h > 8', a height test on a ramp that starts at
apron level, so the first 2-3 segments got no kerb -- the barrier visibly
stopped part-way down. Now 'h > 2'. Conversely the kerb ran all the way to
the mainline end, leaving a 2.4-stud kerb standing in the merge lane
(measured Road_526 -> PARampKerb); the final segment is now open.

Verified in a live Studio datamodel: Honkan roof 93.4 vs deck underside
101.0 (7.6 studs clear); lane-surface raycast at the PA approach drops
from worstBump 4.751 to 0.254 studs.
2026-08-02 23:29:55 -04:00
jesse 6b0e3b75e4 tools: viewport capture + cache-bust rebuild harness
screen_capture DOES work via the bridge (capture_id='ScreenCapture_1');
the prior assumption that the Studio viewport couldn't be captured from
this box was wrong. capture_shots.py sets the Edit camera per shot and
pulls a jpg for each angle.

rebuild_cachebust.luau documents the require() cache trap: pushing new
.Source does not invalidate the per-instance module cache, so a rebuild
silently runs the OLD code and the diagnostic reports already-fixed
failures. Swapping in Clone()s at the same paths forces a fresh compile.
2026-08-02 16:30:20 -04:00
jesse c8dabea53a Daikoku PA: lift mainline over the apron, rebuild both ramps
The PA ring road and the expressway mainline were both at ground level, so
the ring parapet stood INSIDE the carriageway (measured -10.0 studs of
vertical overlap: a wall in the middle of the road).

Reference photos of the real Daikoku Futo PA show the expressway flying
OVER the parking apron on pillars, with the PA at ground level and ramps
climbing up to meet it. Rebuilt to match:

- mainline lifted 38 studs through the junction, eased in/out (max 5.26%
  grade, within real expressway limits)
- PA ring + apron stay at ground level; legacy PARingPillar props removed
- both on/off ramps regraded over the full rise with smoothstep easing,
  segments overlapped 3 studs so wheels can't catch a seam, plus support
  pillars and edge kerbs where the deck is high

Verified in-engine against built parts (tools/diag_daikoku.luau), not just
source: clearance now +28.0 studs, both ramps continuous (max step 5.62,
no gaps). tools/plot_daikoku.py renders the plan/section for comparison.
2026-08-02 15:56:13 -04:00
jesse 8808b919c3 Rebuild Daikoku Futo PA from measured real-world data
The PA was a placeholder: 9 painted stripes and 4 lights on a slab.
Rebuilt it against sourced figures rather than recollection.

Sources:
  * OpenStreetMap way 59608205 (大黒ふ頭パーキングエリア) -- the apron is
    181 m x 174 m / 1.6 ha, i.e. essentially SQUARE, not circular.
  * ja.wikipedia 大黒パーキングエリア -- 首都高速で最大のPA, opened
    1989-09-27. Capacity 小型 341 + 大型 59 + 身障者用 4 = 404, now
    reproduced EXACTLY (11 rows x 31, 20/20/19, 1 row x 4).
  * 本館 on the west side, ベイサイドテラス二番館 on the north, per the
    same article. EV rapid charger (first on a Japanese expressway,
    2008-10-10) and the 段差 bumps retrofitted to deter lowered cars.

Stall dimensions (小型 2.5x5.0 m, 大型 3.3x12 m, 6 m aisles) are
conventional JP parking-design values and are commented as NOT
primary-sourced, unlike the 全標協 road markings. Building footprints
are art-directed to the aerial and flagged as such.

tools/verify_daikoku_pa.luau asserts capacity, containment, block
separation and building placement in-engine: 20 passed, 0 failed.

Two bugs found and fixed by that harness:
  * The hand-placed 小型 Z origin overlapped the 大型 bays by 42 studs.
    The origin is now DERIVED from the 二番館 frontage; clearance is
    61.4 studs. The first version of the overlap test compared
    consecutive head lines (always 0 for back-to-back rows), which was
    meaningless and hid this.
  * push_mapbuilder.py lost a byte whenever a chunk began with a
    newline -- Lua long-bracket literals swallow a newline directly
    after the opening bracket (assembled=65158 vs 65159). It now emits
    a deliberate leading newline; the push verifies match=true.
2026-08-01 19:26:48 -04:00
jesse 7d368d5444 Render C1 structural variety: girder bridges, Ginza overpasses, JCT speeds, 3+3 widening
Four fidelity fixes, all verified in a live Studio datamodel (not just built):

1. Wire up the dead `bridge` flag. Circuit.luau marks 474 samples as bridge
   spans but nothing ever read the field, so every elevated metre rendered as
   the same slab on square columns. Flagged spans now get a steel box girder
   (鋼箱桁橋) with stiffener ribs under the deck, carried on a transverse pier
   cap and round columns at a wider pitch than the viaduct pillars.

2. Ginza 跨道橋. The Ginza section sits below street level in the filled-in
   楓川・築地川 river beds and the surviving city bridges cross over it, their
   piers landing in the median. Adds the crossing decks, parapets and centre
   piers -- which is the physical reason lane changes are banned there, not
   just a paint choice.

3. Junction speed plates. 江戸橋JCT内 / 浜崎橋JCT内 / 京橋JCT-旧白魚橋乗継所 are
   posted 40 km/h, not the 50 km/h mainline limit; plates repeat more often
   through the short restricted stretches.

4. Shiodome JCT -> Hamazakibashi JCT widened to 3+3 lanes via widthAt(), with
   a taper (max ~1.7 studs/sample) instead of a step. Every fixed-width
   consumer -- deck, median, parapets, lane marks, signage offsets, skyline
   setback, finish line -- now reads widthAt() so nothing floats or clips.

Verified headlessly (tools/verify_c1_fixes*.luau):
  girders 488 / 474 flagged samples, pier caps 85, columns 80
  Ginza overpasses 7, piers 7, lateral offset from centreline 0.000 studs
  speed plates 40km/h x7, 50km/h x8
  deck width Shiodome 81.5 vs Kasumigaseki 60.0
  seam lip max 0.0006, road-surface raycast step max 0.260, 0 deck holes

Two earlier "failures" were bad tests, not bad geometry, and the fixed
harnesses document the traps: deck parts are pitched by CFrame.lookAt so
Position.Y+Size.Y/2 is not the surface height (and LookVector is -Z, so the
end faces are easy to swap), and a seam raycast must filter to the Expressway
folder or it hits TunnelCeiling/MedianFence and reports a phantom 19-stud step.
2026-08-01 18:10:00 -04:00
jesse 967385261d Rebuild road markings, signage and lighting to verified JP expressway spec
Road markings now follow the 全標協 (Japan Road Marking Association) 2012/2018
standard rather than eyeballed values:

- Lane dividers: 8 m painted / 12 m gap on a continuous 20 m cycle carried
  across segment boundaries (previously each segment restarted its own dash
  phase, so the rhythm broke at every joint).
- Line widths corrected to 0.15 m lane / 0.20 m edge. The 0.45 m width often
  quoted for expressways applies only to the boundary against a 変速車線
  (accel/decel lane) -- now used at ramps only, where it belongs.
- Yellow SOLID no-lane-change lines through tunnels and tight curves, per the
  standard's rule that the boundary goes solid where overtaking is undesirable.
- 導流帯 gore zebra at ramp junctions: 45 cm bars at 1.0 m pitch.

Signage:
- Guide-sign green corrected to the official #005D35; route badges #007F1F.
- Bilingual Japanese + romaji layout (JP above, romaji below).
- Added 規制標識 speed limit plates posted at 50 km/h -- the C1 Inner Circular
  limit. (80 km/h is the Bayshore Route; the two are not the same.)
- Added キロポスト distance markers every 0.5 km.

Lighting/furniture:
- Tunnel lighting changed from blue/teal neon to warm sodium + cool LED runs,
  and made continuous rather than every-other-segment.
- Deck lamps on a ~34 m pitch; added reflective delineator posts.

Verified in-engine by headless measurement, not by inspection: 14/14 geometric
assertions pass (dash length/width, edge width, yellow count, zebra pitch,
light colour, sign colour/bilingual, speed+km posts, markings flush with deck).

Sources and the corrections to widely-repeated wrong figures are recorded in
docs/japanese-road-standards.md.
2026-08-01 17:08:39 -04:00
jesse 85730f0038 Fix monster-truck stance and head-through-roof clipping
Wheels: WHEEL_RADIUS was 2.0 (4.0-stud tyre on a 19-stud car = 21% of
length; real cars are ~14%). Derived from SHELL_LENGTH instead -> 2.70
dia, ratio 0.142. Suspension drop 2.2 -> 0.85 and GROUND_OFFSET
correspondingly lowered, dropping the body onto the wheels.

Roof fit, three separate bugs:
1. HEAD_ABOVE_SEAT (3.27) was the head CENTRE, not the crown. A default
   R15 head is 1.20 tall so the crown is 3.87 up -- the constant was
   short by half a head, which is exactly what poked through.
2. The adaptive seat solve measured the roof from the shell's bounding
   box top, which includes spoilers/antennas/stray parts (up to +0.74
   of junk). Now uses the largest part's top -- the real roofline.
3. wanted/seatLocal is a pan height but HEAD_ABOVE_SEAT is measured from
   the seat CENTRE, so every car silently lost Size.Y/2 of clearance
   (hyper_x got 0.07 instead of the requested 0.30).

Also lowered SHELL_DROP 0.6 -> 0.25 and let the seat clamp sink to
GROUND_OFFSET - SHELL_DROP, since hyper_x has the lowest roof (4.64)
and had no headroom to give.

Verified live in Play, avatar seated, all five models INSIDE the roof:
hyper_x 0.33, rx7_fc 0.31, silv_s15 0.37, gtr_r34 0.44, hatch_junk 1.09
2026-08-01 02:08:31 -04:00
jesse 37a83e0e8f Map: divided expressway with median, edge parapets+railings, Daikoku Futo PA ring road
Reworked the mainline and Daikoku Futo PA against real references (Wikimedia
aerials of Daikoku JCT + OSM way data for the junction) rather than guesswork.

- Divided carriageways: raised median kerb with dark anti-glare fence down the
  centre, matching OSM (lanes=2, oneway=yes per carriageway).
- Edge containment: white concrete parapet topped by a dark metal railing --
  the signature Shutoko silhouette -- backed by a kill wall so cars cannot
  leave the deck.
- Palette corrected from dark grey to Shutoko pale concrete; asphalt lightened
  to mid-grey to read correctly in daylight.
- Daikoku Futo PA: replaced the single straight stub ramp with a full loop road
  encircling the parking apron (real loop ramps measured at 48m and 121m
  radius), elevated on pillars, reachable from BOTH carriageways via on/off
  ramps. Median and barriers open at those junctions so the ramps connect.
- Removed the old four-sided perimeter box; the apron now extends under the
  ring, so there is no exposed edge to fall off.

Verified by executing MapBuilder headlessly under a stubbed Roblox API:
build ok, 823 road segs, 797 median segs (26 junction gaps), 1594 barriers +
1594 railings + 1594 kill walls, 72 ring segs at constant 389.6 radius,
apron covering the ring, all decks continuous at Y=46.
2026-07-31 19:46:53 -04:00
Hermes 0c5ecd2487 Real car meshes + physics-derived suspension + inPA/void fixes
CI / quality (push) Failing after 56s
Car meshes:
- CarManager: clone ReplicatedStorage/CarModels/<carId> as a visual Shell,
  weld to Body, hide placeholder blocks. Falls back to procedural CarBody
  when no mesh exists, so cars can be converted one at a time.
- Shell parts are Massless/CanCollide=false/CanQuery=false: the constraint
  chassis and driving feel are untouched. Verified 0 violating parts live.
- 5 catalogue cars mapped from the 67-model ServerStorage/CarLibrary import.

Suspension (ref docs/CAR_PHYSICS_REFERENCE.txt, devforum 3915628):
- BUG: old k=9000 gave 1.10 studs static sag against 0.90 studs total travel,
  i.e. the suspension sat permanently bottomed out and the spring did no work.
- Stiffness now solved from a sag target (70%% of half travel); damping now
  derived as a damping ratio c = zeta*2*sqrt(k*m) instead of an unrelated
  constant. Old pair worked out to zeta=0.31 (floaty); now 0.55-0.63.
- Measured after fix: ride height 1.97 studs, bounce 0.000, body roll 0.002.

Also: Circuit.inPA square test (PAFloor is 520x520, test was a r=260 circle,
corners falsely reported outside and spuriously armed runs), and void-fall
recovery now moves the character to the spawn pad BEFORE building the car.
2026-07-31 19:04:32 -04:00
jesse f70a0b06bb fix(client): stop the chassis watchdog dying on a nil seat during respawn
CI / quality (push) Failing after 47s
The stale-loop check indexed seatPart without a nil guard:

    not seatPart:IsDescendantOf(workspace)

On respawn CarManager destroys the old car, so humanoid.SeatPart is nil for a
frame while heartbeatConn is still live. That path then indexed a nil value,
the error propagated out of the task.spawn thread, and the watchdog - the only
self-healing path in the controller - DIED PERMANENTLY. The car was undrivable
from then on, and the bug looked intermittent because the edge-triggered
SeatPart signal still covered the simple nil -> seat transition.

Nil-check seatPart before indexing, and wrap the whole watchdog body in pcall
so no future error can ever silently kill the loop again (warn instead).

Verified: 5 consecutive respawns then drives to MaxSpeed 163 studs/s at road
level (up=1.00), TIME_ATTACK sessionKm advances 0.003 -> 0.169 km.
2026-07-31 16:15:55 -04:00
jesse c8809efa7b fix(client): re-arm chassis when the drive loop is bound to a stale car
CI / quality (push) Failing after 47s
Respawning left the car undrivable. CarManager.spawn() destroys the old car
and builds a new one, but the client's Heartbeat connection survived and kept
driving the DESTROYED car's motors. Because heartbeatConn stayed non-nil, the
cold-start watchdog concluded the chassis was already armed and never armed
the new car - torque stayed at the idle brake value and the car never moved.

Track the seat the live loop is bound to (drivingSeat) and treat the loop as
stale when that seat isn't the one we're sitting in, or its car has left the
workspace. On stale, stop and re-arm against the new car.

Verified: after 4 respawn cycles the car accelerates 49 -> 163 studs/s,
travels 273 studs, stays upright at road level.
2026-07-31 16:00:06 -04:00
jesse 3ae71f9dd3 fix(client): level-triggered watchdog arms chassis on cold start
CI / quality (push) Failing after 53s
The drive loop was armed purely edge-triggered from
humanoid:GetPropertyChangedSignal("SeatPart") plus a one-shot catch-up
applySeat() at init. On cold boot the server seats the player AFTER that
catch-up runs, so the edge is lost, startDriving() never connects, and all
four wheel motors stay at MotorMaxTorque = 0 - the car is completely
undrivable until the player manually gets out and back in.

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

Verified on cold boot (no manual reseat): all 4 motors uniform, poisoned
torque values restored within 0.6s, car accelerates to MaxSpeed 163 studs/s,
holds it upright, and engine-brakes 163 -> 0.
2026-07-31 15:52:12 -04:00
jesse 45069188be fix(CarManager): repair car torn apart on spawn by client-owned character
Sit() dragged the seat's body assembly ~15,700 studs to the client-owned
character while the knuckle/wheel assemblies stayed at the spawn pad,
tearing the car apart; wheels then fell out of the world.

Server-side character:PivotTo() does not stick (client has authority) --
it holds one frame and snaps back, so the pre-seat teleport could not fix
this. Instead: record each part's offset from the body while the model is
intact, take server network ownership, let Sit() and the snap-back settle,
then reassemble every assembly around the body (forced level) and hand
ownership back to the driver.

Verified in Studio: max part separation 6.42 studs (exhaust, normal),
up-vector 1.00, driver seated, drove 98.6 studs at 41 studs/s intact.
2026-07-31 13:00:33 -04:00
jesse d45f35736a fix: car now drives - collision groups, rigid hubs, pad spawn, anticheat grace
Four independent bugs kept the car immobile at the world origin:

1. Wheels collided with their own chassis. Wheel inner edge sits at 2.80
   vs body edge 3.00, and wheel top -0.20 vs body bottom -0.70. The
   overlap is intentional styling, but in one collision group the wheels
   ground against the body: motors spun ~1 rad/s against 20,000 torque.
   Wheels now use a CarWheel group that ignores CarChassis.

2. Suspension exploded on spawn. PrismaticConstraint+SpringConstraint
   between a 0.6-stud hub and the ballasted body was unstable enough to
   fling knuckles ~10,000,000 studs out, past FallenPartsDestroyHeight,
   deleting the wheels. Replaced with a rigid welded hub mount.

3. Car spawned at world origin, not the pad. MapBuilder made a SpawnPad
   part but no SpawnLocation, so characters spawned at (0,0,0) and Sit()
   dragged the car to them. Added a real SpawnLocation and the model is
   now pivoted after parenting, with the character teleported first.

4. AntiCheat snapped that legitimate spawn teleport back. An ~8,500-stud
   server move reads as a teleport hack. Added AntiCheat.grant() so
   server-initiated moves are exempt for a short grace window.

Verified in Studio: 881 studs in 6s, 496 studs/s top speed, 152 deg
steering, braking 33 -> 0 studs/s, chassis stays upright.
2026-07-31 10:04:47 -04:00
jesse 766fce5088 test: add automated map verification harness
Eight geometric assertions against the built map, runnable from Linux over
the Studio MCP bridge. Each check tests the world that exists rather than
trusting that a build reported success.

Check 1 is the important one: it fresh-requires Circuit and asserts
823/200/474. Studio caches ModuleScripts, so a stale Circuit will happily
build a plausible-looking map of the wrong circuit -- which is exactly what
happened before. The shape checks are meaningless without it.

Found and fixed while writing this: a Sound with a doubled rbxassetid://
prefix in the imported CarLibrary chassis.

Probe note: pillar tops are flush with the deck by design (0.00 clearance),
so the car-envelope probe must ride fully above the road surface or it
reports its own supports as intrusions.
2026-07-31 08:25:57 -04:00
jesse 0dbd7420d4 map: signature crossings + fix skyline setback clipping the road
buildBridges() adds the two landmark structures the generated road was
missing, driven off the existing per-sample tunnel/bridge flags in
Circuit.luau (200 tunnel / 474 bridge samples, recorded at trace time):

  * NihonbashiViaduct  - box girder carried on piers over the historic
    stone bridge, the C1's most recognisable stretch.
  * Shiodome cable-stayed span - twin H-towers with fanned stays.

Also fixes buildSkyline() placing geometry inside the racing surface:

  * the setback test compared the building CENTRE against 220 studs, so a
    220-wide tower still reached 110 studs inward - through the tunnel
    bore walls. Now tests the outer face (near - w/2) against
    ROAD_WIDTH/2 + 60.
  * it strided samples by 3, skipping the closest approach on tight
    corners. Now tests every sample.
  * NeonSign hangs off the +X face and was never clearance-checked at
    all; it now gets its own test.

Verified in Studio against a full rebuild (823 samples -> 2468 road
parts): 400 tunnel walls / 200 ceilings for the 200 flagged samples,
621 buildings placed, and raycasts over every road part report 0
headroom and 0 lateral obstructions. Minimum building clearance 47.8
studs.
2026-07-31 02:13:55 -04:00
Hermes 1f888b3693 map: rebuild circuit on real Shuto C1 geometry from OpenStreetMap
CI / quality (push) Successful in 56s
Replace the invented rounded-rectangle circuit with the actual Shuto
Expressway Inner Circular Route (relation 4256008) traced from OSM and
projected to studs at 1 stud = 0.28 m.

- Circuit.luau: 823 evenly-spaced samples (60 studs), 49,388 studs /
  13.83 km lap. Real junction names (Tanimachi, Miyakezaka, Kasumigaseki,
  Ginza, Kyobashi, Edobashi, Hitotsubashi), real tunnel + viaduct
  sections. Deck height smoothed to max 4.3% grade.
- MapBuilder.luau: builds road, guardrails, kill walls, lit tunnel bores,
  viaduct pillars, junction gantry signs, Daikoku-style PA, and Tokyo
  landmarks (Tower, Skytree, Imperial Palace moat, Station, Ginza,
  Shiodome, Roppongi Hills, National Diet) at true relative positions.
- Contract names (SpawnPad, FinishLine, KillWall) unchanged, so gameplay
  code is untouched.
2026-07-30 21:26:49 -04:00
152 changed files with 21453 additions and 755 deletions
+1
View File
@@ -15,3 +15,4 @@ wally.lock
.DS_Store .DS_Store
*.log *.log
audio-work/ audio-work/
tools/shots/
+139
View File
@@ -0,0 +1,139 @@
# CLAUDE.md -- Tokyo Wangan Racing
Guidance for Claude Code running **natively on `windows-gpu`**, the same machine
as Roblox Studio. Pure ASCII on purpose (PowerShell 5.1 reads non-ASCII as ANSI
and desyncs the parser).
---
## 1. Read this before touching MapBuilder
`src/server/MapBuilder.luau` generates the map procedurally. The user also edits
geometry **by hand in Studio**. Those two facts collide, and that collision is
the single most important thing about this repo:
> **A rebuild regenerates anything the source still describes.**
> If the user deleted geometry by hand and you rebuild without teaching the
> source about that deletion, you silently put it back.
The user WILL notice, immediately, and will say so ("you just added back the X
that I deleted"). When that happens: **stop editing, run a read-only probe,
report exact numbers.** Do not "fix" it with another rebuild.
### The live invariant: aux-lane suppression
```lua
local AUX_SUPPRESS_FROM = 525 -- samples 525..550 have NO aux lane
local AUX_SUPPRESS_TO = 550 -- because the user deleted that run by hand
local AUX_BLEND = 5 -- smoothstep width ramp, do not hard-cut
```
- **Do not remove or widen this range.** Re-enabling it resurrects deleted geometry.
- `AUX_BLEND = 5` is not cosmetic. A hard cut produced a **26-stud lateral fence
jog**. The smoothstep removes it.
- The parapet is force-closed across the same range (~line 1031). Normally the
parapet OPENS along a speed-change lane because the aux lane re-fences the
outer edge -- with no aux lane there is nothing to re-fence with, leaving the
deck edge bare. That was the measured barrier gap at samples 527..548.
Any change touching aux lanes, parapets, or barriers must re-run
`tools/probe_regression.luau` and match the baseline below.
---
## 2. Verified baseline (commit 6e50f97)
Measured in the live datamodel, not inferred from source:
| Metric | Value |
|---|---|
| total parts | 16788 |
| PAWestSlip | 60 segs, 34.4 deg total bend, worst 1.4, 0 kinks >5 |
| PAEastSlip | 60 segs, 30.7 deg total bend, worst 1.3, 0 kinks >5 |
| AuxLane parts | 30 (samples 525-550 deliberately absent) |
| barrier coverage | 823 samples, 0 unguarded |
| fence jogs >10 studs | 0 |
`AuxLane = 30` is the **user's hand-edited count**, not the source's original 52.
If a probe reports 52, a rebuild has undone the suppression -- that is a
regression, not progress.
---
## 3. Two channels, different jobs
```
[A] MCP bridge (roblox_studio) -> READ run Luau in Studio, measure, screenshot
[B] Rojo -> WRITE push .luau source into Studio (~1 s)
```
**Use [A] to verify what [B] did. Never assume a sync landed.**
Rojo syncs **scripts, not built geometry**. Editing MapBuilder replaces the
ModuleScript instantly, but `workspace.Map` still holds parts from a PREVIOUS
`build()` run. The map looks stale until you re-run the build. Do not tell the
user "you'll see it live" about map geometry.
**Rojo version must match the Studio plugin.** The plugin auto-updates from the
Marketplace without asking. Server older than plugin fails the handshake with a
cryptic `attempt to index number with 'protocolVersion'`. Both are pinned at
**7.7.0** (`rokit.toml`). If the user reports a connect error, check this first.
---
## 4. Workflow
```bash
make check # stylua --check + selene + rojo build
make build # rojo build --output build.rbxl
make serve # rojo serve (Studio: Rojo -> Connect)
make fmt # stylua src/
```
Probe scripts in `tools/` are **read-only** -- they measure the live datamodel
and never build. Run them through the MCP bridge.
| Script | Answers |
|---|---|
| `probe_regression.luau` | full baseline check vs the table above |
| `verify_auxlane.luau` | is the 525-550 suppression intact? |
| `verify_slips.luau` | slip-road bend angles / kink count |
| `probe_barriergap.luau` | any unguarded deck edge? |
| `probe_jog.luau` | lateral fence jogs |
---
## 5. Hard-won rules
- **Measure, then fix.** Do not infer a bug from symptoms. One inverted
convention can produce three unrelated-looking symptoms; fixing each symptom
leaves the root cause intact.
- **A metric derived from a constant cannot fail.** Ask: would this number
differ between the broken and fixed build? If not, it is not a test.
- **An empty result set must FAIL, never pass.** Count what you actually
exercised; `if tested == 0 then return "NOTHING TESTED -- FAILURE" end`.
- **Verification in Play mode proves nothing.** Play is a disposable sandbox
discarded on stop. Verify in Edit mode.
- **require() caches stale bytecode after a Rojo edit.** The cache still holds
old bytecode even though `.Source` is new -- no error, no warning. Clone the
module and require the clone.
- **Enumerate part names, don't just count.** A raw total hides partial removals
(deleted the tall barrier, missed the kerb). Tally what should be gone vs
what should remain, and report both.
- **AutoRecovery places are silently stale.** After a crash Studio reopens
`*_AutoRecovery_1.rbxl` holding pre-crash state. Check the title bar.
- **Creator Store assets: 50+ scripts or 1M+ tris is zero-tolerance.** Get exact
counts before inserting anything.
---
## 6. Shell
Git Bash (`C:\Program Files\Git\bin\bash.exe`) is present -- prefer it.
PowerShell over a non-interactive pipe mangles quoting. `&` is not a statement
separator in PowerShell. When PowerShell is unavoidable non-interactively, use
`-EncodedCommand` with UTF-16LE base64.
`StudioMCP.exe` lives inside the versioned Studio install and **moves on every
update**. Never hardcode it; `~/claude-tools/studio-mcp.bat` resolves it
dynamically.
+357
View File
@@ -0,0 +1,357 @@
# PROJECT-BRIEF.md — What we're building and where everything is
> **Read this first, before `CLAUDE.md`.**
> `CLAUDE.md` tells you *how to operate the tools safely*.
> This file tells you *what the game is, why it's built this way, and where the
> resources live*. You need both.
>
> Verified against the live repo, the NAS, and Gitea on **2026-08-06**. Every
> path and number below was checked, not recalled.
---
## 1. The game in one paragraph
**Tokyo Wangan Racing** is a Roblox night-driving game set on a neon Tokyo
expressway, built around a real 1993 Mazda RX-7 the owner actually owns. It is
modelled on **Tokyo Xtreme Racer**, not on lap-racing games: **Daikoku Futo PA
(the parking area) is the hub.** You park, choose how you want to run, and drive
out onto the Wangan. You never "join a match."
The core bet: **driving feel is the whole game.** Racing, economy, and
progression are all deliberately deferred until the car is genuinely fun to just
drive around. Do not add game layers on top of a car that doesn't feel good yet.
### The three modes
Selected in the PA, armed when you cross the exit gate, ended when you pull back
into the PA. Defined in `src/shared/Modes.luau`.
| Mode | 日本語 | What it is | Challenges? | Timed? |
|---|---|---|---|---|
| **CRUISE** | 流し | Free roam, bank kilometres, meet drivers | yes | no |
| **DUEL** | バトル | Open a gap and *hold* it — who breaks first | yes | no |
| **TIME ATTACK** | タイムアタック | Solo flying lap, sector splits, personal best | **no** | yes |
Cruise and Duel both allow challenges, so a cruise turns into a battle
organically. **Time Attack opts out on purpose** so a hot lap is never ruined —
that's a design decision, not an oversight. Don't "fix" it.
### The circuit
Closed loop, **~49,400 studs (13.8 km)**, samples evenly spaced 60 studs apart so
the loop closes seamlessly. Roughly a 2:46 lap at 300 km/h. Scale is
**1 stud = 0.28 m**. Coordinates: `+X = east`, `+Z = north`.
Landmark zones (themed, **not** geographically accurate): Daikoku Futo PA,
Rainbow Bridge, Odaiba (Unicorn Gundam + ferris wheel), Yamate-style tunnel,
Tokyo Tower replica, Tatsumi PA, bilingual green signage (大黒PA, レインボーブリッジ,
お台場, 辰巳PA).
---
## 2. Where you are in the plan
Source of truth: `docs/ROADMAP.md` and `docs/GDD.md`. Current position:
- **M0 — drive-only MVP: DONE.** Spawn, drive, switch cars, respawn, persistence.
- **M1 — make driving feel GOOD: IN PROGRESS. This is the current work.**
- done: constraint chassis (springs, prismatic suspension, servo steering,
motor wheels, engine braking), chase camera, two-layer engine sound
- **not done: import the real RX-7 mesh** (`docs/MESH_SWAP.md`)
- **not done: real 13B rotary audio** from the owner's actual car
(`docs/ROTARY_SOUND.md`) — current loops are stand-ins
- **not done: tune accel curve + grip** — needs a Studio playtest
- **M2 — more cars + map polish:** map v3 and ambience done; more meshes pending.
- **M3+ — racing, economy, hardening, traffic AI:** deliberately not started.
> **M1's exit test:** *the RX-7 is genuinely fun to just drive around.*
> Ship nothing else until that's true. If asked to build a racing/economy
> feature, flag that it's M3/M4 and confirm before starting.
---
## 3. Repo layout and architecture
Repo: `~/roblox/tokyo-wangan-racing` (Git Bash path on this box).
**Server-authoritative: clients request, the server decides.** The RX-7
allowlist and anti-cheat are server-side. Keep the client dumb.
```
ReplicatedStorage/Shared src/shared GameConfig, Cars, Circuit, Modes,
Chassis, Remotes, ProfileTemplate
ServerScriptService/Server src/server init.server + managers
ServerScriptService/ServerPackages ServerPackages ProfileStore (Wally)
StarterPlayerScripts/Client src/client HUD, car picker, controllers
```
Mapping is declared in `default.project.json` (Rojo).
| Module | Responsibility |
|---|---|
| `src/server/MapBuilder.luau` | **3,479 lines — the big one.** Procedurally builds the entire expressway, PA, ramps, barriers, signage. |
| `src/server/CarManager.luau` | Spawns the equipped car; enforces the RX-7 allowlist |
| `src/server/ModeManager.luau` | Mode selection, exit-gate arming, mode end |
| `src/server/DataManager.luau` | ProfileStore wrapper — session lock, reconcile, Studio mock |
| `src/server/AntiCheat.luau` | Speed/teleport sanity check — a speed bump, not a fortress |
| `src/server/Ambience.luau` | Atmosphere haze, bloom, 22:00→03:00 clock drift |
| `src/shared/Circuit.luau` | The 1,050-line circuit spline — sample positions, PA geometry |
| `src/shared/Chassis.luau` | Shared chassis tuning constants |
| `src/client/ChassisController.luau` | Drives the chassis client-side (driver owns network physics) |
| `src/client/CameraController.luau` | Damped chase cam, flat horizon, speed FOV kick |
| `src/client/SoundController.luau` | Two-layer engine crossfade + tyre squeal |
**Cars:** RX-7 (restricted), City Runabout (public default), Silvia S15, Skyline
GT-R R34, stylized hypercar. The **RX-7 is demo-locked** to the owner + his son
via `GameConfig.RX7AllowedUserIds`, enforced in `CarManager` and the `EquipCar`
remote. Stats only affect feel — no prices, no unlocks in the MVP.
---
## 4. Resources you can reach — and how
### 4a. Live Roblox Studio (this machine, local)
Two channels, different jobs. **Use [A] to verify what [B] did.**
```
[A] MCP bridge -> READ run Luau in the datamodel, screenshot the viewport
[B] Rojo -> WRITE push .luau source into Studio (~1 s)
```
- `mcp__roblox_studio__execute_luau` — `datamodel_type` **must be `"Edit"`**,
never `"Server"` (Server edits a throwaway Play copy that dies on stop).
- `mcp__roblox_studio__screen_capture` — Studio's **3D viewport only**. Not the
desktop, not Explorer, not Blender.
- Rojo: `rojo serve default.project.json --port 34872`, then **somebody must
click Rojo → Connect in Studio.** It never auto-connects, and an ESTABLISHED
socket on 34872 is idle heartbeat, *not* a sync session.
Full operational detail, version pins, and the failure decoder: **`CLAUDE.md`**.
#### Rojo must run natively on Windows — never over an SSH tunnel
**Rojo runs on the Windows box, in the Windows checkout.** Studio, Rojo and the
repo it serves all live on the same machine. Start it with:
```cmd
cd /d %USERPROFILE%\roblox\tokyo-wangan-racing
%USERPROFILE%\.local\bin\rojo.exe serve default.project.json --port 34872
```
A scheduled task `RojoTWR` does exactly this — `schtasks /Run /TN "RojoTWR"`.
(Launching via `start` over SSH does **not** work: the process dies when the SSH
session exits. Use the task.)
**Rojo does not stay up on its own.** The root cause was found on 2026-08-06:
**Windows OpenSSH kills the entire process tree when the SSH session exits.**
Anything started over SSH with `Start-Process` — including Rojo — dies seconds
later. This presents as "Rojo was running a minute ago and now the plugin won't
connect," and it is *not* a plugin fault.
Proof: `rojo procs: 1` inside the SSH session, `rojo procs: 0` from a new
session moments later.
The fix is to hand ownership to **Task Scheduler**, which is not part of the SSH
process tree:
```
schtasks /Run /TN "RojoTWR" start rojo detached (survives SSH exit)
%USERPROFILE%\rojo-guard.ps1 idempotent watchdog
schtasks /Query /TN "RojoTWR-Guard" runs the watchdog every 3 minutes
%USERPROFILE%\rojo-guard.log only logs real events, silent when healthy
```
Repo copy of the watchdog: `tools/win/rojo-guard.ps1`.
> **Never start Rojo with `Start-Process`/`start` over SSH.** Always go through
> `schtasks /Run /TN "RojoTWR"`. The guard itself follows this rule internally.
The guard is deliberately conservative:
- if `rojo` already owns 34872 it exits 0 and does nothing (safe mid-session —
it will not disturb a live Studio connection)
- if the port is held by **anything else** (e.g. an `sshd` tunnel) it refuses to
start and logs `PORT HIJACKED`, rather than masking the bug
- if `ServerPackages` is missing it logs that instead of letting Rojo silently
exit on startup
Verified end-to-end: killed `rojo`, waited, and the scheduler restored it
unattended (`Last Result: 0`, port owned by `rojo`).
**Clicking Rojo → Connect in Studio is still manual and always will be.** The
guard keeps the *server* alive; it cannot press the button. `Rojo_autoReconnect`
is `false` in the plugin settings, so after a drop you must reconnect by hand
(or flip that setting in the plugin).
> ⚠️ **Do not reverse-tunnel a Linux Rojo to port 34872.** It was tried
> (`ssh -N -R 34872:127.0.0.1:34872`) and it is actively harmful: the port
> answers, `/api/rojo` returns valid JSON, and everything *looks* healthy — but
> it is serving the **Linux** working tree. Studio then syncs files that are not
> the ones being edited on Windows, and `netstat` shows the port owned by
> **sshd** rather than `rojo`. If port 34872 is owned by anything other than a
> process literally named `rojo`, that is the bug.
>
> Diagnostic that settles it in one line:
> ```powershell
> $l=(netstat -ano | Select-String ":34872.*LISTENING"); (Get-Process -Id (($l[0] -split "\s+")[-1])).ProcessName
> ```
> Expect `rojo`. If you get `sshd`, kill the tunnel.
#### `ServerPackages` is gitignored — Rojo will not start without it
`/Packages/`, `/ServerPackages/` and `wally.lock` are **gitignored**, so a fresh
clone has no dependencies and Rojo **refuses to start**, exiting immediately:
```
[ERROR rojo] Rojo project referred to a file using $path that could not be
turned into a Roblox Instance by Rojo. File $path: ServerPackages
```
That error means *missing Wally deps*, not a broken project file. There is only
one dependency (`ProfileStore`, 19 files / 316 KB). Fix with `wally install`, or
copy the folder from a machine that already has it. **Rojo exiting on startup is
silent from Studio's side — it just looks like Rojo "won't connect."**
### 4b. Reference material on the NAS (1.5 GB, verified present)
Host `unraid-1` (10.10.10.43), reachable over SSH. Base path:
```
/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/
```
| Folder | Contents (verified counts) |
|---|---|
| `001-original-images/001-1993-Mazda-RX-7/` | **12 files** — the real car, for the mesh swap |
| `001-original-images/002-1993-Nissan-Skyline-GT-R/` | **38 files** |
| `003-google-earth-images/` | **12 files** — `daikoku-futo-01..12.png`, aerial geometry |
| `004-video-reference/` | `aerial/`, `contact-sheets/`, `docs/`, `entrance-routes/` |
| `002-completed-models/` | empty — finished models land here |
> ⚠️ **The README currently prints a stale path** for the RX-7 reference
> (`001-1993 Mazda RX-7/` with spaces, at the top level). The real path is
> `001-original-images/001-1993-Mazda-RX-7/` with hyphens. Use the real one.
Copy a file over rather than working blind:
```bash
scp unraid-1:'/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/003-google-earth-images/daikoku-futo-01.png' /tmp/
```
### 4c. Reference material committed in-repo
- `reference/ramps/` — 12 integrity-checked photos of Daikoku/Oi/Heiwajima/
Shibaura PAs, plus `INDEX.md`, `BUILD_SPEC.md`, and OSM/satellite scripts.
**All CC BY / CC BY-SA — reference-only for shape study. Do not trace directly
into shipped assets without attribution.**
- `docs/daikoku-reference-spec.md` — measured spec for the PA entrance geometry
- `docs/japanese-road-standards.md` — real road-geometry standards
- `docs/CAR_PHYSICS_REFERENCE.txt`, `docs/MESH_SWAP.md`, `docs/ROTARY_SOUND.md`
- `assets/audio/` — current stand-in RX-7 loops (idle / rev / whistle)
- `audio-work/` — raw recordings from real car meets, for the rotary audio work
- `c1_georeference.json` — georeferencing data behind the circuit
### 4d. Git / CI
Remote: `ssh://[email protected]:2222/jesse/tokyo-wangan-racing.git`
(self-hosted Gitea on UNRAID-1, SSH key auth already working from this box).
CI runs on a **self-hosted Gitea Actions runner** (`.gitea/workflows/ci.yml`),
Ubuntu 24.04 job container — rojo's binary needs glibc 2.39.
### 4e. Existing tooling — check before writing new probes
`tools/` already holds **109 files, 58 of them diagnostic/verification scripts**
(`diag_*.luau`, `verify_*.luau`, `capture_*.py`, `check_*.luau`). There is a
very high chance a probe already exists for what
you need — `verify_auxlane.luau`, `verify_shared_ramps.luau`,
`check_live_source.luau`, `scripts/verify_map.luau`, and capture helpers for
flyovers/ramps/defects. **Look there first.**
---
## 5. Quality gates — CI enforces all three
```bash
stylua --check src/ # formatting
selene src/ # lint
rojo build --output build.rbxl # must compile
```
Conventions:
- Tunables go in `GameConfig`, not scattered magic numbers.
- Add a save key by adding it to `ProfileTemplate` (auto-reconciles). Never
remove a key without a migration.
- Test in Studio after every server change (ProfileStore uses a mock in Studio).
---
## 6. The things that will bite you
These are compressed from hard-won experience. The long versions live in
`CLAUDE.md` — but these three matter most:
### Never regenerate hand-deleted work
**The single most important rule on this project.** If the user deleted
something in Studio, the *source* must honor it or the next rebuild silently
resurrects it — and they will catch it immediately.
Live example, currently in `MapBuilder.luau`:
```lua
AUX_SUPPRESS_FROM = 525 -- user deleted this aux-lane stretch
AUX_SUPPRESS_TO = 550
AUX_BLEND = 5 -- smoothstep; a hard cut caused a 26-stud fence jog
```
**Do not remove that suppression.** Hand-placed assets live in
`workspace.PAProps`, which the builder never touches. Before any rebuild, ask:
*does this regenerate something removed by hand?*
### Measure before reasoning, and re-measure after
Write a **read-only probe**, get real numbers, *then* form a theory. A code
change is not proof — verify against the bug's signature. Enumerate names, not
just totals: a raw count hides a partial removal.
### Beat the `require()` cache
After a Rojo sync the datamodel still holds **old bytecode** — no error, no
warning, and you will measure the previous build and believe it:
```lua
local tmp = game.ServerScriptService.Server.MapBuilder:Clone()
tmp.Parent = game.ServerScriptService
local MapBuilder = require(tmp) -- fresh bytecode, guaranteed
```
### Verified-good baseline (2026-08-06) — regression-check against these
| metric | value |
|---|---|
| total parts | 16,788 |
| PAWestSlip | 60 segs, 34.4° total bend, worst 1.4°, 0 kinks >5° |
| PAEastSlip | 60 segs, 30.7° total bend, worst 1.3°, 0 kinks >5° |
| AuxLane parts | 30 — **samples 525–550 deliberately absent (user-deleted)** |
| barrier coverage | 823 samples, 0 unguarded |
| fence jogs >10 studs | 0 |
### Never fabricate
If Studio is closed, say "Studio is closed." Never invent a screenshot, a part
count, or a measurement you didn't obtain. `{'error': '', 'isError': True}` on
every MCP call means **Studio is closed** — not that your script is broken.
Reporting a blocker honestly always beats a plausible-looking fake.
---
## 7. First five minutes in a new session
```bash
tasklist | findstr /I "Roblox" # 1. is Studio even running?
claude mcp list # 2. bridge registered + connected?
curl -s http://127.0.0.1:34872/api/rojo # 3. rojo serving?
cd ~/roblox/tokyo-wangan-racing && git status --short && git log --oneline -5
```
Then read `docs/ROADMAP.md` to confirm what milestone is active, and check
`tools/` for an existing probe before writing a new one.
**Working state as of 2026-08-06:** clean tree, 0 uncommitted files, HEAD at
`dc0df91`. (An older note in `START-HERE.md` warns about "36 uncommitted files"
on the Linux box — that has since been committed and pushed. Trust `git status`
over that note.)
+2 -1
View File
@@ -102,4 +102,5 @@ the `EquipCar` remote).
One function changes: `CarManager.buildCarModel`. Full step-by-step in One function changes: `CarManager.buildCarModel`. Full step-by-step in
`docs/MESH_SWAP.md`. Reference photos live on the NAS at `docs/MESH_SWAP.md`. Reference photos live on the NAS at
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/`. `unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-original-images/001-1993-Mazda-RX-7/`
(12 files). See `PROJECT-BRIEF.md` for the full reference-asset inventory.
+29
View File
@@ -0,0 +1,29 @@
{
"note": "C1 world<->real Tokyo georeference. X IS NEGATED vs source data.",
"world_to_real": {
"lon": "139.75654 + 2.528e-06*(-X)",
"lat": "35.67327 + 2.117e-06*Z"
},
"real_to_world": {
"X": "-(lon-139.75654)/2.528e-06",
"Z": "(lat-35.67327)/2.117e-06"
},
"orientation": {
"+Z": "NORTH",
"-Z": "SOUTH",
"-X": "EAST",
"+X": "WEST"
},
"scale_studs_per_meter": 4.3,
"median_error_m": 366,
"loop_center_world": [
180,
-180
],
"verified": [
"Nihonbashi east of Palace",
"Tokyo Tower SW outside loop",
"Ginza/Tsukiji east",
"Nagatacho/Kasumigaseki west"
]
}
+8 -7
View File
@@ -42,13 +42,14 @@
"$className": "Lighting", "$className": "Lighting",
"$properties": { "$properties": {
"Technology": "Future", "Technology": "Future",
"ClockTime": 0.0, "ClockTime": 14.0,
"Brightness": 1.0, "Brightness": 2.0,
"Ambient": [0.05, 0.05, 0.1], "Ambient": [0.35, 0.35, 0.38],
"OutdoorAmbient": [0.1, 0.1, 0.18], "OutdoorAmbient": [0.45, 0.46, 0.5],
"FogEnd": 900.0, "FogEnd": 6000.0,
"FogColor": [0.03, 0.03, 0.08], "FogColor": [0.55, 0.6, 0.68],
"EnvironmentDiffuseScale": 0.4, "EnvironmentDiffuseScale": 1.0,
"EnvironmentSpecularScale": 1.0,
"GlobalShadows": true "GlobalShadows": true
} }
} }
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
# Daikoku Futo PA — Measured Reference Spec
Source-of-truth numbers for building the Daikoku PA entrance/exit ramps and
apron in `MapBuilder.luau`. Everything here is **measured**, not estimated:
OSM geometry via Overpass (`/tmp/osm_daikoku.py`), Esri aerial imagery, and
frame-sampled onboard video.
Generated 2026-08-03.
---
## 1. Scale
| Quantity | Value |
|---|---|
| Project scale | `M = 1/0.28` = **3.571 studs per metre** (`MapBuilder.luau:69`) |
| Reciprocal | 1 stud = 0.28 m |
| `Circuit.STUDS_TO_KM` | 0.28 / 1000 |
> NOTE: `c1_georeference.json` records `scale_studs_per_meter: 4.3` for the C1
> loop georeference. That is a **different** mapping (real-Tokyo lat/lon → C1
> world) with a stated `median_error_m: 366`. Do **not** use 4.3 for PA
> geometry. PA work uses 3.571.
---
## 2. The real PA footprint (OSM)
Overpass, bbox 35.4545/139.6700 → 35.4705/139.6935. ENU metres about
`35.46246657, 139.68149001` (user's Google Earth point).
| OSM way | Type | Extent (m) | Perimeter | Name |
|---|---|---|---|---|
| `405966005` | `highway=rest_area` | **203 × 227** | 646 m | 大黒PA |
| `59608205` | `amenity=parking` | **181 × 174** | 507 m | 大黒ふ頭パーキングエリア |
| `88702527` | `amenity=parking` | 102 × 107 | 359 m | (satellite lot, W of main) |
**Current build:** apron is `APRON_X = 210*M`, `APRON_Z = 200*M`
(`MapBuilder.luau:991`) = 210 × 200 m. That sits between the parking polygon
(181×174) and the rest_area polygon (203×227) — **already a good match.** The
apron extent is not the problem.
The *shape* is the divergence: the build is an axis-aligned square; the real
lot is a rounded wedge bounded on the N/E by the loop ramp arc.
---
## 3. Loop ramp radii — MEASURED
Kasa circle fit over each OSM ramp way with |cumulative turn| ≥ 120°.
`rms` = fit residual (low = genuinely circular).
| OSM way | Length | Turn | **Radius** | rms | Layer | Direction |
|---|---|---|---|---|---|---|
| `32355896` | 475 m | −272° | **70.9 m** | 14.4 | — | CW / right |
| `32403901` | 601 m | −217° | **147.4 m** | **1.2** | 5 | CW / right |
| `32592491` | 729 m | +208° | **197.4 m** | 12.7 | 2 | CCW / left |
| `307397558` | 167 m | −178° | **27.5 m** | 9.0 | — | CW / right |
| `779045459` | 371 m | +161° | **123.6 m** | **0.7** | 3 | CCW / left |
| `32403902` | 656 m | −150° | **204.2 m** | 13.3 | 3 | CW / right |
| `32592481` | 359 m | −146° | **120.4 m** | 12.0 | 1 | CW / right |
| `32592487` | 488 m | −122° | **158.8 m** | 11.3 | 2 | CW / right |
**Headline numbers**
- Radius range **27.5 – 204 m**; mean **131 m** = **469 studs**
- The two cleanest circular fits (rms < 1.5) are the true helixes:
- `32403901` — **147 m** (526 studs), 217° of turn, layer 5
- `779045459` — **124 m** (441 studs), 161° of turn, layer 3
- **6 of 8 curve clockwise (right).** Matches the video captions exactly:
`右、右と覚えておくとPAに着きます` — "remember right, right".
**Recommended build values**
| Ramp | Radius (m) | Radius (studs) | Turn |
|---|---|---|---|
| Main entry helix | 147 | **525** | ~220° CW |
| Secondary helix | 124 | **440** | ~160° CCW |
| Tight connector | 71 | **255** | ~270° CW |
Current `Circuit.PA_RADIUS = 260` studs = 72.8 m — which is almost exactly the
tight connector (`32355896`, 70.9 m). The *main* helixes are roughly 2× that.
---
## 4. Deck layers — the vertical stack
OSM `layer` tags on bridge ways in the interchange:
| Layer | Bridge ways | Total length |
|---|---|---|
| 1 | 11 | 17,696 m |
| 2 | 7 | 5,637 m |
| 3 | 8 | 5,368 m |
| 4 | 10 | 10,096 m |
| 5 | 6 | 1,417 m |
**Five distinct deck levels.** This is the single most important structural
fact: Daikoku JCT is a five-level stack, and the PA sits at the bottom of it.
Current `Circuit.PA_LIFT = 56` studs (15.7 m) of clearance over the apron —
that is one level. A faithful build wants at least 3 visible levels overhead.
Typical Japanese expressway deck-to-deck spacing is ~10–12 m; at 3.571 studs/m
that is **36–43 studs per level**.
---
## 5. Video-derived detail
Sources archived at
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/004-video-reference/`
| Route | Video ID | Len | Light | Value |
|---|---|---|---|---|
| CMT TOKYO "How to go to?" | `91suWmNr62c` | 2m50 | night | narrated turn-by-turn |
| Bay Bridge up (ベイブリッジ〜大黒PA) | `PK4BachUlYo` | 2m36 | night | full entry sequence |
| Yokohane down (横羽線下り) | `zlmdaR0opgE` | 5m15 | **day** | best structural read |
| Honmoku JCT (攻略車載#18) | `fNHucFnFQ1c` | 10m07 | **day** | approach + JCT |
| Night walkaround | `7i0-KCwMDyc` | 10m40 | night | apron-level atmosphere |
### Confirmed visual details
1. **Red/orange chevron shoulder paint** on the outer kerb through every ramp
curve. Present in all five videos, day and night. Highest-value cheap
detail available.
2. **White splayed / V-shaped piers** with trapezoidal box-girder deck
underside (clearest at `yokohane_down` t=218–242s).
3. **Chain-link mesh fencing** along ramp edges — not solid barrier. Above a
low concrete jersey base.
4. **Green route shields** `大黒線 K5`; blue circular regulatory signs;
`50` km/h limit discs on ramps.
5. **`/P` + up-right arrow gantry** marks the parking entry (`91suWmNr62c`
t=104s).
6. Apron at night: warm sodium pole lighting, **white stall lines clearly
visible**, painted directional arrows, low guardrail perimeter.
7. Arrival reads as **descending into a canyon** — decks close in overhead.
---
## 6. Gaps / not yet measured
- **Deck heights above ground.** OSM `layer` gives ordering, not elevation.
Needs either a DEM lookup or estimation from video pier counts.
- **Third approach route.** Confirmed to exist (video states "3 ways"); two
are captured in full, the Honmoku run covers the approach only.
- **Exit ramp geometry.** All captured footage is *entry*. Exits not yet
sampled.
- **Stall grid angle.** Aerial shows a diagonal ~40–45° grid; exact bearing
not yet fitted.
---
## 7. Reproduce
```bash
python3 /tmp/osm_daikoku.py # OSM geometry + circle fits
python3 /tmp/route.py <videoid> <name> <step_s> # video -> contact sheets
python3 /tmp/sat.py # Esri aerial tiles
```
+96
View File
@@ -0,0 +1,96 @@
# Japanese expressway road-standard reference (verified)
Compiled 2026-08-01 for the Tokyo Wangan Racing circuit. Every figure here was
read from a primary standard or an official sign artwork file, **not** from
recollection. Where a commonly-repeated number turned out to be wrong, that is
called out explicitly, because the wrong versions are widespread online.
## Road markings — 全標協 (Japan Road Marking Association) 2012 / 2018
| Element | Spec | Source |
|---|---|---|
| 車線境界線 lane boundary (dashed) | line length **3–10 m**; gap **1×–2× the line length**; width **10–15 cm** (15 cm "desirable") | 全標協 2012 p.42 |
| Lane boundary where overtaking undesirable (屈曲部/curves) | becomes **solid** instead of dashed | 全標協 2012 p.42 |
| Boundary vs 変速車線 (accel/decel lane at ramps) | length **2 m** (3 m on decel lanes), width **45 cm** | 全標協 2012 p.42 |
| 導流帯 gore / zebra hatching | **45 cm** white solid bars repeated every **1.0 m**; bounded by a 15–20 cm edge line | 全標協 2018 pp.97–98 |
| Zebra around an obstruction | 45 cm bars at 1.0 m, guided by arrows upstream | 全標協 2018 |
| Dashed extension of edge line through intersections | width 15–30 cm, line+gap every 0.5–2.0 m | 警察庁 2014 p.11 様式4 |
**Correction to a widely-repeated claim.** The "45 cm wide lines on expressways"
figure that circulates online is *not* the lane boundary — 全標協 2012 p.42 gives
45 cm only for the boundary between a through lane and a **変速車線**
(acceleration/deceleration lane). Applying 45 cm to ordinary lane dividers makes
the road look like it is all ramp. Lane dividers are 15 cm.
**On 8 m / 12 m.** The standard specifies a *range* (3–10 m line, gap 1–2×), not a
single value. 8 m line / 12 m gap sits inside that range at the high-speed end
(gap = 1.5× line) and is the configuration used at expressway speeds; a 5 m/5 m
rhythm reads as a surface street. Treat 8/12 as "a legal expressway
configuration", not as "the one legally mandated number".
## Colour values — sampled from official sign artwork, not estimated
| Use | Value |
|---|---|
| Guide sign (案内標識) green | **#005D35** |
| Shutoko route badge green | **#007F1F** |
| Sign lettering | white #FFFFFF |
Japanese **expressway** guide signs are green; ordinary-road guide signs are
blue. Shutoko uses green.
## Speed limits (from the route articles' own signage tables)
| Route | Posted |
|---|---|
| C1 都心環状線 (Inner Circular) | **50 km/h** mainline, **40 km/h** inside the junction sections — 江戸橋JCT内, 浜崎橋JCT内, and 京橋JCT–旧白魚橋乗継所 are each posted 40 in the route's own 車線・最高速度 table |
| B 湾岸線 (Bayshore/Wangan) | **80 km/h** (60–70 in places) |
The C1 and the Wangan are **not** the same limit — a C1-shaped circuit should be
posted 50, not 80.
## Caveats / not verified
- Lamp pole spacing (~35 m) and parapet heights are reasonable engineering
values but were **not** confirmed against a primary source; they are tuned by
eye for gameplay readability.
- Asphalt/concrete RGB values in MapBuilder are art direction, not measured
photometry. Porous asphalt (排水性舗装) does read darker and coarser than
ordinary asphalt, which the palette reflects.
## Structural sections of the C1 (drives MapBuilder geometry)
The C1 is not a uniform elevated tube — it changes structural type along its
length, and the circuit now renders each type differently.
| Section | Real-world form | In-game |
|---|---|---|
| Elevated viaduct (most of the loop) | Deck on concrete columns | `Pillar` every 3rd sample above Y=18 |
| Bridge spans (474 flagged samples) | Steel box girder 鋼箱桁橋 over the old canals | `BridgeGirder` + `GirderRib` under deck; `PierCap` + round `PierColumn` on a 6-sample pitch |
| Ginza 銀座 | Runs **below** street level in the filled-in 楓川・築地川 river beds; surviving city bridges cross **over** it, piers landing in the median | `GinzaOverpass` + `OverpassParapet`, `OverpassPier` in the median |
| Tunnel sections | Bored/cut-and-cover | Tunnel bore, ceiling, no sky lighting |
Girder depth is set from the span/depth rule of thumb for steel box girders
(≈1/20 of span): at ~35 m spans that is ~1.8 m ≈ 6.4 studs (`GIRDER_H`).
**Why Ginza bans lane changes.** The no-lane-change marking through Ginza is not
arbitrary styling — the crossing bridges' piers physically occupy the median
between the carriageways, so the lanes are separated by structure. The yellow
line and the `OverpassPier` geometry are two expressions of the same fact.
## Lane counts
| Section | Lanes |
|---|---|
| Most of C1 | 2 + 2 |
| Shiodome JCT → Hamazakibashi JCT | **3 + 3** (widened; carries the merge from the Haneda line) |
Rendered by `widthAt(i)`: 60 studs baseline, 82 studs in `WIDE_ZONES`, with a
taper between so the widening never steps more than ~1.7 studs per sample.
## Method note
Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and
table markup that carry these figures — the measurements only appear in the raw
wikitext (`prop=revisions&rvslots=main`). Query the wikitext when hunting for
specs.
+64
View File
@@ -0,0 +1,64 @@
# PA Ramp Build Spec — reconciled
Written 2026-08-03 after research + measurement. Supersedes ad-hoc azimuth
tuning in `MapBuilder.luau`.
## Source conflict — READ THIS FIRST
Two sources disagree, and the disagreement matters:
| | Generic standard (AASHTO/FHWA/JP, via research) | Measured Daikoku (OSM circle fits) |
|---|---|---|
| Ramp form | short shallow **parallel slip road** | **loop helix**, 150–220° of turn |
| Radius | 80–120 m (285–430 studs) | 71–204 m, mean 131 m (**469 studs**) |
| Arc | 30–45° | 217° (main helix, rms 1.2 = truly circular) |
**Resolution: measured Daikoku wins for THIS build.** The generic spec describes
a typical roadside PA on a flat ground-level expressway. Daikoku PA is not that
— it sits at the bottom of a **five-level junction stack** on reclaimed land, so
its ramps are genuinely helical loops, not slip roads. The satellite plan view
(`aerial/daikoku_z19.jpg`) shows one large-radius arc sweeping around the lot.
The generic spec is still authoritative for the **merge detail** (below), which
is not Daikoku-specific.
## Numbers to build to
| Quantity | Value | Source |
|---|---|---|
| Scale | 3.571 studs/m (1 stud = 0.28 m) | project |
| Main entry helix radius | **525 studs** (147 m) | OSM way 32403901, rms 1.2 |
| Secondary helix radius | **440 studs** (124 m) | OSM way 779045459, rms 0.7 |
| Tight connector radius | 255 studs (71 m) | OSM way 32355896 |
| Turn direction | **clockwise (right) — BOTH ramps** | 6 of 8 OSM ways; video 「右、右」 |
| Divergence at merge | 2–5° (current build: 0.1–0.2°, tangent) | AASHTO/FHWA |
| Deceleration lane | 150–200 m before gore | AASHTO |
| Acceleration lane | 200–300 m after gore | AASHTO |
| Gore apex angle | < 5°, chevron-painted over 30–50 m | AASHTO |
| Superelevation | 4–8% bank on curves | AASHTO |
| Deck-to-deck spacing | 36–43 studs/level (10–12 m) | JP standard |
## Hard constraints (checkable in a probe)
1. **Entry and exit ramps must NEVER intersect.** Current build fails this:
2547 point-pairs within 30 studs planar at < 12 studs dY, worst case
**0.5 studs apart at dY = 0.0**. This is the dominant visual defect —
it reads as a bowtie tangle. Probe must assert 0 such pairs.
2. Both ramps curve **the same direction** (clockwise). They are at opposite
ends of the lot and wrap the same way; they do not mirror into each other.
3. Ramp arc wraps **outside** the parking apron, forming its boundary — it does
not cut across the lot interior.
4. Merge is tangent (already achieved: 0.1–0.2°).
5. No ramp part intersects any Workspace-level building model (obstacle scope
must be INVERTED, not a name whitelist — see verification skill).
## Edge / structure treatment (from onboard video + Commons photos)
- Edge = **low concrete jersey base + see-through chain-link mesh fence above**.
NOT a tall solid parapet. Current build's heavy kerb chain is why the ramps
read as thick white rails.
- **Red/orange chevron shoulder paint** through every ramp curve — present in
all five reference videos, day and night. Cheapest high-value detail.
- Piers: **large cylindrical columns**, widely spaced, under a **trapezoidal
box-girder** deck underside. White/pale concrete.
- White zebra hatching fills the gore wedge.
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+38
View File
@@ -0,0 +1,38 @@
# PA Ramp Reference Images
Downloaded 2026-08-03. All files verified present on disk and integrity-checked
with PIL (12/12 valid, 0 corrupt).
## Wikimedia Commons (fetched by research agent)
| File | Px | Shows | License |
|---|---|---|---|
| `Daikoku-PA.JPG` | 2487x1797 | Daikoku PA overview | CC BY 3.0 |
| `Daikoku-JCT.JPG` | 2560x1920 | Daikoku JCT elevated stack | CC BY 3.0 |
| `DaikokuJCT-Bnishi.JPG` | 640x480 | Daikoku JCT bayside west | CC BY-SA 3.0 |
| `Shutoko_daikokuPA.jpg` | 1280x960 | Daikoku PA elevated structure | CC BY-SA 3.0 |
| `Shutoko_oiPA_east.jpg` | 1280x960 | Oi PA east | CC BY-SA 3.0 |
| `Shutoko_oiPA_west.jpg` | 1280x960 | Oi PA west (ground level, facility bldg) | CC BY-SA 3.0 |
| `Shutoko_heiwajimaPA_up.jpg` | 1280x960 | Heiwajima PA inbound | CC BY-SA 3.0 |
| `Shutoko_heiwajimaPA_down.jpg` | 1280x960 | Heiwajima PA outbound | CC BY-SA 3.0 |
| `Shutoko_shibauraPA.jpg` | 1280x960 | Shibaura PA | CC BY-SA 3.0 |
| `Shuto_expressway_shibaura_pa.jpg` | 1000x680 | Shibaura PA / expressway | CC BY-SA 3.0 |
License note: all CC BY / CC BY-SA. Reference-only for shape study. Do NOT
trace directly into shipped assets without attribution (see
`reference-image-research` skill, license matrix).
## From project NAS archive (higher value for geometry)
| File | Px | Shows |
|---|---|---|
| `aerial/daikoku_z19.jpg` | Esri z19 | **Best plan view.** Satellite of Daikoku PA: single large-radius arc wrapping the lot |
| `aerial/daikoku_bldg_big.jpg` | | Daikoku facility building aerial |
| `daikoku_jct_sheet2.jpg` | 1728x972 | Onboard contact sheet, Daikoku JCT |
| `exit_to_baybridge_sheet2.jpg` | 1728x1296 | **Best ground view.** Off-ramp divergence, gore hatching, jersey+mesh edge, box girder |
## Measured source of truth
`docs/daikoku-reference-spec.md` — OSM Overpass geometry with Kasa circle fits
of the actual ramp ways. This is measured, not estimated, and outranks generic
standards for Daikoku-specific radii.
Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -0,0 +1,160 @@
# Daikoku Futo PA — Measured Reference Spec
Source-of-truth numbers for building the Daikoku PA entrance/exit ramps and
apron in `MapBuilder.luau`. Everything here is **measured**, not estimated:
OSM geometry via Overpass (`/tmp/osm_daikoku.py`), Esri aerial imagery, and
frame-sampled onboard video.
Generated 2026-08-03.
---
## 1. Scale
| Quantity | Value |
|---|---|
| Project scale | `M = 1/0.28` = **3.571 studs per metre** (`MapBuilder.luau:69`) |
| Reciprocal | 1 stud = 0.28 m |
| `Circuit.STUDS_TO_KM` | 0.28 / 1000 |
> NOTE: `c1_georeference.json` records `scale_studs_per_meter: 4.3` for the C1
> loop georeference. That is a **different** mapping (real-Tokyo lat/lon → C1
> world) with a stated `median_error_m: 366`. Do **not** use 4.3 for PA
> geometry. PA work uses 3.571.
---
## 2. The real PA footprint (OSM)
Overpass, bbox 35.4545/139.6700 → 35.4705/139.6935. ENU metres about
`35.46246657, 139.68149001` (user's Google Earth point).
| OSM way | Type | Extent (m) | Perimeter | Name |
|---|---|---|---|---|
| `405966005` | `highway=rest_area` | **203 × 227** | 646 m | 大黒PA |
| `59608205` | `amenity=parking` | **181 × 174** | 507 m | 大黒ふ頭パーキングエリア |
| `88702527` | `amenity=parking` | 102 × 107 | 359 m | (satellite lot, W of main) |
**Current build:** apron is `APRON_X = 210*M`, `APRON_Z = 200*M`
(`MapBuilder.luau:991`) = 210 × 200 m. That sits between the parking polygon
(181×174) and the rest_area polygon (203×227) — **already a good match.** The
apron extent is not the problem.
The *shape* is the divergence: the build is an axis-aligned square; the real
lot is a rounded wedge bounded on the N/E by the loop ramp arc.
---
## 3. Loop ramp radii — MEASURED
Kasa circle fit over each OSM ramp way with |cumulative turn| ≥ 120°.
`rms` = fit residual (low = genuinely circular).
| OSM way | Length | Turn | **Radius** | rms | Layer | Direction |
|---|---|---|---|---|---|---|
| `32355896` | 475 m | −272° | **70.9 m** | 14.4 | — | CW / right |
| `32403901` | 601 m | −217° | **147.4 m** | **1.2** | 5 | CW / right |
| `32592491` | 729 m | +208° | **197.4 m** | 12.7 | 2 | CCW / left |
| `307397558` | 167 m | −178° | **27.5 m** | 9.0 | — | CW / right |
| `779045459` | 371 m | +161° | **123.6 m** | **0.7** | 3 | CCW / left |
| `32403902` | 656 m | −150° | **204.2 m** | 13.3 | 3 | CW / right |
| `32592481` | 359 m | −146° | **120.4 m** | 12.0 | 1 | CW / right |
| `32592487` | 488 m | −122° | **158.8 m** | 11.3 | 2 | CW / right |
**Headline numbers**
- Radius range **27.5 – 204 m**; mean **131 m** = **469 studs**
- The two cleanest circular fits (rms < 1.5) are the true helixes:
- `32403901` — **147 m** (526 studs), 217° of turn, layer 5
- `779045459` — **124 m** (441 studs), 161° of turn, layer 3
- **6 of 8 curve clockwise (right).** Matches the video captions exactly:
`右、右と覚えておくとPAに着きます` — "remember right, right".
**Recommended build values**
| Ramp | Radius (m) | Radius (studs) | Turn |
|---|---|---|---|
| Main entry helix | 147 | **525** | ~220° CW |
| Secondary helix | 124 | **440** | ~160° CCW |
| Tight connector | 71 | **255** | ~270° CW |
Current `Circuit.PA_RADIUS = 260` studs = 72.8 m — which is almost exactly the
tight connector (`32355896`, 70.9 m). The *main* helixes are roughly 2× that.
---
## 4. Deck layers — the vertical stack
OSM `layer` tags on bridge ways in the interchange:
| Layer | Bridge ways | Total length |
|---|---|---|
| 1 | 11 | 17,696 m |
| 2 | 7 | 5,637 m |
| 3 | 8 | 5,368 m |
| 4 | 10 | 10,096 m |
| 5 | 6 | 1,417 m |
**Five distinct deck levels.** This is the single most important structural
fact: Daikoku JCT is a five-level stack, and the PA sits at the bottom of it.
Current `Circuit.PA_LIFT = 56` studs (15.7 m) of clearance over the apron —
that is one level. A faithful build wants at least 3 visible levels overhead.
Typical Japanese expressway deck-to-deck spacing is ~10–12 m; at 3.571 studs/m
that is **36–43 studs per level**.
---
## 5. Video-derived detail
Sources archived at
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/004-video-reference/`
| Route | Video ID | Len | Light | Value |
|---|---|---|---|---|
| CMT TOKYO "How to go to?" | `91suWmNr62c` | 2m50 | night | narrated turn-by-turn |
| Bay Bridge up (ベイブリッジ〜大黒PA) | `PK4BachUlYo` | 2m36 | night | full entry sequence |
| Yokohane down (横羽線下り) | `zlmdaR0opgE` | 5m15 | **day** | best structural read |
| Honmoku JCT (攻略車載#18) | `fNHucFnFQ1c` | 10m07 | **day** | approach + JCT |
| Night walkaround | `7i0-KCwMDyc` | 10m40 | night | apron-level atmosphere |
### Confirmed visual details
1. **Red/orange chevron shoulder paint** on the outer kerb through every ramp
curve. Present in all five videos, day and night. Highest-value cheap
detail available.
2. **White splayed / V-shaped piers** with trapezoidal box-girder deck
underside (clearest at `yokohane_down` t=218–242s).
3. **Chain-link mesh fencing** along ramp edges — not solid barrier. Above a
low concrete jersey base.
4. **Green route shields** `大黒線 K5`; blue circular regulatory signs;
`50` km/h limit discs on ramps.
5. **`/P` + up-right arrow gantry** marks the parking entry (`91suWmNr62c`
t=104s).
6. Apron at night: warm sodium pole lighting, **white stall lines clearly
visible**, painted directional arrows, low guardrail perimeter.
7. Arrival reads as **descending into a canyon** — decks close in overhead.
---
## 6. Gaps / not yet measured
- **Deck heights above ground.** OSM `layer` gives ordering, not elevation.
Needs either a DEM lookup or estimation from video pier counts.
- **Third approach route.** Confirmed to exist (video states "3 ways"); two
are captured in full, the Honmoku run covers the approach only.
- **Exit ramp geometry.** All captured footage is *entry*. Exits not yet
sampled.
- **Stall grid angle.** Aerial shows a diagonal ~40–45° grid; exact bearing
not yet fitted.
---
## 7. Reproduce
```bash
python3 /tmp/osm_daikoku.py # OSM geometry + circle fits
python3 /tmp/route.py <videoid> <name> <step_s> # video -> contact sheets
python3 /tmp/sat.py # Esri aerial tiles
```
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Pull real Daikoku JCT / PA geometry from OpenStreetMap via Overpass.
Outputs measured, real-world numbers:
- PA service-area polygon (extent, area)
- Each ramp/link way: length, turn direction, fitted circle radius
- Bridge levels present
All distances in metres. Local ENU projection about the PA centre so we can
fit circles in a flat plane without map-projection distortion.
"""
import json, math, urllib.request, urllib.parse, sys
LAT0, LON0 = 35.46246657, 139.68149001 # user's Google Earth point
R_EARTH = 6378137.0
# Generous bbox around the whole interchange
S, W, N, E = 35.4545, 139.6700, 35.4705, 139.6935
Q = f"""
[out:json][timeout:120];
(
way["highway"]({S},{W},{N},{E});
way["amenity"="parking"]({S},{W},{N},{E});
way["highway"="services"]({S},{W},{N},{E});
way["highway"="rest_area"]({S},{W},{N},{E});
);
out body geom;
"""
ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
def fetch():
last = None
for ep in ENDPOINTS:
try:
data = urllib.parse.urlencode({"data": Q}).encode()
req = urllib.request.Request(ep, data=data,
headers={"User-Agent": "daikoku-ref/1.0"})
with urllib.request.urlopen(req, timeout=150) as r:
return json.loads(r.read().decode())
except Exception as e:
last = f"{ep}: {e}"
print(f" endpoint failed {last}", file=sys.stderr)
raise SystemExit(f"all overpass endpoints failed: {last}")
def enu(lat, lon):
"""Local east/north metres relative to (LAT0, LON0)."""
x = math.radians(lon - LON0) * R_EARTH * math.cos(math.radians(LAT0))
y = math.radians(lat - LAT0) * R_EARTH
return x, y
def polyline_len(pts):
return sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
def fit_circle(pts):
"""Algebraic (Kasa) circle fit. Returns (cx, cy, R, rms_residual)."""
n = len(pts)
if n < 3:
return None
sx = sum(p[0] for p in pts) / n
sy = sum(p[1] for p in pts) / n
u = [p[0] - sx for p in pts]
v = [p[1] - sy for p in pts]
Suu = sum(a * a for a in u); Svv = sum(b * b for b in v)
Suv = sum(u[i] * v[i] for i in range(n))
Suuu = sum(a ** 3 for a in u); Svvv = sum(b ** 3 for b in v)
Suvv = sum(u[i] * v[i] * v[i] for i in range(n))
Svuu = sum(v[i] * u[i] * u[i] for i in range(n))
d = 2 * (Suu * Svv - Suv * Suv)
if abs(d) < 1e-9:
return None
uc = (Svv * (Suuu + Suvv) - Suv * (Svvv + Svuu)) / d
vc = (Suu * (Svvv + Svuu) - Suv * (Suuu + Suvv)) / d
cx, cy = uc + sx, vc + sy
rs = [math.dist(p, (cx, cy)) for p in pts]
R = sum(rs) / len(rs)
rms = math.sqrt(sum((r - R) ** 2 for r in rs) / len(rs))
return cx, cy, R, rms
def total_turn(pts):
"""Signed cumulative heading change in degrees. +ccw / -cw."""
tot = 0.0
for i in range(1, len(pts) - 1):
a1 = math.atan2(pts[i][1] - pts[i - 1][1], pts[i][0] - pts[i - 1][0])
a2 = math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0])
d = math.degrees(a2 - a1)
while d > 180: d -= 360
while d < -180: d += 360
tot += d
return tot
def main():
data = fetch()
els = data.get("elements", [])
print(f"overpass returned {len(els)} ways\n")
ramps, areas = [], []
for el in els:
if el.get("type") != "way" or "geometry" not in el:
continue
t = el.get("tags", {})
pts = [enu(g["lat"], g["lon"]) for g in el["geometry"]]
if len(pts) < 2:
continue
rec = {"id": el["id"], "tags": t, "pts": pts,
"len": polyline_len(pts)}
hw = t.get("highway", "")
if hw in ("services", "rest_area") or t.get("amenity") == "parking":
areas.append(rec)
elif hw.endswith("_link") or t.get("oneway") == "yes":
ramps.append(rec)
# ---- PA area ----
print("=" * 70)
print("PARKING / SERVICE AREA POLYGONS")
print("=" * 70)
for a in sorted(areas, key=lambda r: -r["len"])[:6]:
xs = [p[0] for p in a["pts"]]; ys = [p[1] for p in a["pts"]]
w = max(xs) - min(xs); h = max(ys) - min(ys)
nm = a["tags"].get("name", a["tags"].get("name:en", "-"))
print(f" way {a['id']:>12} {a['tags'].get('highway') or a['tags'].get('amenity'):<10}"
f" extent {w:6.0f} x {h:6.0f} m perim {a['len']:7.0f} m {nm}")
print(f" E {min(xs):7.0f}..{max(xs):7.0f} N {min(ys):7.0f}..{max(ys):7.0f}")
# ---- curved ramps: the loops ----
print()
print("=" * 70)
print("CURVED RAMPS (|turn| >= 120 deg) -- the helix loops")
print("=" * 70)
print(f" {'way id':>12} {'len(m)':>7} {'turn':>8} {'radius(m)':>10} {'rms':>6} layer/bridge")
loops = []
for r in ramps:
turn = total_turn(r["pts"])
if abs(turn) < 120 or r["len"] < 60:
continue
fc = fit_circle(r["pts"])
if not fc:
continue
cx, cy, R, rms = fc
if R > 400: # not a tight loop
continue
loops.append((r, turn, cx, cy, R, rms))
for r, turn, cx, cy, R, rms in sorted(loops, key=lambda z: -abs(z[1])):
t = r["tags"]
lb = f"layer={t.get('layer','-')} bridge={t.get('bridge','-')}"
print(f" {r['id']:>12} {r['len']:7.0f} {turn:7.0f}\u00b0 {R:10.1f} {rms:6.1f} {lb}")
print(f" centre E {cx:7.0f} N {cy:7.0f} "
f"{'CCW/left' if turn>0 else 'CW/right'}")
if loops:
rr = [z[4] for z in loops]
print(f"\n loop radius: min {min(rr):.0f} m max {max(rr):.0f} m "
f"mean {sum(rr)/len(rr):.0f} m")
print(f" in studs (1 stud = 0.28 m): mean {sum(rr)/len(rr)/0.28:.0f} studs")
# ---- layers present ----
print()
print("=" * 70)
print("DECK LAYERS (bridge ways)")
print("=" * 70)
lay = {}
for r in ramps:
if r["tags"].get("bridge"):
L = r["tags"].get("layer", "0")
lay.setdefault(L, []).append(r["len"])
for L in sorted(lay, key=lambda s: int(s) if s.lstrip("-").isdigit() else 0):
v = lay[L]
print(f" layer {L:>3}: {len(v):3d} bridge ways, total {sum(v):8.0f} m")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Download a route video, sample frames every N seconds, and build labelled
contact sheets. Built for Daikoku entrance/exit reference gathering.
Usage: route.py <videoid> <shortname> [step_seconds]
"""
import os, re, subprocess, sys, glob, json
from PIL import Image, ImageDraw
YTDLP = os.path.expanduser("~/.local/bin/yt-dlp")
ROOT = "/tmp/routes"
def run(cmd, **kw):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
def meta(vid):
r = run(f'{YTDLP} --no-warnings -J --skip-download "https://www.youtube.com/watch?v={vid}" 2>/dev/null')
try:
d = json.loads(r.stdout)
except Exception:
return {}
return {k: d.get(k) for k in ("title", "uploader", "duration", "upload_date", "width", "height")}
def fetch(vid, name):
d = f"{ROOT}/{name}"
os.makedirs(d, exist_ok=True)
mp4 = f"{d}/{name}.mp4"
if not os.path.exists(mp4):
r = run(f'{YTDLP} -f "bv*[height<=1080][ext=mp4]/bv*[height<=1080]/b" '
f'--no-warnings -o "{mp4}" "https://www.youtube.com/watch?v={vid}"')
if not os.path.exists(mp4):
print(f" DOWNLOAD FAILED: {r.stderr[-300:]}")
return None
return mp4
def sample(mp4, d, step):
dur = float(run(f'ffprobe -v error -show_entries format=duration '
f'-of default=nw=1:nk=1 "{mp4}"').stdout.strip() or 0)
for f in glob.glob(f"{d}/fr_*.jpg"):
os.remove(f)
ts = list(range(2, int(dur), step))
for t in ts:
run(f'ffmpeg -v error -ss {t} -i "{mp4}" -frames:v 1 -q:v 2 '
f'-vf scale=960:-1 "{d}/fr_{t:04d}.jpg" -y')
return dur, ts
def sheets(d, name, cols=3, per=9):
fs = sorted(glob.glob(f"{d}/fr_*.jpg"))
out = []
for page in range((len(fs) + per - 1) // per):
sub = fs[page * per:(page + 1) * per]
ims = [Image.open(f).convert("RGB") for f in sub]
w, h = ims[0].size
rows = (len(sub) + cols - 1) // cols
s = Image.new("RGB", (w * cols, h * rows), (10, 10, 10))
for i, im in enumerate(ims):
s.paste(im, ((i % cols) * w, (i // cols) * h))
dr = ImageDraw.Draw(s)
for i, f in enumerate(sub):
t = int(re.search(r"fr_(\d+)", f).group(1))
x, y = (i % cols) * w, (i // cols) * h
dr.rectangle([x + 6, y + 6, x + 190, y + 54], fill=(0, 0, 0))
dr.text((x + 20, y + 22), f"t={t}s", fill=(255, 225, 0))
s = s.resize((int(s.width * 0.60), int(s.height * 0.60)), Image.LANCZOS)
o = f"{d}/{name}_sheet{page+1}.jpg"
s.save(o, quality=90)
out.append(o)
return out
if __name__ == "__main__":
vid, name = sys.argv[1], sys.argv[2]
step = int(sys.argv[3]) if len(sys.argv) > 3 else 8
m = meta(vid)
print(f"=== {name} [{vid}] ===")
print(f" title: {m.get('title')}")
print(f" dur : {m.get('duration')}s {m.get('width')}x{m.get('height')}")
mp4 = fetch(vid, name)
if not mp4:
sys.exit(1)
d = f"{ROOT}/{name}"
dur, ts = sample(mp4, d, step)
sh = sheets(d, name)
print(f" frames: {len(ts)} @ {step}s sheets: {len(sh)}")
for s in sh:
print(f" {s}")
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Fetch + stitch Esri World Imagery tiles (no API key) around a lat/lon.
Esri World Imagery over Japan is high-res aerial. Tile scheme is standard
Web Mercator XYZ, same as OSM.
"""
import math, sys, urllib.request, io
from PIL import Image
URL = "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) research/1.0"}
def deg2tile(lat, lon, z):
lat_r = math.radians(lat)
n = 2.0 ** z
xt = (lon + 180.0) / 360.0 * n
yt = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
return xt, yt
def fetch(z, x, y):
u = URL.format(z=z, x=x, y=y)
req = urllib.request.Request(u, headers=UA)
with urllib.request.urlopen(req, timeout=30) as r:
return Image.open(io.BytesIO(r.read())).convert("RGB")
def build(lat, lon, z, half, out):
xt, yt = deg2tile(lat, lon, z)
cx, cy = int(xt), int(yt)
n = half * 2 + 1
canvas = Image.new("RGB", (256 * n, 256 * n))
ok = 0
for i, x in enumerate(range(cx - half, cx + half + 1)):
for j, y in enumerate(range(cy - half, cy + half + 1)):
try:
canvas.paste(fetch(z, x, y), (i * 256, j * 256))
ok += 1
except Exception as e:
print(f" tile {z}/{x}/{y} FAIL {e}", file=sys.stderr)
canvas.save(out, quality=92)
# metres per pixel at this latitude
mpp = 156543.03392 * math.cos(math.radians(lat)) / (2 ** z)
span = mpp * 256 * n
print(f"{out} z={z} tiles={ok}/{n*n} size={canvas.size} "
f"mpp={mpp:.3f} span={span:.0f}m")
return canvas
if __name__ == "__main__":
LAT, LON = 35.46246657, 139.68149001
build(LAT, LON, 17, 2, "/tmp/daikoku_z17.jpg") # wide: whole loop
build(LAT, LON, 18, 2, "/tmp/daikoku_z18.jpg") # closer
build(LAT, LON, 19, 2, "/tmp/daikoku_z19.jpg") # detail: stalls
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
[tools] [tools]
rojo = "rojo-rbx/rojo@7.5.1" rojo = "rojo-rbx/rojo@7.7.0"
wally = "UpliftGames/[email protected]" wally = "UpliftGames/[email protected]"
selene = "Kampfkarren/[email protected]" selene = "Kampfkarren/[email protected]"
stylua = "JohnnyMorganz/[email protected]" stylua = "JohnnyMorganz/[email protected]"
+238
View File
@@ -0,0 +1,238 @@
--!strict
-- verify_map.luau — automated map regression test.
--
-- Run from Linux:
-- python3 -c "import sys;sys.path.insert(0,'/tmp');import rbx;
-- print(rbx.call('execute_luau',
-- {'code':open('scripts/verify_map.luau').read(),'datamodel_type':'Edit'},
-- wait=90,timeout=600))"
--
-- Exits with a PASS/FAIL summary. Every check is a real geometric assertion
-- against the built map -- no check trusts that a build "ran".
--
-- IMPORTANT: this must be run against a map built from CURRENT source. Studio
-- caches ModuleScripts, so a stale Circuit will silently pass the shape checks
-- while describing the wrong circuit. Check 1 guards exactly that.
local RS = game:GetService("ReplicatedStorage")
local EXPECT_SAMPLES = 823
local EXPECT_TUNNEL = 200
local EXPECT_BRIDGE = 474
local ROAD_HALF = 24 -- ROAD_WIDTH 48 / 2
local CAR_HALF_W = 5 -- 10-stud car
local CAR_HEIGHT = 5.5
local results: { string } = {}
local failures = 0
local function check(name: string, ok: boolean, detail: string)
if ok then
table.insert(results, (" PASS %-34s %s"):format(name, detail))
else
failures += 1
table.insert(results, (" FAIL %-34s %s"):format(name, detail))
end
end
-- Fresh-require the circuit so a cached stale module can't mask a bad build.
local orig = RS.Shared:FindFirstChild("Circuit")
if not orig then
return "FATAL: ReplicatedStorage.Shared.Circuit missing"
end
orig.Name = "_CircuitOld"
local fresh = orig:Clone()
fresh.Name = "Circuit"
fresh.Parent = RS.Shared
local ok, C = pcall(require, fresh)
local function cleanup()
fresh:Destroy()
orig.Name = "Circuit"
end
if not ok then
cleanup()
return "FATAL: Circuit require failed: " .. tostring(C)
end
local s = C.samples
local n = #s
-- 1. Source freshness ------------------------------------------------------
local nTunnel, nBridge = 0, 0
for i = 1, n do
if s[i].tunnel then
nTunnel += 1
end
if s[i].bridge then
nBridge += 1
end
end
check(
"circuit is current source",
n == EXPECT_SAMPLES and nTunnel == EXPECT_TUNNEL and nBridge == EXPECT_BRIDGE,
("samples=%d tunnel=%d bridge=%d"):format(n, nTunnel, nBridge)
)
-- 2. Loop closure + spacing ------------------------------------------------
local minGap, maxGap = math.huge, 0
for i = 1, n do
local d = (s[(i % n) + 1].pos - s[i].pos).Magnitude
minGap = math.min(minGap, d)
maxGap = math.max(maxGap, d)
end
check("loop closed & evenly spaced", maxGap - minGap < 5, ("gap %.1f..%.1f studs"):format(minGap, maxGap))
-- 3. Grade is drivable -----------------------------------------------------
local maxGrade, gradeAt = 0, 0
for i = 1, n do
local a, b = s[i].pos, s[(i % n) + 1].pos
local flat = (Vector3.new(b.X, 0, b.Z) - Vector3.new(a.X, 0, a.Z)).Magnitude
if flat > 0.1 then
local g = math.abs(b.Y - a.Y) / flat
if g > maxGrade then
maxGrade, gradeAt = g, i
end
end
end
check("max grade drivable (<15%)", maxGrade < 0.15, ("%.1f%% at sample %d"):format(maxGrade * 100, gradeAt))
local map = workspace:FindFirstChild("Map")
if not map then
cleanup()
return "FATAL: workspace.Map missing -- run MapBuilder first"
end
local exp = map:FindFirstChild("Expressway")
-- 4. Continuous driving surface -------------------------------------------
local rp = RaycastParams.new()
rp.FilterType = Enum.RaycastFilterType.Include
rp.FilterDescendantsInstances = { exp }
local holes, worstOffset = 0, 0
for i = 1, n do
local hit = workspace:Raycast(s[i].pos + Vector3.new(0, 12, 0), Vector3.new(0, -40, 0), rp)
if not hit then
holes += 1
else
worstOffset = math.max(worstOffset, math.abs(s[i].pos.Y - hit.Position.Y))
end
end
check("no holes in road surface", holes == 0, ("%d gaps, worst offset %.2f studs"):format(holes, worstOffset))
-- 5. Tunnel bore count matches flags --------------------------------------
local walls, ceilings = 0, 0
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "TunnelWall" then
walls += 1
elseif d.Name == "TunnelCeiling" then
ceilings += 1
end
end
check(
"tunnel bore matches flags",
walls == nTunnel * 2 and ceilings == nTunnel,
("%d walls (want %d), %d ceilings"):format(walls, nTunnel * 2, nTunnel)
)
-- 6. Nothing intrudes into the car envelope -------------------------------
-- Probe sits ON the deck: centre lifted by half the car height, so the box
-- never dips below the road and false-positives on the pillars beneath it.
local probe = Instance.new("Part")
probe.Size = Vector3.new(CAR_HALF_W * 2, CAR_HEIGHT, 20)
probe.Anchored = true
probe.CanCollide = false
probe.Transparency = 1
probe.Parent = workspace
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Exclude
op.FilterDescendantsInstances = { probe, exp }
op.MaxParts = 8
local expected = {
KillWall = true,
Guardrail = true,
TunnelCeiling = true,
TunnelLight = true,
RoadLight = true,
BoxGirder = true,
PierCap = true,
Cable = true,
Baseplate = true,
}
local intrusions = 0
local firstHit = ""
for i = 1, n do
local a, b = s[i].pos, s[(i % n) + 1].pos
local dir = b - a
if dir.Magnitude > 0.1 then
-- Pillar tops are flush with the deck (0.00 clearance by design), so the
-- probe must sit fully ABOVE the road surface or it clips its own
-- supports. Lift a further 1.5 studs of margin.
local mid = (a + b) / 2 + Vector3.new(0, CAR_HEIGHT / 2 + 2.0, 0)
probe.CFrame = CFrame.lookAt(mid, mid + dir.Unit)
for _, h in ipairs(workspace:GetPartsInPart(probe, op)) do
-- only judge the NEW map; legacy C1/City trees are separate content
local root = h
while root.Parent and root.Parent ~= workspace do
root = root.Parent
end
if root == map and not expected[h.Name] then
intrusions += 1
if firstHit == "" then
firstHit = h:GetFullName() .. " @sample" .. i
end
break
end
end
end
end
probe:Destroy()
check("car envelope clear", intrusions == 0, ("%d intrusions %s"):format(intrusions, firstHit))
-- 7. Supports stay under the deck -----------------------------------------
local badPillars, minGapUnder = 0, math.huge
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "Pillar" and d:IsA("BasePart") then
local top = d.Position.Y + d.Size.Y / 2
local best = math.huge
for i = 1, n do
local flat = (Vector3.new(s[i].pos.X, 0, s[i].pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude
if flat < best then
best = flat
if flat < ROAD_HALF then
local gap = s[i].pos.Y - top
minGapUnder = math.min(minGapUnder, gap)
if gap < 0 then
badPillars += 1
end
end
end
end
end
end
check(
"pillars stay below deck",
badPillars == 0,
("%d protruding, min clearance %.2f studs"):format(badPillars, minGapUnder == math.huge and 0 or minGapUnder)
)
-- 8. Asset hygiene ---------------------------------------------------------
local badSounds = 0
for _, svc in ipairs({ game:GetService("ServerStorage"), RS, workspace }) do
for _, d in ipairs(svc:GetDescendants()) do
if d:IsA("Sound") then
local _, count = d.SoundId:gsub("rbxassetid://", "")
if count > 1 then
badSounds += 1
end
end
end
end
check("no malformed sound ids", badSounds == 0, ("%d double-prefixed"):format(badSounds))
cleanup()
local header = failures == 0 and ("MAP VERIFY: ALL %d CHECKS PASSED"):format(#results)
or ("MAP VERIFY: %d/%d CHECKS FAILED"):format(failures, #results)
return header .. "\n" .. table.concat(results, "\n")
+65 -1
View File
@@ -29,6 +29,11 @@ local ChassisController = {}
local heartbeatConn: RBXScriptConnection? = nil local heartbeatConn: RBXScriptConnection? = nil
local seatedConn: RBXScriptConnection? = nil local seatedConn: RBXScriptConnection? = nil
-- The seat the live heartbeatConn is bound to. On respawn the old car is
-- destroyed and a new one spawned; the connection stays alive but drives the
-- DESTROYED car's motors. Tracking the seat lets the watchdog notice that the
-- loop is stale and re-arm against the new car.
local drivingSeat: VehicleSeat? = nil
local function findCarModel(seat: VehicleSeat): Model? local function findCarModel(seat: VehicleSeat): Model?
local model = seat:FindFirstAncestorOfClass("Model") local model = seat:FindFirstAncestorOfClass("Model")
@@ -70,6 +75,7 @@ local function startDriving(seat: VehicleSeat)
return return
end end
drivingSeat = seat
heartbeatConn = RunService.Heartbeat:Connect(function() heartbeatConn = RunService.Heartbeat:Connect(function()
local throttle = seat.ThrottleFloat local throttle = seat.ThrottleFloat
local steer = seat.SteerFloat local steer = seat.SteerFloat
@@ -106,6 +112,7 @@ local function stopDriving()
heartbeatConn:Disconnect() heartbeatConn:Disconnect()
heartbeatConn = nil heartbeatConn = nil
end end
drivingSeat = nil
end end
function ChassisController.init() function ChassisController.init()
@@ -115,13 +122,70 @@ function ChassisController.init()
if seatedConn then if seatedConn then
seatedConn:Disconnect() seatedConn:Disconnect()
end end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function() local function applySeat()
stopDriving() stopDriving()
local seatPart = humanoid.SeatPart local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startDriving(seatPart) startDriving(seatPart)
end end
end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(applySeat)
-- WATCHDOG: the SeatPart signal alone is NOT reliable enough to arm the
-- chassis. Observed on cold boot: humanoid is Seated in a valid DriveSeat,
-- the car is network-owned by us, and the catch-up applySeat() below has
-- already run — yet the Heartbeat loop is not connected, so every motor
-- keeps MotorMaxTorque = 0 and the car is completely undrivable. The
-- signal fires during client bootstrap while modules are still being
-- required, and the edge is simply lost; re-running applySeat() once at
-- init doesn't help when the seat is assigned a moment AFTER that call.
-- A level-triggered check is immune to every ordering of these events:
-- if we are seated in a DriveSeat but have no live loop, arm it.
task.spawn(function()
while humanoid.Parent do
-- The whole body is pcall-wrapped: this watchdog is the ONLY
-- self-healing path. If it ever errors the thread dies silently
-- and the car becomes permanently undrivable, with the failure
-- appearing intermittent because the edge-triggered seat signal
-- still handles the easy nil -> seat case. Never let it die.
local ok, err = pcall(function()
local seatPart = humanoid.SeatPart
local seated = seatPart ~= nil
and seatPart:IsA("VehicleSeat")
and seatPart.Name == Chassis.SEAT_NAME
-- A live connection bound to a different seat (or to a car
-- that has since been destroyed) is STALE: it keeps driving
-- the old car's motors, so heartbeatConn stays non-nil and
-- the new car never gets armed. Re-arm whenever the bound
-- seat isn't the one we're sitting in, or it left the world.
-- NOTE: seatPart is nil for a frame whenever the old car is
-- destroyed on respawn, so it MUST be nil-checked before it
-- is indexed -- that crash is what killed this loop before.
local stale = heartbeatConn ~= nil
and (drivingSeat ~= seatPart or seatPart == nil or not seatPart:IsDescendantOf(workspace))
if seated and (not heartbeatConn or stale) then
stopDriving()
startDriving(seatPart :: VehicleSeat)
elseif not seated and heartbeatConn then
stopDriving()
end
end)
if not ok then
warn("[ChassisController] watchdog iteration failed: " .. tostring(err))
end
task.wait(0.5)
end
end) end)
-- RACE FIX: the server can seat the player in a spawned car BEFORE this
-- listener exists (CarManager sits them during spawn, which may complete
-- before the client finishes requiring its modules). In that case the
-- SeatPart signal already fired with nobody listening, so startDriving
-- never ran and the motors kept MotorMaxTorque = 0 — the car looked
-- completely dead to the player. Evaluate the CURRENT seat state once at
-- init instead of only reacting to future changes.
applySeat()
end end
if player.Character then if player.Character then
+87 -13
View File
@@ -1,26 +1,43 @@
--!strict --!strict
--[[ --[[
Ambience.luau (SERVER) Ambience.luau (SERVER)
The night-city mood pass (M2). The Rojo project file sets the static
Lighting baseline (night, fog); this module adds everything that can't be
expressed as plain properties:
- Atmosphere: dense hazy air that catches the neon glow Two looks, one switch (GameConfig.DevDaylight):
- Post effects: bloom (neon pop) + subtle cyan-shifted color correction
- A giant "moon" disc + low star field for a sky with depth
- A slow ambient clock drift (deep night 22:00 -> 03:00 loop) so
shadows/sky aren't frozen — the game stays night forever by design,
it just breathes.
All tunables at the top. Runs once at boot; the drift loop is one cheap NIGHT (shipping, DevDaylight = false)
task.spawn. The night-city mood pass (M2). The Rojo project file sets the static
Lighting baseline (night, fog); this module adds everything that can't
be expressed as plain properties:
- Atmosphere: dense hazy air that catches the neon glow
- Post effects: bloom (neon pop) + subtle cyan-shifted color correction
- A slow ambient clock drift (deep night 22:00 -> 03:00 loop) so
shadows/sky aren't frozen — the game stays night forever by design,
it just breathes.
DAYLIGHT (development, DevDaylight = true)
A flat, bright midday pass so geometry is actually visible while
building: road surface, kerbs, gap placement, PA layout. Full sun, no
fog, no bloom, no drift — a frozen noon.
This must ACTIVELY OVERRIDE the night values baked into
default.project.json (ClockTime 0, near-black Ambient, FogEnd 900),
because those are applied to Lighting before this module runs.
Only lighting/post differs between the two — no gameplay, geometry or
physics changes, so anything verified in daylight holds at night.
All tunables at the top. Runs once at boot; the night drift loop is one
cheap task.spawn that does not start in daylight mode.
]] ]]
local Lighting = game:GetService("Lighting") local Lighting = game:GetService("Lighting")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage.Shared.GameConfig)
local Ambience = {} local Ambience = {}
-- Tunables -------------------------------------------------------------------- -- Night tunables --------------------------------------------------------------
local ATMOS_DENSITY = 0.35 local ATMOS_DENSITY = 0.35
local ATMOS_HAZE = 2.1 local ATMOS_HAZE = 2.1
local BLOOM_INTENSITY = 0.9 local BLOOM_INTENSITY = 0.9
@@ -29,7 +46,42 @@ local NIGHT_START = 22 -- ClockTime loop range
local NIGHT_END = 27 -- 27 == 03:00 next day local NIGHT_END = 27 -- 27 == 03:00 next day
local NIGHT_LOOP_SECONDS = 600 -- one full drift cycle = 10 minutes local NIGHT_LOOP_SECONDS = 600 -- one full drift cycle = 10 minutes
function Ambience.init() -- Daylight tunables -----------------------------------------------------------
local DAY_CLOCK = 13.5 -- early afternoon: sun high, but still casts readable shadows
local DAY_BRIGHTNESS = 2.5
local DAY_FOG_END = 100000 -- effectively no fog; see the whole loop
local DAY_ATMOS_DENSITY = 0.18 -- a little depth on distant geometry, no murk
--- Bright, flat midday. Overrides the night baseline from the project file.
local function applyDaylight()
Lighting.ClockTime = DAY_CLOCK
Lighting.GeographicLatitude = 35.6 -- Tokyo, so the sun angle is plausible
Lighting.Brightness = DAY_BRIGHTNESS
Lighting.ExposureCompensation = 0
Lighting.Ambient = Color3.fromRGB(120, 120, 130)
Lighting.OutdoorAmbient = Color3.fromRGB(150, 152, 160)
Lighting.FogStart = 0
Lighting.FogEnd = DAY_FOG_END
Lighting.FogColor = Color3.fromRGB(190, 205, 225)
Lighting.EnvironmentDiffuseScale = 1
Lighting.EnvironmentSpecularScale = 1
Lighting.GlobalShadows = true
local atmosphere = Instance.new("Atmosphere")
atmosphere.Density = DAY_ATMOS_DENSITY
atmosphere.Offset = 0.1
atmosphere.Color = Color3.fromRGB(199, 209, 227)
atmosphere.Decay = Color3.fromRGB(106, 112, 125)
atmosphere.Glare = 0
atmosphere.Haze = 0.6
atmosphere.Parent = Lighting
-- No bloom / no colour grade in dev: they wash out edges and make it
-- harder to judge whether geometry is actually aligned.
end
--- The shipping neon-night look.
local function applyNight()
-- Atmosphere ----------------------------------------------------------------- -- Atmosphere -----------------------------------------------------------------
local atmosphere = Instance.new("Atmosphere") local atmosphere = Instance.new("Atmosphere")
atmosphere.Density = ATMOS_DENSITY atmosphere.Density = ATMOS_DENSITY
@@ -68,4 +120,26 @@ function Ambience.init()
end) end)
end end
function Ambience.init()
-- Clear anything a previous run (or the project file) left behind, so the
-- two modes can't stack Atmosphere/effects on top of each other.
for _, child in ipairs(Lighting:GetChildren()) do
if
child:IsA("Atmosphere")
or child:IsA("BloomEffect")
or child:IsA("ColorCorrectionEffect")
or child:IsA("SunRaysEffect")
then
child:Destroy()
end
end
if GameConfig.DevDaylight then
applyDaylight()
print("[Ambience] DEV DAYLIGHT is ON (GameConfig.DevDaylight) — set it to false for the night look.")
else
applyNight()
end
end
return Ambience return Ambience
+29 -1
View File
@@ -25,6 +25,19 @@ type Track = { lastPos: Vector3, lastValid: CFrame }
local tracked: { [Player]: Track } = {} local tracked: { [Player]: Track } = {}
local SAMPLE_INTERVAL = 0.5 local SAMPLE_INTERVAL = 0.5
-- Server-initiated teleports (car spawn onto the pad, respawn, mode resets)
-- legitimately move a player thousands of studs in one frame, which trips the
-- speed check and snaps them straight back -- dragging their car with them.
-- Callers announce those moves via AntiCheat.grant() so the next sample is
-- accepted as the new baseline instead of treated as a hack.
local graceUntil: { [Player]: number } = {}
local GRACE_SECONDS = 2
--- Exempt a player from the speed check briefly after a legitimate teleport.
function AntiCheat.grant(player: Player, seconds: number?)
graceUntil[player] = os.clock() + (seconds or GRACE_SECONDS)
end
local function hrpOf(player: Player): BasePart? local function hrpOf(player: Player): BasePart?
local char = player.Character local char = player.Character
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart? return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
@@ -33,9 +46,23 @@ end
function AntiCheat.init() function AntiCheat.init()
local function watch(player: Player) local function watch(player: Player)
player.CharacterAdded:Connect(function(char) player.CharacterAdded:Connect(function(char)
-- RESPAWN FIX: drop the previous character's baseline IMMEDIATELY,
-- before the WaitForChild below yields. A new character spawns at
-- the SpawnLocation (the parking area, ~8,500 studs from the old
-- body at the origin). If the Heartbeat sampler runs during that
-- yield it measures that jump against the STALE baseline, calls it
-- a teleport hack, and snaps the fresh character back to the dead
-- character's CFrame -- dragging the car with it. Clearing the
-- entry means "no baseline yet", so nothing can be flagged until
-- the new one is recorded.
tracked[player] = nil
AntiCheat.grant(player)
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart? local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
if hrp then if hrp then
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame } tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
-- The spawn itself is a legitimate long-distance move; keep the
-- grace alive past the first sample after the baseline lands.
AntiCheat.grant(player)
end end
end) end)
end end
@@ -66,7 +93,8 @@ function AntiCheat.init()
local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude
local hSpeed = horizontal / interval local hSpeed = horizontal / interval
if hSpeed > GameConfig.MaxPlausibleSpeed then local inGrace = (graceUntil[player] or 0) > os.clock()
if hSpeed > GameConfig.MaxPlausibleSpeed and not inGrace then
-- Snap back: likely a speed/teleport hack. -- Snap back: likely a speed/teleport hack.
hrp.CFrame = track.lastValid hrp.CFrame = track.lastValid
hrp.AssemblyLinearVelocity = Vector3.zero hrp.AssemblyLinearVelocity = Vector3.zero
+735 -40
View File
@@ -30,6 +30,8 @@ local Chassis = require(Shared.Chassis)
local GameConfig = require(Shared.GameConfig) local GameConfig = require(Shared.GameConfig)
local DataManager = require(script.Parent.DataManager) local DataManager = require(script.Parent.DataManager)
local CarBody = require(script.Parent.CarBody) local CarBody = require(script.Parent.CarBody)
local AntiCheat = require(script.Parent.AntiCheat)
local PhysicsService = game:GetService("PhysicsService")
type Car = Cars.Car type Car = Cars.Car
type Tuning = Chassis.Tuning type Tuning = Chassis.Tuning
@@ -39,6 +41,75 @@ local CarManager = {}
local activeCars: { [Player]: Model } = {} local activeCars: { [Player]: Model } = {}
local carsFolder: Folder local carsFolder: Folder
-- Spawn seating --------------------------------------------------------------
-- How far above the character's root the car body is placed before seating, so
-- the freshly teleported chassis doesn't start interpenetrating the ground.
local SEAT_LIFT = 3
-- Grace period after Sit() before network ownership is handed to the client.
-- The rig must be fully settled first or the client re-asserts a stale layout.
local SETTLE_TIME = 1.0
-- How far to sink an imported mesh shell into the chassis so it doesn't look
-- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false).
-- Sink the shell slightly so the tyres aren't fully swallowed by the bodywork.
-- Every stud of drop is a stud of cabin height lost, and hyper_x (lowest roof
-- in the library at 4.64) has none to spare, so keep this small.
local SHELL_DROP = 0.25
-- Collision groups ------------------------------------------------------------
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
-- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has
-- its inner edge at 2.80 -- a 0.2 stud lateral interpenetration. Vertically the
-- wheel top reaches -0.2 against a body bottom of -0.7. Left in the same
-- collision group the wheels grind against the body and the car will not roll
-- (motors spin at ~1 rad/s against 20,000 torque). Putting wheels in their own
-- group that ignores the chassis is the fix; the geometry is intentional.
local WHEEL_GROUP = "CarWheel"
local CHASSIS_GROUP = "CarChassis"
-- Seating the driver on the cabin FLOOR (see Chassis.SEAT_ABOVE_GROUND) tucks
-- the avatar down inside the body slab, so its legs/torso now genuinely
-- interpenetrate the chassis. Overlapping colliders is exactly what launches a
-- character across the map, so the driver gets its own group that ignores the
-- car. The driver still collides with the world, so it can't clip through
-- terrain when ejected.
local DRIVER_GROUP = "CarDriver"
local groupsReady = false
local function ensureCollisionGroups()
if groupsReady then
return
end
groupsReady = true
local ok = pcall(function()
PhysicsService:RegisterCollisionGroup(WHEEL_GROUP)
PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP)
PhysicsService:RegisterCollisionGroup(DRIVER_GROUP)
end)
if not ok then
-- Already registered (Play-solo re-entry) -- harmless.
end
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false)
PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true)
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false)
PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, CHASSIS_GROUP, false)
PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, WHEEL_GROUP, false)
end
--- Put every part of a character into the driver collision group (or back into
--- Default when they get out), so a low-seated avatar can't fight the chassis.
local function setCharacterGroup(character: Model?, group: string)
if not character then
return
end
for _, d in ipairs(character:GetDescendants()) do
if d:IsA("BasePart") then
pcall(function()
d.CollisionGroup = group
end)
end
end
end
-- Helpers --------------------------------------------------------------------- -- Helpers ---------------------------------------------------------------------
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
@@ -84,7 +155,21 @@ end
-- Build one corner of the suspension. `isFront` corners get a steering servo. -- Build one corner of the suspension. `isFront` corners get a steering servo.
-- All positions are in BODY-LOCAL space, converted via body.CFrame. -- 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 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 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) local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
@@ -102,35 +187,36 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS) local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS)
local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS) local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS)
local prismatic = Instance.new("PrismaticConstraint") -- Rigid hub mount. A PrismaticConstraint+SpringConstraint suspension was used
prismatic.Name = "SuspSlide_" .. cornerName -- here, but the hub (0.6 cube) is ~3 orders of magnitude lighter than the
prismatic.Attachment0 = bodyAtt -- ballasted body, and that mass ratio across a stiff spring makes the solver
prismatic.Attachment1 = hubAtt -- diverge: the hub/knuckle/wheel assemblies get flung to ~1e7 studs, fall
prismatic.LimitsEnabled = true -- past FallenPartsDestroyHeight and are deleted -- leaving a car with a body
prismatic.LowerLimit = -Chassis.SUSPENSION_TRAVEL / 2 -- and no wheels that cannot move. Welding the hub makes it part of the body
prismatic.UpperLimit = Chassis.SUSPENSION_TRAVEL / 2 -- assembly, which is rock stable. Ride is rigid; the car actually drives.
prismatic.Parent = body local hubWeld = Instance.new("WeldConstraint")
hubWeld.Name = "SuspWeld_" .. cornerName
local spring = Instance.new("SpringConstraint") hubWeld.Part0 = body
spring.Name = "SuspSpring_" .. cornerName hubWeld.Part1 = hub
spring.Attachment0 = bodyAtt hubWeld.Parent = body
spring.Attachment1 = hubAtt
spring.FreeLength = 0.2
spring.Stiffness = tuning.SpringStiffness
spring.Damping = tuning.SpringDamping
spring.LimitsEnabled = false
spring.Parent = body
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------ -- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
local wheelParent: Part = hub local wheelParent: Part = hub
if isFront then if isFront then
-- Knuckle size is load-bearing, not cosmetic: it sets the mass the
-- steering servo actually has to work with. See Chassis.KNUCKLE_SIZE
-- for the measured sweep -- at the old 0.5 the servo overshot its own
-- limits by 70+ degrees and the front wheels were uncontrollable.
-- It is CanCollide=false and fully transparent, so growing it changes
-- physics authority only, never the look of the car.
local knuckle = makePart({ local knuckle = makePart({
Name = "Knuckle_" .. cornerName, Name = "Knuckle_" .. cornerName,
Size = Vector3.new(0.5, 0.5, 0.5), Size = Vector3.new(Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE),
CFrame = wheelWorld, CFrame = wheelWorld,
CanCollide = false, CanCollide = false,
Parent = model, Parent = model,
}) })
knuckle.Transparency = 1
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS) local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS) local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
@@ -151,6 +237,15 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
end end
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------ -- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
-- VISIBILITY: this cylinder is the PHYSICS wheel and must be invisible when
-- a real imported shell is used, because that shell brings its own modelled
-- wheels. Leaving it opaque put TWO sets of wheels on every car -- the mesh's
-- tyre plus a grey rubber cylinder on top of it -- which is what read as
-- "way too wide". They don't even line up: the cylinder's outer edge sits at
-- X=4.10 while the gtr_r34 mesh tyre sits at 4.52, so the duplicate poked out
-- past the real tyre at a different track width. The shell is applied later
-- in buildCarModel and hides these; if NO shell exists the procedural CarBody
-- kit supplies rims, so the cylinder is never the intended visual either way.
local wheel = makePart({ local wheel = makePart({
Name = "Wheel_" .. cornerName, Name = "Wheel_" .. cornerName,
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2), Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
@@ -166,6 +261,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new()) local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
local wheelAtt = makeAttachment(wheel, "WheelAtt", 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") local motor = Instance.new("HingeConstraint")
motor.Name = Chassis.WHEEL_MOTOR_NAME motor.Name = Chassis.WHEEL_MOTOR_NAME
motor.Attachment0 = parentAtt motor.Attachment0 = parentAtt
@@ -223,10 +332,13 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
seat.HeadsUpDisplay = false seat.HeadsUpDisplay = false
seat.CanCollide = false seat.CanCollide = false
seat.Color = Color3.new(0.1, 0.1, 0.1) seat.Color = Color3.new(0.1, 0.1, 0.1)
-- Seat pan just above the body slab (top = H/2): low enough to look tucked -- Seat pan on the CABIN FLOOR, measured up from the ground plane the wheels
-- in the cabin, high enough that the avatar's legs never interpenetrate -- rest on -- NOT up from the body slab. The slab sits at the car's vertical
-- the slab (which causes physics launches). -- middle, so the old "slab top + 0.3" placement put the driver's hips at the
seat.CFrame = origin * CFrame.new(0, Chassis.BODY_SIZE.Y / 2 + 0.3, 0.2) -- car's waistline and its head through the roof. Sitting the avatar this low
-- means its torso now overlaps the slab, which is why the driver goes in its
-- own collision group (see DRIVER_GROUP).
seat.CFrame = origin * CFrame.new(0, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + seat.Size.Y / 2, 0.2)
seat.Parent = model seat.Parent = model
local seatWeld = Instance.new("WeldConstraint") local seatWeld = Instance.new("WeldConstraint")
seatWeld.Part0 = body seatWeld.Part0 = body
@@ -240,22 +352,389 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
end end
end end
-- Sculpted visual body kit (per-car styling; all parts Massless + welded) -- Visual shell. If a real imported mesh exists under
CarBody.build(model, body, car) -- ReplicatedStorage/CarModels/<carId>, use it; otherwise fall back to the
-- procedural CarBody kit. Meshes are visual-ONLY: every part is Massless,
-- CanCollide=false and welded to Body, so the constraint chassis (springs,
-- steering servos, motor wheels) and therefore the driving feel are
-- completely unchanged no matter which shell is used.
local shellTemplate = ReplicatedStorage:FindFirstChild("CarModels")
shellTemplate = shellTemplate and shellTemplate:FindFirstChild(car.Id)
if shellTemplate then
local shell = shellTemplate:Clone()
shell.Name = "Shell"
-- Measure the shell from its PARTS, not from Model:GetExtentsSize() /
-- Model:GetBoundingBox(). Both of those return an axis-aligned box that
-- is inflated by rotated parts: silv_s15 reports H=12.32 when its real
-- geometry is only 4.71 tall, which made it hover ~4 studs in the air.
-- Accumulating each part's own rotated half-extents gives the true box.
local function trueAABB(model)
local lo = Vector3.new(math.huge, math.huge, math.huge)
local hi = Vector3.new(-math.huge, -math.huge, -math.huge)
local found = false
for _, p in ipairs(model:GetDescendants()) do
if p:IsA("BasePart") then
found = true
local cf, s = p.CFrame, p.Size
local hx = 0.5
* (
math.abs(cf.RightVector.X * s.X)
+ math.abs(cf.UpVector.X * s.Y)
+ math.abs(cf.LookVector.X * s.Z)
)
local hy = 0.5
* (
math.abs(cf.RightVector.Y * s.X)
+ math.abs(cf.UpVector.Y * s.Y)
+ math.abs(cf.LookVector.Y * s.Z)
)
local hz = 0.5
* (
math.abs(cf.RightVector.Z * s.X)
+ math.abs(cf.UpVector.Z * s.Y)
+ math.abs(cf.LookVector.Z * s.Z)
)
local c = p.Position
lo = Vector3.new(math.min(lo.X, c.X - hx), math.min(lo.Y, c.Y - hy), math.min(lo.Z, c.Z - hz))
hi = Vector3.new(math.max(hi.X, c.X + hx), math.max(hi.Y, c.Y + hy), math.max(hi.Z, c.Z + hz))
end
end
if not found then
return nil, nil, nil
end
return lo, hi, hi - lo
end
-- 1. NORMALISE SIZE. Library meshes range 15.6 -> 19.0 studs long at
-- native scale, so cars looked inconsistent and the short ones had no
-- cabin room for the avatar. Uniform Model:ScaleTo keeps each mesh's
-- own proportions while making every car the same length.
-- The long axis is NOT always Z: gtr_r34 and hyper_x are modelled
-- rotated 90 degrees (length on X), so scaling by .Z blew them up to
-- ~2x. Use whichever horizontal axis is actually longer.
local _, _, rawSize = trueAABB(shell)
if rawSize then
local rawLen = math.max(rawSize.X, rawSize.Z)
if rawLen > 0.01 then
shell:ScaleTo(Chassis.SHELL_LENGTH / rawLen)
end
end
-- 2. SIT IT ON THE GROUND. Align the true box's BOTTOM to the ground
-- plane the wheels rest on, then sink by SHELL_DROP so tyres aren't
-- swallowed. Measured against the pivot, since PivotTo moves the pivot.
local lo2 = trueAABB(shell)
local bottomOffset = 0
if lo2 then
bottomOffset = lo2.Y - shell:GetPivot().Position.Y
end
-- 2b. FACE THE SHELL FORWARD. PivotTo below forces the shell's pivot
-- orientation to the body's, so a shell inherits whatever yaw its
-- author happened to save it with -- there is no automatic
-- alignment. Three of the five library shells were modelled
-- nose-toward -Z (the chassis convention, since the front axle is
-- built at Z = -WHEELBASE/2) and looked right by luck; hatch_junk
-- and hyper_x were modelled nose-toward +Z and rendered BACKWARDS
-- on a correctly-built chassis.
-- 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 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
local pivotInv = shell:GetPivot():Inverse()
local fSum, fN = Vector3.zero, 0
local rSum, rN = Vector3.zero, 0
for _, w in ipairs(wheelsFolder:GetChildren()) do
-- 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 * (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 and rN > 0 then
local nose = (fSum / fN) - (rSum / rN)
-- 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
end
-- Rotating about the pivot swings the bodywork sideways whenever the
-- pivot is not the geometric centre (hyper_x's centre sits 0.70 studs
-- off it, so a 180-degree flip would shift the shell 1.4 studs off the
-- chassis). Cancel that by re-centring the shell's horizontal AABB
-- centre onto the body axis; Y is untouched, so the ground alignment
-- solved above still holds (a yaw rotation cannot change world Y
-- extents).
local hi2 = select(2, trueAABB(shell))
local centreFix = CFrame.new()
if lo2 and hi2 then
local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2)
centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z)
end
shell:PivotTo(
body.CFrame
* CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0)
* CFrame.Angles(0, shellYaw, 0)
* centreFix
)
-- 3. DROP THE SEAT TO FIT THIS SHELL'S ROOF. Cabin height varies by more
-- than a stud across the library (hyper_x roof sits 0.72 above the
-- body plane, gtr_r34 1.80). A single fixed seat height that looks
-- right in the tallest car pushes the driver's head through the
-- roof of the lowest one, so solve for the seat from the measured
-- roofline instead: seat = roof - head height - clearance.
local seatPart = model:FindFirstChild(Chassis.SEAT_NAME)
if seatPart and rawSize then
-- The roofline is NOT the top of the shell's bounding box. Every
-- library mesh carries junk above the cabin -- gtr_r34 has a stray
-- part literally named "Brick" 0.74 studs above its roof, rx7_fc
-- 0.51 -- so measuring to the AABB top invents headroom that isn't
-- there and the head pokes through the actual roof. Take the top of
-- the largest part by volume instead: that is the main body mesh.
local lo3, hi3 = trueAABB(shell)
local roofY = hi3 and hi3.Y or nil
local bestVol = -1
for _, p in ipairs(shell:GetDescendants()) do
if p:IsA("BasePart") then
local vol = p.Size.X * p.Size.Y * p.Size.Z
if vol > bestVol then
bestVol = vol
local cf, sz = p.CFrame, p.Size
roofY = p.Position.Y
+ 0.5
* (math.abs(cf.RightVector.Y * sz.X) + math.abs(cf.UpVector.Y * sz.Y) + math.abs(
cf.LookVector.Y * sz.Z
))
end
end
end
if roofY and lo3 then
local roofLocal = roofY - body.Position.Y
-- HEAD_ABOVE_SEAT was measured from the seat part's CENTRE, but
-- seatLocal below is the pan height (the part is then placed at
-- seatLocal + Size.Y/2). Subtract the half-thickness here or every
-- car quietly loses that much of its intended clearance -- which
-- is why hyper_x measured 0.07 instead of the requested 0.30.
local wanted = roofLocal - Chassis.HEAD_ABOVE_SEAT - Chassis.ROOF_CLEARANCE - seatPart.Size.Y / 2
-- Allow sinking to the cabin floor: a low roof must win over the
-- nominal seat height, otherwise the clamp silently discards the
-- whole adaptive solve (which is what used to happen). The shell
-- bottom sits SHELL_DROP below the ground plane, so the seat can
-- go slightly under GROUND_OFFSET without breaching the underside
-- -- hyper_x (lowest roof) needs that headroom.
local floor = Chassis.GROUND_OFFSET - SHELL_DROP + 0.1
local seatLocal = math.clamp(wanted, floor, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND)
seatPart.CFrame = body.CFrame * CFrame.new(0, seatLocal + seatPart.Size.Y / 2, 0.2)
-- A WeldConstraint locks in the relative offset it had when it
-- was created, so moving the seat afterwards would be snapped
-- straight back. Rebuild the weld at the new offset.
for _, w in ipairs(body:GetChildren()) do
if w:IsA("WeldConstraint") and (w.Part1 == seatPart or w.Part0 == seatPart) then
w:Destroy()
end
end
local seatWeld2 = Instance.new("WeldConstraint")
seatWeld2.Part0 = body
seatWeld2.Part1 = seatPart
seatWeld2.Parent = body
end
end
local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true)
for _, d in ipairs(shell:GetDescendants()) do
if d:IsA("BasePart") then
d.Anchored = false
d.CanCollide = false
d.CanTouch = false
d.CanQuery = false
d.Massless = true
end
end
if shellRoot then
local w = Instance.new("WeldConstraint")
w.Part0 = body
w.Part1 = shellRoot
w.Parent = body
end
shell.Parent = model
-- Hide the placeholder blocks the mesh replaces.
body.Transparency = 1
-- ...INCLUDING the physics wheels. The shell brings its own modelled
-- wheels, so leaving the chassis cylinders opaque puts TWO sets of
-- wheels on the car -- the mesh tyre plus a grey rubber cylinder over
-- it. That is what reads as absurdly wide tyres. They aren't even
-- concentric: the cylinder's outer edge sits at X=4.10 against the
-- gtr_r34 mesh tyre's 4.52, so the duplicate juts out past the real
-- wheel at a different track width.
-- This is deliberately INSIDE the shell branch. The procedural CarBody
-- fallback (else-branch) has no wheels of its own -- buildRims only
-- welds a small Rim disc INTO each Wheel_ part, at 1.2x radius versus
-- the wheel's 2x, and transparency is not inherited by children. Hide
-- these unconditionally and a shell-less car visibly rides on four
-- undersized floating discs.
for _, d in ipairs(model:GetChildren()) do
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
d.Transparency = 1
end
end
-- RE-HANG THE MODELLED WHEELS ON THE PHYSICS WHEELS. The imported
-- shells weld every wheel part straight to the body mesh (the template
-- weld graph is MB <-> FL, MB <-> Rotor, MB <-> Caliper, ...), so the
-- visible tyres are rigidly glued to the chassis: their assembly root
-- comes out as DriveSeat rather than the wheel. They never spin and
-- never steer -- painted-on hubcaps, which is most of why the wheels
-- looked wrong in motion. Cut those welds and re-weld each wheel group
-- to its own Wheel_ cylinder so it inherits that cylinder's spin, and
-- on the front axle its steer.
local wheelGroups = shell:FindFirstChild("Wheels")
if wheelGroups then
for _, w in ipairs(wheelGroups:GetChildren()) do
local phys = model:FindFirstChild("Wheel_" .. w.Name:upper())
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
-- 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.
for _, c in ipairs(shell:GetDescendants()) do
if c:IsA("WeldConstraint") or c:IsA("Weld") then
local a, b = c.Part0, c.Part1
local aIn, bIn = a ~= nil and inGroup[a], b ~= nil and inGroup[b]
if (aIn or bIn) and not (aIn and bIn) then
c:Destroy()
end
end
end
-- Make it CONCENTRIC with the cylinder it now rides on.
-- Each shell models its own track and wheelbase (the
-- gtr_r34 rear tyre sits 1.34 studs ahead of the chassis
-- axle, the fronts 0.33 inboard). A weld preserves relative
-- offset, so welding at that offset would make the tyre
-- ORBIT the axle at 1.34 studs radius instead of spinning
-- on it -- far worse than the static wheel it replaces.
local delta = phys.Position - root.Position
for _, p in ipairs(group) do
p.CFrame = p.CFrame + delta
end
-- The template hung every part off the body, so with those
-- welds cut the group has no internal rigidity left of its
-- own. Rebuild it around root (these parts are already
-- Massless + CanCollide=false, so this adds no physics
-- cost), then hang root off the cylinder.
for _, p in ipairs(group) do
if p ~= root then
local iw = Instance.new("WeldConstraint")
iw.Part0 = root
iw.Part1 = p
iw.Parent = root
end
end
local pw = Instance.new("WeldConstraint")
pw.Part0 = phys
pw.Part1 = root
pw.Parent = phys
end
end
end
local cabin = model:FindFirstChild("Cabin")
if cabin then
cabin:Destroy()
end
else
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
CarBody.build(model, body, car)
end
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
ensureCollisionGroups()
for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") then
desc.CollisionGroup = if desc.Name:match("^Wheel_") then WHEEL_GROUP else CHASSIS_GROUP
end
end
model.PrimaryPart = body model.PrimaryPart = body
return model return model
end end
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) -------- -- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
-- CharacterAdded can fire before MapBuilder.build() has finished, in which case
-- a naive lookup silently falls back to the origin and dumps the car ~8,600
-- studs off-circuit. Wait briefly for the pad rather than guessing.
local function spawnCFrame(): CFrame local function spawnCFrame(): CFrame
local map = Workspace:FindFirstChild("Map") local deadline = os.clock() + 10
if map then repeat
local pad = map:FindFirstChild("SpawnPad", true) local map = Workspace:FindFirstChild("Map")
local pad = map and map:FindFirstChild("SpawnPad", true)
if pad and pad:IsA("BasePart") then if pad and pad:IsA("BasePart") then
return pad.CFrame * CFrame.new(0, 5, 0) return pad.CFrame * CFrame.new(0, 5, 0)
end end
end task.wait(0.1)
until os.clock() > deadline
warn("[CarManager] SpawnPad never appeared; falling back to origin.")
return CFrame.new(0, 8, 0) return CFrame.new(0, 8, 0)
end end
@@ -288,27 +767,101 @@ function CarManager.spawn(player: Player, carId: string?)
local tuning = Chassis.tuningFor(car) local tuning = Chassis.tuningFor(car)
local model = buildCarModel(car, tuning) local model = buildCarModel(car, tuning)
model:PivotTo(spawnCFrame()) local target = spawnCFrame()
model.Parent = carsFolder model.Parent = carsFolder
-- Pivot AFTER parenting: pivoting an unparented model then reparenting lets
-- the physics solver re-resolve from the model's build origin, which drops
-- the car back at (0,0,0) instead of the spawn pad.
model:PivotTo(target)
model:SetAttribute("OwnerUserId", player.UserId) model:SetAttribute("OwnerUserId", player.UserId)
model:SetAttribute("CarId", id) model:SetAttribute("CarId", id)
-- Give the driver network ownership of EVERY assembly in the car. With a -- Record every movable part's offset from the body while the model is
-- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE -- guaranteed intact (freshly built, nothing has simulated yet), and hold
-- assemblies (constraints don't merge them like welds do) — missing one -- SERVER authority for now. Handing ownership to the client before the
-- causes stuttery mixed-authority physics. -- driver is seated lets the client re-assert a stale layout on top of us.
local primary = model.PrimaryPart
local offsets: { [BasePart]: CFrame } = {}
local movable: { BasePart } = {}
for _, desc in ipairs(model:GetDescendants()) do for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") and not desc.Anchored then if desc:IsA("BasePart") and not desc.Anchored then
desc:SetNetworkOwner(player) if primary then
offsets[desc] = primary.CFrame:Inverse() * desc.CFrame
end
table.insert(movable, desc)
pcall(function()
desc:SetNetworkOwner(nil)
end)
end end
end end
local character = player.Character local character = player.Character
if character then local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat?
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat if character and seat and primary then
if seat then -- Why the car is moved to the player instead of the player to the car:
--
-- The character is CLIENT-owned, so a server-side PivotTo of it does
-- NOT stick -- it holds for one frame, then the client snaps it back.
-- Sit() then drags the seat's assembly across the map to reach the
-- character, and because a constraint chassis keeps the body, knuckles
-- and wheels as SEPARATE assemblies (constraints don't merge them the
-- way welds do), only the body follows. The rest stay at the pad and
-- the car is torn apart -- measured ~15,700 studs -- then falls out of
-- the world.
--
-- So: never make Sit() do the travelling. Teleport the INTACT car to
-- wherever the character already is, part-by-part through the recorded
-- offsets (PivotTo alone won't carry separate assemblies reliably),
-- and only then seat the driver. Sit() now moves nobody and nothing
-- tears. Ownership goes to the client last, once the rig is settled.
local humanoid = character:FindFirstChildOfClass("Humanoid")
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if humanoid and root then
-- Face the car the way the character is facing, forced level so it
-- can never spawn on its roof.
local look = root.CFrame.LookVector
local flat = Vector3.new(look.X, 0, look.Z)
if flat.Magnitude < 0.1 then
flat = Vector3.new(0, 0, -1)
end
local origin = root.Position + Vector3.new(0, SEAT_LIFT, 0)
local base = CFrame.lookAt(origin, origin + flat.Unit)
for _, part in ipairs(movable) do
part.CFrame = base * offsets[part]
part.AssemblyLinearVelocity = Vector3.zero
part.AssemblyAngularVelocity = Vector3.zero
end
AntiCheat.grant(player)
-- Driver ignores the chassis BEFORE sitting: the avatar is seated
-- down inside the body slab, and overlapping colliders at Sit() is
-- what launches characters.
setCharacterGroup(character, DRIVER_GROUP)
seat:Sit(humanoid)
-- Restore normal collision when they get out, otherwise the player
-- walks around permanently unable to touch cars.
local seatConn: RBXScriptConnection
seatConn = seat:GetPropertyChangedSignal("Occupant"):Connect(function()
if seat.Occupant == nil then
setCharacterGroup(character, "Default")
seatConn:Disconnect()
end
end)
task.defer(function() task.defer(function()
seat:Sit(character:FindFirstChildOfClass("Humanoid")) task.wait(SETTLE_TIME) -- let Sit() + the solver settle
if not primary:IsDescendantOf(Workspace) then
return
end
AntiCheat.grant(player)
for _, part in ipairs(movable) do
if part:IsDescendantOf(Workspace) then
pcall(function()
part:SetNetworkOwner(player)
end)
end
end
end) end)
end end
end end
@@ -321,9 +874,146 @@ function CarManager.init()
carsFolder.Name = "ActiveCars" carsFolder.Name = "ActiveCars"
carsFolder.Parent = Workspace carsFolder.Parent = Workspace
-- FLIP RECOVERY: a car that ends up on its roof has no way to right itself,
-- so the player is stranded upside down indefinitely -- endurance testing
-- left one inverted (up = -0.6) grinding a barrier for 140 seconds. When a
-- car is inverted AND effectively stationary for a few consecutive checks
-- (so we never flip someone mid-drift), lift it clear and set it upright,
-- preserving heading. Ownership is pulled back to the server for the
-- teleport: PivotTo on client-owned parts gets reverted by the owning
-- client on the next physics frame.
local flipTicks: { [Player]: number } = {}
task.spawn(function()
while true do
task.wait(1)
for _, player in ipairs(Players:GetPlayers()) do
local ok, err = pcall(function()
local model = activeCars[player]
if not model or not model.Parent then
flipTicks[player] = nil
return
end
local body = model:FindFirstChild("Body")
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
if not root then
return
end
local upright = root.CFrame.UpVector:Dot(Vector3.yAxis)
local slow = root.AssemblyLinearVelocity.Magnitude < 8
if upright >= 0.35 or not slow then
flipTicks[player] = 0
return
end
flipTicks[player] = (flipTicks[player] or 0) + 1
if flipTicks[player] < 3 then
return
end
flipTicks[player] = 0
local parts: { BasePart } = {}
for _, d in ipairs(model:GetDescendants()) do
if d:IsA("BasePart") and not d.Anchored then
table.insert(parts, d)
end
end
for _, d in ipairs(parts) do
pcall(function()
d:SetNetworkOwner(nil)
end)
end
local look = root.CFrame.LookVector
local flat = Vector3.new(look.X, 0, look.Z)
if flat.Magnitude < 0.1 then
flat = Vector3.new(0, 0, -1)
end
local target = root.Position + Vector3.new(0, 6, 0)
model:PivotTo(CFrame.lookAt(target, target + flat.Unit))
for _, d in ipairs(parts) do
d.AssemblyLinearVelocity = Vector3.zero
d.AssemblyAngularVelocity = Vector3.zero
end
AntiCheat.grant(player, 3)
task.wait(0.3)
for _, d in ipairs(parts) do
pcall(function()
d:SetNetworkOwner(player)
end)
end
warn(("[CarManager] righted flipped car for %s"):format(player.Name))
end)
if not ok then
warn("[CarManager] flip-recovery iteration failed: " .. tostring(err))
end
end
end
end)
Players.PlayerRemoving:Connect(function(player) Players.PlayerRemoving:Connect(function(player)
CarManager.despawn(player) CarManager.despawn(player)
end) end)
-- VOID RECOVERY: nothing else rescues a car that leaves the world. If it
-- falls off the map it drops past Workspace.FallenPartsDestroyHeight, the
-- model is deleted out from under the driver, and the player is left
-- standing (or seatless) with no car and no way to get another -- observed
-- in endurance testing as 100+ seconds of a dead session. Watch for a car
-- that has fallen below the recovery floor OR whose model was destroyed
-- while the player is still in the game, and respawn it on the pad.
local RECOVER_Y = Workspace.FallenPartsDestroyHeight + 100
task.spawn(function()
while true do
task.wait(1)
for _, player in ipairs(Players:GetPlayers()) do
local ok, err = pcall(function()
if not player.Parent or not player.Character then
return
end
local model = activeCars[player]
if not model then
return
end
local gone = model.Parent == nil
local fell = false
if not gone then
local body = model:FindFirstChild("Body")
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
fell = root ~= nil and root.Position.Y < RECOVER_Y
end
if gone or fell then
warn(
("[CarManager] recovering car for %s (destroyed=%s fell=%s)"):format(
player.Name,
tostring(gone),
tostring(fell)
)
)
-- CRITICAL: spawn() teleports the car to wherever the
-- CHARACTER is. During a void fall the seated character is
-- falling too, so respawning here would drop a fresh car
-- into the void and loop forever. Put the character back on
-- solid ground FIRST, then build the car around it.
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if root and root:IsA("BasePart") then
local safe = spawnCFrame()
AntiCheat.grant(player, 5)
root.CFrame = safe + Vector3.new(0, 5, 0)
root.AssemblyLinearVelocity = Vector3.zero
root.AssemblyAngularVelocity = Vector3.zero
task.wait(0.2)
end
local data = DataManager.get(player)
CarManager.spawn(player, data and data.EquippedCarId)
end
end)
if not ok then
warn("[CarManager] void-recovery iteration failed: " .. tostring(err))
end
end
end
end)
end end
-- The player's live car model, or nil if they have none right now. -- The player's live car model, or nil if they have none right now.
@@ -349,4 +1039,9 @@ function CarManager.getRoot(player: Player): BasePart?
return model.PrimaryPart return model.PrimaryPart
end 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 return CarManager
+28 -2
View File
@@ -34,6 +34,8 @@ local DataManager = {}
-- Profiles keyed by player. Value is the ProfileStore profile object. -- Profiles keyed by player. Value is the ProfileStore profile object.
local profiles: { [Player]: any } = {} local profiles: { [Player]: any } = {}
-- Players whose profile session is mid-flight (StartSessionAsync yields).
local loading: { [Player]: boolean } = {}
-- Callbacks queued before a profile finished loading. -- Callbacks queued before a profile finished loading.
local readyQueue: { [Player]: { (ProfileData) -> () } } = {} local readyQueue: { [Player]: { (ProfileData) -> () } } = {}
@@ -55,6 +57,16 @@ local function flushReady(player: Player, data: ProfileData)
end end
local function onPlayerAdded(player: Player) local function onPlayerAdded(player: Player)
-- Guard against being invoked twice for the same player. init() connects
-- PlayerAdded *before* sweeping existing players (see init), so a player who
-- joins during that window can legitimately hit both paths. Loading twice
-- would make ProfileStore throw "Profile ... is already loaded in this
-- session" and leave the player with no data at all.
if profiles[player] or loading[player] then
return
end
loading[player] = true
local profile = store:StartSessionAsync(keyFor(player), { local profile = store:StartSessionAsync(keyFor(player), {
Cancel = function() Cancel = function()
-- Give up starting the session if the player already left. -- Give up starting the session if the player already left.
@@ -64,6 +76,7 @@ local function onPlayerAdded(player: Player)
if profile == nil then if profile == nil then
-- Another server holds the lock, or player left mid-load. Kick to be safe. -- Another server holds the lock, or player left mid-load. Kick to be safe.
loading[player] = nil
player:Kick("[Wangan] Your data could not be loaded. Please rejoin.") player:Kick("[Wangan] Your data could not be loaded. Please rejoin.")
return return
end end
@@ -84,6 +97,7 @@ local function onPlayerAdded(player: Player)
profile.OnSessionEnd:Connect(function() profile.OnSessionEnd:Connect(function()
-- The session was lost (another server stole it, or we released it). -- The session was lost (another server stole it, or we released it).
profiles[player] = nil profiles[player] = nil
loading[player] = nil
if player.Parent == Players then if player.Parent == Players then
player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.") player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.")
end end
@@ -91,11 +105,13 @@ local function onPlayerAdded(player: Player)
if player.Parent ~= Players then if player.Parent ~= Players then
-- Player left during the yield above; release immediately. -- Player left during the yield above; release immediately.
loading[player] = nil
profile:EndSession() profile:EndSession()
return return
end end
profiles[player] = profile profiles[player] = profile
loading[player] = nil
flushReady(player, profile.Data) flushReady(player, profile.Data)
end end
@@ -105,6 +121,7 @@ local function onPlayerRemoving(player: Player)
profile:EndSession() -- saves + releases the lock profile:EndSession() -- saves + releases the lock
end end
profiles[player] = nil profiles[player] = nil
loading[player] = nil
readyQueue[player] = nil readyQueue[player] = nil
end end
@@ -143,11 +160,20 @@ function DataManager.init()
store = base store = base
end end
-- ORDER MATTERS: connect PlayerAdded FIRST, then sweep players who are
-- already in the game. The reverse order (sweep, then connect) has a gap —
-- in Studio the local player joins during that gap almost every run, so
-- neither path fired and the player was left with NO profile forever:
-- DataManager.get() stayed nil, CarManager.spawn() hit its `if not data`
-- guard and returned silently, and no car ever appeared.
-- onPlayerAdded is idempotent (see its profiles/loading guard), so a player
-- caught by both paths is loaded exactly once.
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
for _, player in ipairs(Players:GetPlayers()) do for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player) task.spawn(onPlayerAdded, player)
end end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
-- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too, -- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too,
-- but ending sessions explicitly is tidy for the last frame). -- but ending sessions explicitly is tidy for the last frame).
+3356 -601
View File
File diff suppressed because it is too large Load Diff
+55 -7
View File
@@ -57,29 +57,77 @@ CarManager.init()
ModeManager.init() ModeManager.init()
AntiCheat.init() AntiCheat.init()
-- 3. Build the world -------------------------------------------------------- -- 3. Player lifecycle: push car id + auto-spawn their car -------------------
MapBuilder.build() -- SPAWN FIX: characters must NOT auto-load. MapBuilder.build() yields for
Ambience.init() -- several seconds and, until it returns, the world contains no SpawnLocation
-- at all -- so an auto-loaded character lands at world origin (0, 5, 0),
-- ~2,700 studs off the nearest road and ~8,500 from the parking area. Because
-- CarManager reassembles the car around the character's root, the car is
-- stranded at the origin too, sitting on an isolated pad; drive more than a
-- few hundred studs and you fall out of the world forever. Load characters
-- only once the map (and its PASpawn SpawnLocation) actually exists.
Players.CharacterAutoLoads = false
local mapReady = false
local handled: { [Player]: boolean } = {}
local function onPlayer(player: Player)
if handled[player] then
return
end
handled[player] = true
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
Players.PlayerAdded:Connect(function(player)
ModeManager.onPlayerAdded(player) ModeManager.onPlayerAdded(player)
DataManager.onReady(player, function() DataManager.onReady(player, function()
pushCar(player) pushCar(player)
ModeManager.pushTo(player) ModeManager.pushTo(player)
end) end)
player.CharacterAdded:Connect(function() player.CharacterAdded:Connect(function(character)
DataManager.onReady(player, function(data) DataManager.onReady(player, function(data)
task.wait(1) task.wait(1)
CarManager.spawn(player, data.EquippedCarId) CarManager.spawn(player, data.EquippedCarId)
end) end)
-- CharacterAutoLoads = false also disables respawn-on-death, so drive
-- it manually or a dead player is stuck as a corpse forever.
local humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
if humanoid then
humanoid.Died:Once(function()
task.wait(Players.RespawnTime)
if player.Parent then
player:LoadCharacter()
end
end)
end
end) end)
end)
task.spawn(function()
while not mapReady do
task.wait(0.1)
end
if player.Parent then
player:LoadCharacter()
end
end)
end
-- Connected BEFORE the build so a player joining mid-build is never missed.
Players.PlayerAdded:Connect(onPlayer)
-- ...and catch anyone already present: in a Studio playtest the local player
-- routinely exists the moment this script runs, so PlayerAdded never fires.
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayer, player)
end
Players.PlayerRemoving:Connect(function(player) Players.PlayerRemoving:Connect(function(player)
handled[player] = nil
ModeManager.onPlayerRemoving(player) ModeManager.onPlayerRemoving(player)
end) end)
-- 4. Build the world --------------------------------------------------------
MapBuilder.build()
Ambience.init()
mapReady = true
-- 5. Remote handlers (validated server-side) -------------------------------- -- 5. Remote handlers (validated server-side) --------------------------------
rSpawnCar.OnServerEvent:Connect(function(player) rSpawnCar.OnServerEvent:Connect(function(player)
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it) CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
+121 -7
View File
@@ -31,14 +31,86 @@ Chassis.DRIVE_SIGN = -1 -- forward (-Z) needs negative angular velocity about +X
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
-- Geometry (studs) ------------------------------------------------------------ -- Geometry (studs) ------------------------------------------------------------
Chassis.BODY_SIZE = Vector3.new(6, 1.4, 12) -- Sized to REAL imported car meshes, not the other way around. The donor models
Chassis.WHEEL_RADIUS = 1.4 -- in ServerStorage/CarLibrary average 17.4 studs long / ~8.5 wide / ~5 tall,
-- which is correct for a Roblox character (4.7 studs tall) to sit in. The old
-- 12-stud chassis forced those meshes to be scaled DOWN ~26%, which left the
-- driver sticking out of the roof. Chassis now matches the meshes at 1:1 scale.
Chassis.BODY_SIZE = Vector3.new(6.6, 1.6, 17.0)
-- Wheel size is set from the shell length, not picked by eye. A real car's
-- tyre diameter is ~14% of its length; at SHELL_LENGTH 19 that is ~2.7 studs.
-- The old 4.0-stud tyre (21%) is what made every car read as a monster truck,
-- and because GROUND_OFFSET is derived from it the whole body rode 4.2 studs
-- up in the air to clear it.
Chassis.WHEEL_RADIUS = 1.35
Chassis.WHEEL_WIDTH = 1.1 Chassis.WHEEL_WIDTH = 1.1
Chassis.TRACK_HALF = 3.35 -- lateral distance body centre -> wheel centre Chassis.TRACK_HALF = 3.55 -- lateral distance body centre -> wheel centre
Chassis.WHEELBASE_HALF = 4.2 -- longitudinal distance body centre -> axle Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle
Chassis.SUSPENSION_DROP = 1.6 -- rest distance from body centre down to wheel centre Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
-- Steering knuckle ------------------------------------------------------------
-- The knuckle must be a SUBSTANTIAL body, not a token 0.5 cube. The steering
-- servo yaws the knuckle, and the knuckle has to carry the wheel (mass 5.67)
-- against tyre-to-ground friction. At the old 0.5 size the knuckle massed
-- 0.087 -- 65x LIGHTER than the wheel hanging off it -- and the servo simply
-- lost: measured under real controller input, CurrentAngle missed TargetAngle
-- by a mean of 32 deg (peak 72 deg) and broke through the servo's own
-- LimitsEnabled bounds on 6 of 20 samples. A constraint blowing past its hard
-- limits is the signature of an under-massed link, not of a weak motor:
-- raising ServoMaxTorque cannot fix it because the torque was never the
-- binding constraint.
--
-- Measured sweep (full lock, real SteerFloat input, gtr_r34):
-- size 0.5 (mass 0.09) -> mean err 32.2 deg, 6/20 limit violations, drives
-- size 1.6 (mass 2.87) -> mean err 2.2 deg, 1/20 violations, drives
-- density 80 (mass 328) -> mean err 0.1 deg, 0/20 violations, CAR FROZEN
-- Mass is what buys control, but overshoot it and the knuckles outweigh the
-- 125-mass body and the car cannot move at all. 1.6 studs at stock density
-- puts the knuckle in the same order of magnitude as its wheel, which is the
-- physical relationship that matters. Do NOT "improve" this with density.
Chassis.KNUCKLE_SIZE = 1.6
-- Cabin fit --------------------------------------------------------------------
-- The driver used to stick out of the roof for THREE compounding reasons, all
-- fixed here + in CarManager:
--
-- 1. SEAT AT ROOF HEIGHT. The Body slab sits at the car's vertical MIDDLE,
-- but the seat was mounted just above it (BODY_SIZE.Y/2 + 0.3). That put
-- the avatar's hips level with the car's waistline and its head a full
-- stud above the roof. A seat belongs on the CABIN FLOOR, which is near
-- the car's BOTTOM -- i.e. measured up from the ground plane, not up from
-- the body slab. SEAT_ABOVE_GROUND does that.
-- 2. SHELLS NEVER SCALED. Imported meshes were parented at native size, which
-- ranges 15.4 -> 19.0 studs long across the library. Cars visibly differed
-- in size and the short ones had no cabin room. SHELL_LENGTH normalises
-- every shell to one length; the mesh's own proportions are preserved
-- because the scale is uniform (Model:ScaleTo).
-- 3. SHELL FLOATED. Placement used ext.Y/2 (a raw extents number that ignores
-- a model's pivot offset -- silv_s15's pivot is 1.45 studs off its own
-- geometry), so shells hovered above the wheels. CarManager now aligns the
-- shell's true bounding box BOTTOM to the ground plane.
--
-- Ground plane, relative to the Body part's centre: the wheels rest on it, so
-- it is one suspension drop plus one wheel radius below the body centre.
Chassis.GROUND_OFFSET = -(Chassis.SUSPENSION_DROP + Chassis.WHEEL_RADIUS)
-- Height of the seat PAN above that ground plane (cabin floor + cushion).
-- This is the STARTING height; CarManager lowers it per-car after the shell is
-- measured (see HEAD_ABOVE_SEAT / ROOF_CLEARANCE), because shells differ in
-- roof height by over a stud (hyper_x 4.92 vs gtr_r34 6.00).
Chassis.SEAT_ABOVE_GROUND = 0.45
-- Measured in Play on a default R15 avatar: the head CENTRE sits 3.27 above the
-- seat pan, but the head is 1.20 tall, so the CROWN is 3.87 up. The old 3.27
-- constant compared a centre against a roofline and so under-measured the
-- avatar by half a head -- the head cleared on paper and poked out on screen.
-- This is deliberately the crown, i.e. the number that must stay under the roof.
Chassis.HEAD_ABOVE_SEAT = 3.90
-- Target gap between the crown and the shell roofline. Now that HEAD_ABOVE_SEAT
-- is a true crown height this is real headroom, so it can be modest.
Chassis.ROOF_CLEARANCE = 0.30
-- Every imported shell is uniformly scaled so its bounding box is this long.
Chassis.SHELL_LENGTH = 19.0
-- Per-car tuning derived from stats ------------------------------------------- -- Per-car tuning derived from stats -------------------------------------------
export type Tuning = { export type Tuning = {
MaxSpeed: number, -- studs/sec target top speed MaxSpeed: number, -- studs/sec target top speed
@@ -52,8 +124,50 @@ export type Tuning = {
WheelFriction: number, WheelFriction: number,
} }
-- Suspension physics ----------------------------------------------------------
-- Derived rather than eyeballed. Reference: "In Depth Scripted Car Physics"
-- (devforum 3915628) -> docs/CAR_PHYSICS_REFERENCE.txt. That tutorial builds
-- raycast suspension, but the underlying spring math is identical for the
-- SpringConstraints we use, so the two tuning laws below carry over directly:
--
-- 1. STATIC SAG. A spring holding mass m compresses by x = m*g/k. If that
-- sag exceeds the available travel the suspension sits bottomed out, the
-- spring stops doing any work, and the body rides on the constraint limit
-- -- which reads as a twitchy, tippy car. The OLD constants (k=9000) gave
-- 1.10 studs of sag against 0.90 studs of TOTAL travel: permanently
-- bottomed. Now k is solved so the car rests at SAG_FRACTION of half
-- travel, leaving real droop AND real compression headroom.
--
-- 2. DAMPING RATIO. c = zeta * 2*sqrt(k*m). Damping cannot be picked
-- independently of stiffness -- it only means anything relative to
-- critical damping. The old pair worked out to zeta = 0.31 (floaty,
-- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within
-- that band by Handling so a grippy car is also a composed one.
--
-- !! CURRENTLY INERT !! CarManager.buildCorner welds the hub rigidly to the
-- body (see the comment there: a light hub on a stiff spring made the solver
-- diverge and fling the wheels away). There is no SpringConstraint in the rig
-- today, so SpringStiffness/SpringDamping below are computed but never
-- consumed -- the ride is rigid, not damped. These stay here because the
-- numbers are now correct and are what a re-introduced spring should use;
-- do NOT read a smooth ride-height trace as evidence they are working.
local GRAVITY = 196.2 -- studs/s^2 (Roblox default)
local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners
local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel
function Chassis.tuningFor(car: Car): Tuning function Chassis.tuningFor(car: Car): Tuning
local maxSpeed = 70 + car.Stats.Speed * 1.5 local maxSpeed = 70 + car.Stats.Speed * 1.5
local handling01 = car.Stats.Handling / 100
-- Solve stiffness from the sag target, then stiffen with Handling.
local halfTravel = Chassis.SUSPENSION_TRAVEL / 2
local baseK = CORNER_MASS * GRAVITY / (halfTravel * SAG_FRACTION)
local stiffness = baseK * (0.9 + handling01 * 0.45) -- ~28k soft .. ~41k stiff
-- Damping ratio 0.45 (compliant) -> 0.65 (buttoned down) by Handling.
local zeta = 0.45 + handling01 * 0.20
local damping = zeta * 2 * math.sqrt(stiffness * CORNER_MASS)
return { return {
MaxSpeed = maxSpeed, MaxSpeed = maxSpeed,
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS, MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
@@ -61,8 +175,8 @@ function Chassis.tuningFor(car: Car): Tuning
BrakeTorque = 30000, BrakeTorque = 30000,
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
SteerSpeed = 6, SteerSpeed = 6,
SpringStiffness = 9000, SpringStiffness = stiffness,
SpringDamping = 420, SpringDamping = damping,
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0 WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
} }
end end
+950 -74
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -51,4 +51,16 @@ GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Stu
-- (studs/sec) for the fastest car. Sustained above this => snap back. -- (studs/sec) for the fastest car. Sustained above this => snap back.
GameConfig.MaxPlausibleSpeed = 400 GameConfig.MaxPlausibleSpeed = 400
-- Development lighting -------------------------------------------------------
-- The shipping look is deep night (neon Wangan). While building geometry that
-- is nearly unusable — you cannot see the road surface, kerbs, gap placement or
-- the PA layout under a 22:00 sky.
--
-- Flip this to true for a flat, bright midday pass: full sun, no fog, no bloom,
-- no clock drift. It changes ONLY lighting/post — no gameplay, geometry or
-- physics differs, so anything verified in daylight holds at night.
--
-- Set back to false to restore the night look ("turn the lights off").
GameConfig.DevDaylight = true
return GameConfig return GameConfig
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Capture the speed-change (aux) lanes ANCHORED to the parts themselves.
Hardcoded camera offsets are how you end up photographing empty air after the
geometry moves. Every shot here is framed from the LIVE position of the aux
lane / slip parts queried out of the DataModel at capture time, so the camera
cannot drift away from the thing under test.
"""
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
# Ask Studio where the aux lanes actually are, then frame from that.
# Returns "x y z | x y z ..." anchors: west aux mid, east aux mid, and the
# two slip mouths.
ANCHOR_CODE = """
local map = workspace:FindFirstChild("Map")
if not map then return "NOMAP" end
local aux = {}
local mouths = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local ai = d.Name:match("^AuxLane_(%d+)$")
if ai then table.insert(aux, {i = tonumber(ai), p = d.Position}) end
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
if k and tonumber(k) <= 1 then mouths[s] = d.Position end
end
end
end
if #aux == 0 then return "NOAUX" end
table.sort(aux, function(a, b) return a.i < b.i end)
-- Split the aux segments into two clusters (west run and east run) by index gap.
local groups, cur = {}, {aux[1]}
for k = 2, #aux do
if aux[k].i - aux[k - 1].i > 3 then
table.insert(groups, cur); cur = {}
end
table.insert(cur, aux[k])
end
table.insert(groups, cur)
local parts = {}
for _, g in ipairs(groups) do
local mid = g[math.max(1, math.floor(#g / 2))].p
table.insert(parts, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z))
end
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
local m = mouths[s]
if m then table.insert(parts, ("%.1f %.1f %.1f"):format(m.X, m.Y, m.Z)) end
end
return table.concat(parts, " | ")
"""
CAM_TPL = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(
Vector3.new({px}, {py}, {pz}),
Vector3.new({tx}, {ty}, {tz})
)
return "cam ok"
"""
def grab(name, px, py, pz, tx, ty, tz, fov=55):
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {r}")
return None
time.sleep(1.5)
cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
return None
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
print(f"{name}: OK -> {dest}")
return dest
def main():
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
wait=40, timeout=300)
txt = a if isinstance(a, str) else str(a)
if "NOMAP" in txt or "NOAUX" in txt:
print("ANCHOR FAIL: " + txt[:200])
return
# Pull the coordinate triples out of whatever wrapper the bridge returns.
import re
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
if not trip:
print("ANCHOR PARSE FAIL: " + txt[:300])
return
pts = [tuple(float(v) for v in t) for t in trip]
print("anchors: " + str(pts))
made = []
for n, (x, y, z) in enumerate(pts[:2], start=1):
# Oblique from outside the deck, looking back at the merge.
d = grab(f"aux{n}_oblique", x + 260, y + 150, z + 260, x, y, z, fov=50)
if d:
made.append(d)
# Top-down: the merge taper reads clearest from directly above.
d = grab(f"aux{n}_topdown", x + 3, y + 430, z + 3, x, y, z, fov=42)
if d:
made.append(d)
# Driver's-eye down the lane.
d = grab(f"aux{n}_driver", x - 190, y + 16, z - 60, x + 120, y + 8, z + 40, fov=68)
if d:
made.append(d)
print("\nMADE " + str(len(made)) + " shots")
for m in made:
print(m)
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Capture the two spots the user circled in their screenshot.
Their view looks along the mainline with the ramp descending on the left; the
two marks sit at the suppression boundary (mainline samples ~525 and ~551),
where the aux lane ends and the parapet transitions back to the deck edge.
Anchor the camera to those SAMPLES, live, so the shot cannot drift.
left oval -> sample 525 area: was a 4-sample hole with no barrier
right circle -> sample 551 area: was a 26-stud protruding fence stub
"""
import re
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
ANCHOR_CODE = """
local map = workspace:FindFirstChild("Map")
if not map then return "NOMAP" end
local deck = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
if k then deck[tonumber(k)] = d end
end
end
local out = {}
for _, i in ipairs({520, 525, 538, 551, 556}) do
local d = deck[i]
if d then
table.insert(out, ("%.1f %.1f %.1f"):format(d.Position.X, d.Position.Y, d.Position.Z))
end
end
if #out < 5 then return "NOSAMP" end
return table.concat(out, " | ")
"""
CAM_TPL = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(
Vector3.new({px}, {py}, {pz}),
Vector3.new({tx}, {ty}, {tz})
)
return "cam ok"
"""
def grab(name, px, py, pz, tx, ty, tz, fov=55):
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {r}")
return None
time.sleep(1.5)
cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
return None
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
print(f"{name}: OK -> {dest}")
return dest
def main():
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
wait=40, timeout=300)
txt = a if isinstance(a, str) else str(a)
if "NOMAP" in txt or "NOSAMP" in txt:
print("ANCHOR FAIL: " + txt[:200])
return
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
if len(trip) < 5:
print("ANCHOR PARSE FAIL: " + txt[:300])
return
p = [tuple(float(v) for v in t) for t in trip]
s520, s525, s538, s551, s556 = p
print("anchors: " + str(p))
made = []
# LEFT OVAL: the formerly-bare run. Look along the road from upstream so the
# barrier line is seen edge-on -- a gap would read as an obvious break.
made.append(grab("fix_left_oval",
s520[0] - 90, s520[1] + 55, s520[2] - 90,
s538[0], s538[1], s538[2], fov=55))
# RIGHT CIRCLE: the transition where the fence used to jut out 26 studs.
made.append(grab("fix_right_circle",
s556[0] + 120, s556[1] + 50, s556[2] + 120,
s551[0], s551[1], s551[2], fov=50))
# Top-down over each boundary: a jut shows instantly as a step in the line.
made.append(grab("fix_boundary_525_top",
s525[0], s525[1] + 300, s525[2],
s525[0], s525[1], s525[2], fov=55))
made.append(grab("fix_boundary_551_top",
s551[0], s551[1] + 300, s551[2],
s551[0], s551[1], s551[2], fov=55))
# Wide overview matching the user's framing.
made.append(grab("fix_overview",
s538[0] - 420, s538[1] + 240, s538[2] - 420,
s538[0], s538[1], s538[2], fov=60))
made = [m for m in made if m]
print("\nMADE " + str(len(made)) + " shots")
for m in made:
print(m)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Eyes-on shots at the two REPORTED defects (not generic beauty angles).
Targets:
A entry ramp start (-4122, 47, -7860) -- the 390-stud access gap
B exit ramp start (-4258, 47, -7836)
C mainline junction (-3700, 102, -7184) -- the dY -6.0 step
D ramp vs nearest building(-4419, 223, -6751)
"""
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
SHOTS = [
# name, camera pos, look-at
("D1_entry_ramp_start", (-4122 + 210, 47 + 120, -7860 + 210), (-4122, 47, -7860)),
("D2_entry_start_low", (-4122 + 70, 47 + 25, -7860 + 70), (-4122, 55, -7860)),
("D3_exit_ramp_start", (-4258 - 200, 47 + 110, -7836 + 200), (-4258, 47, -7836)),
("D4_junction_step", (-3700 + 190, 102 + 90, -7184 + 190), (-3700, 102, -7184)),
("D5_ramp_vs_building", (-4419 + 520, 223 + 180, -6751 + 520), (-4300, 120, -7100)),
("D6_corridor_along", (-4122, 47 + 260, -7860 + 480), (-4300, 110, -7200)),
]
CAM = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(Vector3.new({px},{py},{pz}), Vector3.new({tx},{ty},{tz}))
return "ok"
"""
def main():
made = []
for name, (px, py, pz), (tx, ty, tz) in SHOTS:
fov = 70 if "low" in name or "along" in name else 55
r = rbx.call(
"execute_luau",
{
"datamodel_type": "Edit",
"code": CAM.format(
fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz
),
},
wait=12,
timeout=300,
)
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {str(r)[:120]}")
continue
time.sleep(1.5)
cap = rbx.call(
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:140]}")
continue
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
made.append(dest)
print(f"{name}: OK -> {dest}")
print(f"\nMADE {len(made)}")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Eyes-on shots of the PA flyover ramps.
Camera targets are QUERIED FROM THE LIVE GEOMETRY on every run, never
hardcoded. The previous capture script pinned literal coordinates at the
old defect sites; after the ramps were redesigned those coords pointed at
empty air and the shots silently framed the wrong thing. Same class of bug
as the hardcoded yFork=96 -- a constant that stops matching the model.
Each shot names a part and an offset; the part's real position is looked up
in Studio at capture time. If a named part is missing the shot is SKIPPED
with a loud message rather than falling back to a stale guess.
"""
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
# name, anchor part, camera offset from anchor, look-at offset from anchor, fov
SHOTS = [
# The flyover itself, from the side -- the money shot: ramp over deck.
("F1_flyover_side", "PAEntryRampCross_100", (300, 150, 300), (0, -20, 0), 55),
# Underneath the crossing, looking up: proves real clearance.
("F2_flyover_under", "PAEntryRampCross_100", (140, -70, 140), (0, 10, 0), 75),
# The spiral descending into the PA apron.
("F3_spiral_into_pa", "PAEntryRampTrunk_200", (340, 220, 340), (-120, -60, -120), 60),
# Exit ramp climbing out of the PA.
("F4_exit_climb", "PAExitRampTrunk_200", (-340, 200, 300), (60, -40, 40), 60),
# Where the descent lands on the far carriageway.
("F5_landing_merge", "PAEntryRampNear_200", (220, 130, 220), (0, -15, 0), 55),
# Wide establishing shot of the whole interchange.
("F6_wide", "PAEntryRampCross_100", (900, 620, 900), (-250, -120, -250), 60),
]
POS_QUERY = """
local ws=game:GetService("Workspace"); local map=ws:FindFirstChild("Map")
if not map then return "NOMAP" end
local want={}
for _,n in ipairs({%s}) do want[n]=true end
local found={}
for _,d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and want[d.Name] then
found[#found+1]=("%%s=%%.1f,%%.1f,%%.1f"):format(d.Name,d.Position.X,d.Position.Y,d.Position.Z)
end
end
return table.concat(found,"|")
"""
CAM = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(Vector3.new({px},{py},{pz}), Vector3.new({tx},{ty},{tz}))
return "ok"
"""
def fetch_anchors(names):
"""Look up each anchor part's real position in the live datamodel."""
lst = ",".join('"%s"' % n for n in sorted(set(names)))
r = rbx.call(
"execute_luau",
{"datamodel_type": "Edit", "code": POS_QUERY % lst},
wait=40,
timeout=400,
)
txt = str(r)
out = {}
for chunk in txt.split("|"):
if "=" not in chunk:
continue
nm, _, vals = chunk.partition("=")
nm = nm.strip().strip("'\"[]{}() ")
parts = vals.strip().strip("'\"[]{}() ").split(",")
if len(parts) != 3:
continue
try:
out[nm] = tuple(float(v) for v in parts)
except ValueError:
continue
return out
def main():
anchors = fetch_anchors([s[1] for s in SHOTS])
print(f"resolved {len(anchors)} anchors: {sorted(anchors)}")
made, skipped = [], []
for name, anchor, cam_off, look_off, fov in SHOTS:
if anchor not in anchors:
print(f"{name}: SKIP -- anchor {anchor} not found in model")
skipped.append(name)
continue
ax, ay, az = anchors[anchor]
px, py, pz = ax + cam_off[0], ay + cam_off[1], az + cam_off[2]
tx, ty, tz = ax + look_off[0], ay + look_off[1], az + look_off[2]
r = rbx.call(
"execute_luau",
{
"datamodel_type": "Edit",
"code": CAM.format(
fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz
),
},
wait=12,
timeout=300,
)
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {str(r)[:120]}")
skipped.append(name)
continue
time.sleep(1.5)
cap = rbx.call(
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:140]}")
skipped.append(name)
continue
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
made.append(dest)
print(f"{name}: OK ({anchor} @ {ax:.0f},{ay:.0f},{az:.0f}) -> {dest}")
print(f"\nMADE {len(made)} SKIPPED {len(skipped)} {skipped if skipped else ''}")
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Capture the PA slip ramps from the user's own viewpoint, LIVE-ANCHORED.
The user's screenshot looks along the mainline from above the deck with the
ramp descending on the left. Reproduce that framing from the actual ramp
geometry so the shot cannot drift onto empty air after an edit.
"""
import re
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
# Anchors: for each slip -> mouth (first deck seg), mid-span, landing (last seg).
ANCHOR_CODE = """
local map = workspace:FindFirstChild("Map")
if not map then return "NOMAP" end
local segs = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
if k then
segs[s] = segs[s] or {}
table.insert(segs[s], {i = tonumber(k), p = d.Position})
end
end
end
end
local out = {}
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
local g = segs[s]
if g and #g > 2 then
table.sort(g, function(a, b) return a.i < b.i end)
local mouth = g[1].p
local mid = g[math.floor(#g / 2)].p
local land = g[#g].p
table.insert(out, ("%.1f %.1f %.1f"):format(mouth.X, mouth.Y, mouth.Z))
table.insert(out, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z))
table.insert(out, ("%.1f %.1f %.1f"):format(land.X, land.Y, land.Z))
end
end
if #out == 0 then return "NOSLIP" end
return table.concat(out, " | ")
"""
CAM_TPL = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(
Vector3.new({px}, {py}, {pz}),
Vector3.new({tx}, {ty}, {tz})
)
return "cam ok"
"""
def grab(name, px, py, pz, tx, ty, tz, fov=55):
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {r}")
return None
time.sleep(1.5)
cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
return None
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
print(f"{name}: OK -> {dest}")
return dest
def main():
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
wait=40, timeout=300)
txt = a if isinstance(a, str) else str(a)
if "NOMAP" in txt or "NOSLIP" in txt:
print("ANCHOR FAIL: " + txt[:200])
return
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
if len(trip) < 6:
print("ANCHOR PARSE FAIL: " + txt[:300])
return
pts = [tuple(float(v) for v in t) for t in trip]
print("anchors: " + str(pts))
made = []
for n, base in enumerate((0, 3), start=1):
mouth, mid, land = pts[base], pts[base + 1], pts[base + 2]
tag = ["west", "east"][n - 1]
# 1. The user's view: high, behind the mouth, looking down the ramp.
made.append(grab(
f"ramp_{tag}_userview",
mouth[0] - 300, mouth[1] + 120, mouth[2] - 300,
land[0], land[1], land[2], fov=60))
# 2. Side elevation: shows the descent profile against the deck.
made.append(grab(
f"ramp_{tag}_side",
mid[0] + 420, mid[1] + 90, mid[2],
mid[0], mid[1], mid[2], fov=48))
# 3. Top-down over mid-span: is it parallel to the deck or cutting away?
made.append(grab(
f"ramp_{tag}_topdown",
mid[0], mid[1] + 560, mid[2],
mid[0], mid[1], mid[2], fov=55))
made = [m for m in made if m]
print("\nMADE " + str(len(made)) + " shots")
for m in made:
print(m)
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Capture a set of Studio viewport angles of the Daikoku PA junction.
Sets the Edit-mode camera via execute_luau, then pulls a screen_capture for
each shot. Bridge calls are slow (~30-60s each), so this runs as one batch.
NOTE (corrects an old assumption): screen_capture DOES work on this setup.
It needs capture_id='ScreenCapture_1'. Earlier notes claimed the Studio
viewport could not be screenshotted from wks-victus -- that was wrong.
"""
import shutil
import sys
import time
sys.path.insert(0, "/tmp")
import rbx # noqa: E402
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
# PA centre measured from the built parts.
CX, CY, CZ = -4116, 51, -7479
SHOTS = [
# name, camera offset from PA centre, look-at offset
("01_overview_oblique", (520, 340, 520), (0, 0, 0)),
("02_top_down", (5, 900, 5), (0, 0, 0)),
("03_underneath_clearance", (330, 20, 300), (0, 30, 0)),
("04_onramp_low", (-250, 70, 60), (60, 20, -40)),
("05_offramp_low", (300, 75, -160), (-60, 20, 40)),
("06_section_side", (900, 120, 40), (0, 25, 0)),
]
CAM_TPL = """
local cam = workspace.CurrentCamera
cam.CameraType = Enum.CameraType.Scriptable
cam.FieldOfView = {fov}
cam.CFrame = CFrame.lookAt(
Vector3.new({px}, {py}, {pz}),
Vector3.new({tx}, {ty}, {tz})
)
return "cam ok"
"""
def main():
made = []
for name, (ox, oy, oz), (tx, ty, tz) in SHOTS:
fov = 55 if "top_down" not in name else 40
code = CAM_TPL.format(
fov=fov,
px=CX + ox,
py=CY + oy,
pz=CZ + oz,
tx=CX + tx,
ty=CY + ty,
tz=CZ + tz,
)
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
if isinstance(r, dict) and r.get("isError"):
print(f"{name}: CAM FAIL {r}")
continue
time.sleep(1.5)
cap = rbx.call(
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
)
imgs = cap.get("images") if isinstance(cap, dict) else None
if not imgs:
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
continue
dest = f"{OUT}/{name}.jpg"
shutil.copy(imgs[0], dest)
made.append(dest)
print(f"{name}: OK -> {dest}")
print("\nMADE " + str(len(made)) + " shots")
for m in made:
print(m)
if __name__ == "__main__":
main()
+48
View File
@@ -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 "<not found>")
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")
+116
View File
@@ -0,0 +1,116 @@
-- CONTROL + CONNECTIVITY.
-- 1. Control: deliberately test a part known to intersect structure, so a
-- 0-hit result on the ramps means "clear", not "probe is broken".
-- 2. Connectivity: largest gap between consecutive deck segments within each
-- run, and the trunk->near handoff distance.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "no Map"
end
local structural, runs = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local nm = d.Name
if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then
table.insert(structural, d)
end
for _, base in ipairs({
"PAEntryRampTrunk",
"PAEntryRampCross",
"PAEntryRampNear",
"PAExitRampTrunk",
"PAExitRampCross",
"PAExitRampNear",
}) do
if nm:sub(1, #base + 1) == base .. "_" then
runs[base] = runs[base] or {}
table.insert(runs[base], d)
end
end
end
end
local function overlaps(p, s)
local d1, d2 = p.Position, s.Position
return math.abs(d1.X - d2.X) < (p.Size.X + s.Size.X) / 2
and math.abs(d1.Z - d2.Z) < (p.Size.Z + s.Size.Z) / 2
and math.abs(d1.Y - d2.Y) < (p.Size.Y + s.Size.Y) / 2
end
local out = {}
-- CONTROL: a probe box placed exactly on the first structural part must hit.
local ctrl = structural[1]
local fake = { Position = ctrl.Position, Size = Vector3.new(4, 4, 4) }
local ctrlHits = 0
for _, s in ipairs(structural) do
if overlaps(fake, s) then
ctrlHits += 1
end
end
table.insert(
out,
("CONTROL (box on a known pier): hits=%d -> probe %s"):format(
ctrlHits,
ctrlHits > 0 and "WORKS" or "IS BROKEN/VACUOUS"
)
)
table.insert(out, "structuralParts=" .. #structural)
-- CONNECTIVITY
local function sortKey(p)
return tonumber(p.Name:match("_(%d+)$")) or 0
end
for _, base in ipairs({
"PAEntryRampTrunk",
"PAEntryRampCross",
"PAEntryRampNear",
"PAExitRampTrunk",
"PAExitRampCross",
"PAExitRampNear",
}) do
local parts = runs[base]
if not parts then
table.insert(out, base .. ": MISSING")
else
table.sort(parts, function(a, b)
return sortKey(a) < sortKey(b)
end)
local maxGap = 0
for i = 2, #parts do
local g = (parts[i].Position - parts[i - 1].Position).Magnitude
maxGap = math.max(maxGap, g)
end
table.insert(out, ("%s: n=%d maxSegGap=%.1f"):format(base, #parts, maxGap))
end
end
-- Handoffs along the flyover chain: Trunk -> Cross -> Near. The ramps now
-- cross OVER the mainline, so `Cross` sits between the spiral and the
-- descent; checking Trunk->Near directly would report a false gap.
for _, pair in ipairs({
{ "PAEntryRampTrunk", "PAEntryRampCross" },
{ "PAEntryRampCross", "PAEntryRampNear" },
{ "PAExitRampTrunk", "PAExitRampCross" },
{ "PAExitRampCross", "PAExitRampNear" },
}) do
local t, nr = runs[pair[1]], runs[pair[2]]
if t and nr then
table.sort(t, function(a, b)
return sortKey(a) < sortKey(b)
end)
table.sort(nr, function(a, b)
return sortKey(a) < sortKey(b)
end)
local best = math.huge
for _, a in ipairs({ t[1], t[#t] }) do
for _, b in ipairs({ nr[1], nr[#nr] }) do
best = math.min(best, (a.Position - b.Position).Magnitude)
end
end
table.insert(out, ("HANDOFF %s->%s closestEnd=%.1f"):format(pair[1], pair[2], best))
end
end
return table.concat(out, "\n")
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+145
View File
@@ -0,0 +1,145 @@
-- Diagnose the two reported defects:
-- (1) ramps not accessible in either direction (connectivity)
-- (2) ramps pass through buildings (clipping)
-- Uses native GetPartBoundsInBox so this stays fast on ~22k parts.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local BUILDING = {
Building = true,
TokyoTower = true,
TowerDeck = true,
TowerAntenna = true,
ShiodomeTower = true,
TowerCrown = true,
DietTower = true,
RoppongiHills = true,
TokyoStation = true,
StationDome = true,
Skytree = true,
GinzaBlock = true,
NationalDiet = true,
BridgeTower = true,
TowerCrossbeam = true,
Structure = true,
PalaceWall = true,
}
local ramp, drivable = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local n = d.Name
if n:find("PAEntryRamp") or n:find("PAExitRamp") then
table.insert(ramp, d)
elseif n:match("^Road_%d+$") or n == "PAFloor" then
table.insert(drivable, d)
end
end
end
if #ramp == 0 then
return "no ramp parts"
end
-- ---------- (2) BUILDING CLIP CHECK ----------
local op = OverlapParams.new()
op.MaxParts = 40
local clipHits, clipNames, worst = 0, {}, {}
for _, r in ipairs(ramp) do
local hits = ws:GetPartBoundsInBox(r.CFrame, r.Size, op)
for _, h in ipairs(hits) do
if h ~= r and BUILDING[h.Name] then
clipHits += 1
clipNames[h.Name] = (clipNames[h.Name] or 0) + 1
if #worst < 5 then
table.insert(worst, ("%s@%.0f,%.0f,%.0f"):format(r.Name, r.Position.X, r.Position.Y, r.Position.Z))
end
break
end
end
end
-- CONTROL: same probe against mainline Road_ parts (known-good, should be ~0)
local ctrlHits, ctrlN = 0, 0
for i = 1, #drivable, 9 do
local d = drivable[i]
ctrlN += 1
local hits = ws:GetPartBoundsInBox(d.CFrame, d.Size, op)
for _, h in ipairs(hits) do
if h ~= d and BUILDING[h.Name] then
ctrlHits += 1
break
end
end
end
local cn = {}
for k, v in pairs(clipNames) do
table.insert(cn, k .. "=" .. v)
end
-- ---------- (1) CONNECTIVITY ----------
-- Ramp basename groups, then endpoints of each group by trailing index.
local groups = {}
for _, r in ipairs(ramp) do
local base, idx = r.Name:match("^(.-)_?(%d+)$")
base = base or r.Name
idx = tonumber(idx) or 0
local g = groups[base]
if not g then
g = { lo = math.huge, hi = -math.huge, loP = nil, hiP = nil, n = 0 }
groups[base] = g
end
g.n += 1
if idx < g.lo then
g.lo, g.loP = idx, r
end
if idx > g.hi then
g.hi, g.hiP = idx, r
end
end
-- For an endpoint, find nearest drivable surface and report gap + height delta.
local function nearestDrivable(p)
local bd, bp = math.huge, nil
for _, d in ipairs(drivable) do
local dd = (d.Position - p).Magnitude
if dd < bd then
bd, bp = dd, d
end
end
return bd, bp
end
local lines = {}
for base, g in pairs(groups) do
if g.loP and g.hiP then
local d1, p1 = nearestDrivable(g.loP.Position)
local d2, p2 = nearestDrivable(g.hiP.Position)
table.insert(
lines,
("%s n=%d | START gap=%.0f dY=%.1f to %s | END gap=%.0f dY=%.1f to %s"):format(
base,
g.n,
d1,
p1 and (g.loP.Position.Y - p1.Position.Y) or -999,
p1 and p1.Name or "nil",
d2,
p2 and (g.hiP.Position.Y - p2.Position.Y) or -999,
p2 and p2.Name or "nil"
)
)
end
end
return ("CLIP ramp=%d/%d parts hit buildings [%s] samples=[%s] || CONTROL road %d/%d hit\n%s"):format(
clipHits,
#ramp,
table.concat(cn, ", "),
table.concat(worst, " ; "),
ctrlHits,
ctrlN,
table.concat(lines, "\n")
)
+109
View File
@@ -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")
+167
View File
@@ -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")
+136
View File
@@ -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")
+91
View File
@@ -0,0 +1,91 @@
-- Why did the building-clip probe return 0 with a 0 control? Diagnose the metric.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
local BUILDING = {
Building = true,
TokyoTower = true,
TowerDeck = true,
TowerAntenna = true,
ShiodomeTower = true,
TowerCrown = true,
DietTower = true,
RoppongiHills = true,
TokyoStation = true,
StationDome = true,
Skytree = true,
GinzaBlock = true,
NationalDiet = true,
BridgeTower = true,
TowerCrossbeam = true,
Structure = true,
PalaceWall = true,
}
local ramp, bld = {}, {}
local anyQueryOff, anyCollideOff = 0, 0
for _, d in ipairs(ws:GetDescendants()) do
if d:IsA("BasePart") then
if d.Name:find("PAEntryRamp") or d.Name:find("PAExitRamp") then
table.insert(ramp, d)
elseif BUILDING[d.Name] then
table.insert(bld, d)
if not d.CanQuery then
anyQueryOff += 1
end
if not d.CanCollide then
anyCollideOff += 1
end
end
end
end
if #bld == 0 then
return "NO building parts found in Workspace at all (name set wrong?)"
end
-- nearest building to the ramp chain (subsample ramps for speed)
local bd, br, bb = math.huge, nil, nil
for i = 1, #ramp, 7 do
local r = ramp[i]
for _, b in ipairs(bld) do
local dd = (r.Position - b.Position).Magnitude
if dd < bd then
bd, br, bb = dd, r, b
end
end
end
-- how many buildings are anywhere near the PA at all?
local cen = Vector3.new(-4128, 81, -7438)
local near2000 = 0
for _, b in ipairs(bld) do
if (b.Position - cen).Magnitude < 2000 then
near2000 += 1
end
end
-- does the raw overlap API return ANYTHING for a ramp part? (metric sanity)
local op = OverlapParams.new()
op.MaxParts = 50
local sample = ramp[math.floor(#ramp / 2)]
local hits = ws:GetPartBoundsInBox(sample.CFrame, sample.Size * 1.2, op)
local hn = {}
for i = 1, math.min(#hits, 8) do
table.insert(hn, hits[i].Name)
end
return ("buildings=%d (CanQuery off=%d, CanCollide off=%d) | nearestBldToRamp=%.0f (%s -> %s @%.0f,%.0f,%.0f) | bldWithin2000ofPA=%d | overlapSanity hits=%d [%s]"):format(
#bld,
anyQueryOff,
anyCollideOff,
bd,
br and br.Name or "nil",
bb and bb.Name or "nil",
bb and bb.Position.X or 0,
bb and bb.Position.Y or 0,
bb and bb.Position.Z or 0,
near2000,
#hits,
table.concat(hn, ",")
)
+161
View File
@@ -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 " <nothing from the car>")
-- 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")
+161
View File
@@ -0,0 +1,161 @@
--!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")
+72
View File
@@ -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")
+86
View File
@@ -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,
})
+182
View File
@@ -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")
+109
View File
@@ -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")
+67
View File
@@ -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")
+116
View File
@@ -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")
+200
View File
@@ -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")
+155
View File
@@ -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 <direction>"
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")
+116
View File
@@ -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")
+101
View File
@@ -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")
+77
View File
@@ -0,0 +1,77 @@
-- Diagnose the raycast outlier: report WHICH parts the rays hit.
-- The v3 raycast filtered on the whole map, so overhead geometry (overpass
-- decks, girders, gantries, tunnel ceilings) can be hit before the road.
local sss = game:GetService("ServerScriptService")
local orig = sss:FindFirstChild("MapBuilder", true)
local clone = orig:Clone()
clone.Name = "MapBuilder_Diag"
clone.Parent = orig.Parent
local MB = require(clone)
local map = MB.build()
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
local samples = Circuit.samples
local n = #samples
local rp = RaycastParams.new()
rp.FilterType = Enum.RaycastFilterType.Include
rp.FilterDescendantsInstances = { map }
local out = {}
-- Road-only params: what the driver actually touches.
local roadOnly = RaycastParams.new()
roadOnly.FilterType = Enum.RaycastFilterType.Include
roadOnly.FilterDescendantsInstances = { map:FindFirstChild("Expressway") }
local worstAll, worstRoad, atAll, atRoad = 0, 0, -1, -1
local hits = {}
for i = 1, n do
local j = (i % n) + 1
local p = samples[j].pos
local fwd = (samples[(j % n) + 1].pos - samples[i].pos)
fwd = Vector3.new(fwd.X, 0, fwd.Z)
if fwd.Magnitude > 0.01 then
fwd = fwd.Unit
local o1 = p - fwd * 3 + Vector3.new(0, 40, 0)
local o2 = p + fwd * 3 + Vector3.new(0, 40, 0)
local a1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), rp)
local a2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), rp)
if a1 and a2 then
local d = math.abs(a1.Position.Y - a2.Position.Y)
if d > worstAll then
worstAll, atAll = d, i
end
if d > 1 then
hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format(
i,
samples[i].piece,
d,
a1.Instance.Name,
a2.Instance.Name
)
end
end
local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly)
local b2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), roadOnly)
if b1 and b2 then
local d = math.abs(b1.Position.Y - b2.Position.Y)
if d > worstRoad then
worstRoad, atRoad = d, i
end
end
end
end
out[#out + 1] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll)
out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format(
worstRoad,
atRoad,
worstRoad <= 0.60 and "PASS" or "FAIL"
)
out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits)
for k = 1, math.min(12, #hits) do
out[#out + 1] = " " .. hits[k]
end
map:Destroy()
clone:Destroy()
return table.concat(out, "\n")
+120
View File
@@ -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")
+109
View File
@@ -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 <no car def for this shell id>", 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 <no Shell child -- using procedural CarBody>", 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 <shell has no usable F/R wheels after build (F=%d R=%d)>", id, fN, rN))
end
end
model:Destroy()
end
end
pad:Destroy()
clone:Destroy()
return table.concat(out, "\n")
+136
View File
@@ -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(" <no Shell>")
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")
+190
View File
@@ -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")
+108
View File
@@ -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")
+140
View File
@@ -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")
+95
View File
@@ -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")
+144
View File
@@ -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")
+101
View File
@@ -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 "<unreadable>",
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")
+204
View File
@@ -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")
+188
View File
@@ -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 "<missing>"
end
local r = p.AssemblyRootPart
return r and r:GetFullName():gsub("^.*%.", "") or "<none>"
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")
+153
View File
@@ -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")
+179
View File
@@ -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")
+203
View File
@@ -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")
+92
View File
@@ -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,
})
+139
View File
@@ -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")
+167
View File
@@ -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")
+184
View File
@@ -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")
+51
View File
@@ -0,0 +1,51 @@
-- Export a top-down plan of the PA area as JSON-ish text so it can be
-- plotted offline, and measure the median-barrier opening properly by
-- looking for gaps along the mainline centreline near each junction.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
local PA = Vector3.new(-4124, 46, -7470)
local RANGE = 2600 -- studs around the PA to export
local buckets = {
EntryTrunk = {},
EntryNear = {},
ExitTrunk = {},
ExitNear = {},
Road = {},
Barrier = {},
Ring = {},
}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local p = d.Position
if math.abs(p.X - PA.X) < RANGE and math.abs(p.Z - PA.Z) < RANGE then
local nm = d.Name
local key
if nm:sub(1, 18) == "PAEntryRampTrunk_" or nm:sub(1, 17) == "PAEntryRampTrunk_" then
key = "EntryTrunk"
elseif nm:sub(1, 16) == "PAEntryRampNear_" then
key = "EntryNear"
elseif nm:sub(1, 16) == "PAExitRampTrunk_" then
key = "ExitTrunk"
elseif nm:sub(1, 15) == "PAExitRampNear_" then
key = "ExitNear"
elseif nm:gsub("_?%d+$", "") == "Road" then
key = "Road"
elseif nm:gsub("_?%d+$", "") == "Barrier" then
key = "Barrier"
elseif nm:find("PARing") then
key = "Ring"
end
if key then
table.insert(buckets[key], ("%d,%d,%d"):format(math.floor(p.X), math.floor(p.Z), math.floor(p.Y)))
end
end
end
end
local out = {}
for k, v in pairs(buckets) do
table.insert(out, "#" .. k .. "#" .. table.concat(v, ";"))
end
return table.concat(out, "\n")
+92
View File
@@ -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")
+110
View File
@@ -0,0 +1,110 @@
-- Measure the redesigned ramps: extent, grade, side (E/W), and collisions
-- with STRUCTURAL parts (piers, masts, girders). Read-only.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "no Map"
end
local PA = Vector3.new(-4124, 46, -7470)
-- Gather deck parts per run.
local runs = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local nm = d.Name
for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do
if nm:sub(1, #base) == base then
runs[base] = runs[base] or {}
table.insert(runs[base], d)
end
end
end
end
local out = {}
local order = { "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }
for _, base in ipairs(order) do
local parts = runs[base]
if not parts then
table.insert(out, base .. ": MISSING")
else
local minY, maxY = math.huge, -math.huge
local minX, maxX = math.huge, -math.huge
local minZ, maxZ = math.huge, -math.huge
local maxGrade = 0
table.sort(parts, function(a, b)
return a.Name < b.Name
end)
for _, p in ipairs(parts) do
local q = p.Position
minY, maxY = math.min(minY, q.Y), math.max(maxY, q.Y)
minX, maxX = math.min(minX, q.X), math.max(maxX, q.X)
minZ, maxZ = math.min(minZ, q.Z), math.max(maxZ, q.Z)
end
for i = 2, #parts do
local a, b = parts[i - 1].Position, parts[i].Position
local horiz = (Vector3.new(a.X, 0, a.Z) - Vector3.new(b.X, 0, b.Z)).Magnitude
if horiz > 0.5 then
maxGrade = math.max(maxGrade, math.abs(b.Y - a.Y) / horiz * 100)
end
end
-- mean X relative to PA centre tells us east (+) or west (-)
local sx = 0
for _, p in ipairs(parts) do
sx += p.Position.X
end
local side = (sx / #parts) > PA.X and "EAST" or "WEST"
table.insert(
out,
("%s: n=%d Y=%.0f..%.0f grade<=%.2f%% side=%s X=%.0f..%.0f Z=%.0f..%.0f"):format(
base,
#parts,
minY,
maxY,
maxGrade,
side,
minX,
maxX,
minZ,
maxZ
)
)
end
end
-- Structural collision test: does any ramp deck part overlap a pier/mast?
local structural = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local nm = d.Name
if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then
table.insert(structural, d)
end
end
end
table.insert(out, "structuralParts=" .. #structural)
local hits = 0
local detail = {}
for _, base in ipairs(order) do
local parts = runs[base] or {}
local h = 0
for _, p in ipairs(parts) do
for _, s in ipairs(structural) do
local dx = math.abs(p.Position.X - s.Position.X)
local dz = math.abs(p.Position.Z - s.Position.Z)
local dy = math.abs(p.Position.Y - s.Position.Y)
local rx = (p.Size.X + s.Size.X) / 2
local rz = (p.Size.Z + s.Size.Z) / 2
local ry = (p.Size.Y + s.Size.Y) / 2
if dx < rx and dz < rz and dy < ry then
h += 1
end
end
end
hits += h
table.insert(detail, base .. "=" .. h)
end
table.insert(out, "STRUCTURAL_HITS total=" .. hits .. " (" .. table.concat(detail, " ") .. ")")
return table.concat(out, "\n")
+71
View File
@@ -0,0 +1,71 @@
-- Median opening test, now against the CORRECT part name. The centre divider
-- is "Median_<i>"; "Barrier" is the outer edge parapet (which must NOT open).
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
local medians, roads = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local b = d.Name:gsub("_?%d+$", "")
if b == "Median" then
table.insert(medians, d.Position)
elseif b == "Road" then
table.insert(roads, d.Position)
end
end
end
local anchors = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and (d.Name == "PAEntryRampNear_200" or d.Name == "PAExitRampNear_200") then
table.insert(anchors, { d.Name, d.Position })
end
end
local WIN = 220
local function countNear(pos, list)
local n = 0
for _, bp in ipairs(list) do
if (Vector3.new(pos.X, 0, pos.Z) - Vector3.new(bp.X, 0, bp.Z)).Magnitude < WIN then
n += 1
end
end
return n
end
local out = {}
table.insert(out, ("medianParts=%d roadParts=%d window=+/-%d"):format(#medians, #roads, WIN))
local ctrl = {}
local step = math.max(1, math.floor(#roads / 14))
for i = 1, #roads, step do
local p = roads[i]
local far = true
for _, a in ipairs(anchors) do
if (Vector3.new(p.X, 0, p.Z) - Vector3.new(a[2].X, 0, a[2].Z)).Magnitude < 700 then
far = false
end
end
if far then
table.insert(ctrl, countNear(p, medians))
end
end
table.sort(ctrl)
local sum = 0
for _, v in ipairs(ctrl) do
sum += v
end
table.insert(
out,
("CONTROL plain mainline (n=%d): min=%d median=%d max=%d mean=%.1f"):format(
#ctrl,
ctrl[1] or -1,
ctrl[math.max(1, math.floor(#ctrl / 2))] or -1,
ctrl[#ctrl] or -1,
#ctrl > 0 and sum / #ctrl or -1
)
)
for _, a in ipairs(anchors) do
table.insert(out, ("JUNCTION %s: mediansInWindow=%d"):format(a[1], countNear(a[2], medians)))
end
return table.concat(out, "\n")
+161
View File
@@ -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")
+163
View File
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
f'<rect width="{size}" height="{size}" fill="#0e0e12"/>'
]
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'<g transform="translate({sx(p["x"]):.1f},{sy(p["z"]):.1f}) rotate({-p["r"]:.1f})">'
f'<rect x="{-w/2:.1f}" y="{-h/2:.1f}" width="{w:.1f}" height="{h:.1f}"'
f' fill="{col}" opacity="{op}"/></g>'
)
svg.append(f'<circle cx="{sx(cx):.0f}" cy="{sy(cz):.0f}" r="5" fill="#39ff14"/>')
svg.append(
f'<text x="20" y="34" fill="#eee" font-family="monospace" font-size="21">{title}</text>'
)
for i, (lab, col) in enumerate(LEGEND):
yy = 64 + i * 25
svg.append(
f'<rect x="20" y="{yy-11}" width="16" height="13" fill="{col}"/>'
f'<text x="44" y="{yy}" fill="#bbb" font-family="monospace" font-size="16">{lab}</text>'
)
svg.append("</svg>")
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()
+73
View File
@@ -0,0 +1,73 @@
-- The rebuild was interrupted mid-flight; its effect is UNKNOWN. Establish
-- ground truth before touching anything else.
-- (a) Did the build run? part count vs 16714 (user-edited) / 16820 (source).
-- (b) Is the aux lane back? user's edited workspace had 30 aux parts,
-- source produces 52 -- so they deleted ~22.
-- (c) Did the smoothing pass actually land on the east slip?
local out = {}
local function say(s) table.insert(out, s) end
local map = workspace:FindFirstChild("Map")
if not map then return "workspace.Map MISSING" end
local parts = 0
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then parts += 1 end
end
say(("parts=%d (16714=user-edited, 16820=source rebuild)"):format(parts))
-- Aux lane inventory, with index range + position so I can tell WHICH lane.
local aux = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local k = d.Name:match("^AuxLane_(%d+)$")
if k then table.insert(aux, { i = tonumber(k), p = d.Position, inst = d }) end
end
end
table.sort(aux, function(a, b) return a.i < b.i end)
say(("AuxLane parts=%d (30=after user deletion, 52=source)"):format(#aux))
if #aux > 0 then
-- group into contiguous runs so each physical lane shows up separately
local runs, cur = {}, nil
for _, a in ipairs(aux) do
if cur and a.i - cur.last <= 2 then cur.last = a.i; cur.n += 1
else
if cur then table.insert(runs, cur) end
cur = { first = a.i, last = a.i, n = 1, p = a.p }
end
end
if cur then table.insert(runs, cur) end
for _, r in ipairs(runs) do
say((" aux run idx %d..%d n=%d at (%.0f,%.0f,%.0f)"):format(
r.first, r.last, r.n, r.p.X, r.p.Y, r.p.Z))
end
end
-- Slip smoothness: did the relaxation take effect?
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
local segs = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
end
end
table.sort(segs, function(a, b) return a.i < b.i end)
if #segs == 0 then say(s .. ": ABSENT") continue end
local total, worst, worstAt, over = 0, 0, -1, 0
for j = 2, #segs do
local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector
local d = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
total += d
if d > worst then worst = d; worstAt = segs[j].i end
if d > 5 then over += 1 end
end
local topY, level = segs[1].p.Y, 0
for _, sg in ipairs(segs) do
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
end
say(("%s: segs=%d total=%.1fdeg worst=%.1f@%d over5=%d level=%d"):format(
s, #segs, total, worst, worstAt, over, level))
end
return table.concat(out, "\n")
+55
View File
@@ -0,0 +1,55 @@
local HttpService = game:GetService("HttpService")
local RS = game:GetService("ReplicatedStorage")
local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit"))
local map = workspace:FindFirstChild("Map")
if not map then return "no Map" end
-- Aggregate PA parts EXCLUDING the slips we just built, so we can see the lot.
local agg = { xMin = math.huge, xMax = -math.huge, zMin = math.huge, zMax = -math.huge }
local names = {}
local biggest, biggestArea = nil, 0
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and d.Name:sub(1, 2) == "PA" and not d.Name:find("Slip") then
names[d.Name] = (names[d.Name] or 0) + 1
agg.xMin = math.min(agg.xMin, d.Position.X - d.Size.X / 2)
agg.xMax = math.max(agg.xMax, d.Position.X + d.Size.X / 2)
agg.zMin = math.min(agg.zMin, d.Position.Z - d.Size.Z / 2)
agg.zMax = math.max(agg.zMax, d.Position.Z + d.Size.Z / 2)
local area = d.Size.X * d.Size.Z
if area > biggestArea then
biggestArea = area
biggest = ("%s size=(%d,%d,%d) pos=(%d,%d,%d) yTop=%.1f"):format(
d.Name, d.Size.X, d.Size.Y, d.Size.Z,
d.Position.X, d.Position.Y, d.Position.Z,
d.Position.Y + d.Size.Y / 2)
end
end
end
local nameList = {}
for n, c in pairs(names) do
table.insert(nameList, n .. "x" .. c)
end
table.sort(nameList)
-- cap the list so the payload can't truncate
local head = {}
for i = 1, math.min(#nameList, 40) do head[i] = nameList[i] end
local s = Circuit.samples
local function probe(i)
local a, b = s[i], s[i + 1]
local d = (b.pos - a.pos)
local fwd = Vector3.new(d.X, 0, d.Z).Unit
local right = fwd:Cross(Vector3.yAxis)
return ("s%d pos=(%d,%d) fwd=(%.2f,%.2f) right=(%.2f,%.2f)"):format(
i, a.pos.X, a.pos.Z, fwd.X, fwd.Z, right.X, right.Z)
end
return HttpService:JSONEncode({
pa_nonslip_bounds = { x = { math.floor(agg.xMin), math.floor(agg.xMax) }, z = { math.floor(agg.zMin), math.floor(agg.zMax) } },
biggest_pa_part = biggest,
distinct_pa_names = #nameList,
sample_names = head,
pa_center = tostring(Circuit.PA_CENTER),
joins = { probe(525), probe(537), probe(551), probe(539) },
})
+134
View File
@@ -0,0 +1,134 @@
-- Two questions, measured rather than eyeballed:
-- (1) WHERE exactly is the mainline missing barriers? Walk the deck edge and
-- report contiguous runs with no Barrier/EdgeRail within reach.
-- (User deleted ~106 parts by hand, so gaps are wherever they cut.)
-- (2) HOW does the south on-ramp differ from the north off-ramp? Compare the
-- two slips feature-by-feature so "make it look like the other" is a
-- concrete list of deltas, not a guess.
local out = {}
local function say(s) table.insert(out, s) end
local map = workspace:FindFirstChild("Map")
if not map then return "Map MISSING" end
-- Collect barrier-ish parts into a coarse spatial hash for fast lookup.
local CELL = 60
local grid = {}
local function key(x, z) return math.floor(x / CELL) .. ":" .. math.floor(z / CELL) end
local barrierCount = 0
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail") then
barrierCount += 1
local k = key(d.Position.X, d.Position.Z)
grid[k] = grid[k] or {}
table.insert(grid[k], d)
end
end
say(("indexed %d Barrier/EdgeRail parts"):format(barrierCount))
local function nearestBarrier(p)
local best = math.huge
local cx, cz = math.floor(p.X / CELL), math.floor(p.Z / CELL)
for dx = -1, 1 do
for dz = -1, 1 do
local c = grid[(cx + dx) .. ":" .. (cz + dz)]
if c then
for _, b in ipairs(c) do
local d2 = (Vector3.new(b.Position.X, 0, b.Position.Z) - Vector3.new(p.X, 0, p.Z)).Magnitude
-- only count barriers at roughly this height
if math.abs(b.Position.Y - p.Y) < 14 and d2 < best then best = d2 end
end
end
end
end
return best
end
-- Walk the mainline deck parts to build an ordered centreline.
local deck = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
or d.Name:match("^RoadDeck_(%d+)$")
if k then table.insert(deck, { i = tonumber(k), p = d.Position, cf = d.CFrame, sz = d.Size }) end
end
end
table.sort(deck, function(a, b) return a.i < b.i end)
say(("mainline deck parts found: %d"):format(#deck))
if #deck > 0 then
local UP = Vector3.new(0, 1, 0)
local runs = {}
local cur = nil
for j, s in ipairs(deck) do
local fwd = s.cf.LookVector
local right = fwd:Cross(UP).Unit
local half = s.sz.X / 2
-- probe just outboard of each edge
local gapL = nearestBarrier(s.p - right * (half + 2)) > 12
local gapR = nearestBarrier(s.p + right * (half + 2)) > 12
local bad = gapL or gapR
if bad then
local side = (gapL and gapR) and "BOTH" or (gapL and "L" or "R")
if cur and cur.side == side and s.i - cur.last <= 2 then
cur.last = s.i; cur.n += 1
else
if cur then table.insert(runs, cur) end
cur = { first = s.i, last = s.i, n = 1, side = side, pos = s.p }
end
else
if cur then table.insert(runs, cur); cur = nil end
end
end
if cur then table.insert(runs, cur) end
table.sort(runs, function(a, b) return a.n > b.n end)
say(("UNBARRIERED RUNS: %d total"):format(#runs))
for j = 1, math.min(#runs, 14) do
local r = runs[j]
say((" samples %d..%d (%d) side=%s at (%.0f,%.0f,%.0f)"):format(
r.first, r.last, r.n, r.side, r.pos.X, r.pos.Y, r.pos.Z))
end
end
-- (2) Compare the two slips.
say("--- SLIP COMPARISON ---")
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
local segs, walls, lines, other = {}, 0, 0, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and d.Name:match("^" .. s) then
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
if k then
table.insert(segs, { i = tonumber(k), p = d.Position, sz = d.Size, cf = d.CFrame })
elseif d.Name:match("_Wall_") then walls += 1
elseif d.Name:match("[Ll]ine") then lines += 1
else
local nm = d.Name:gsub("^" .. s .. "_", ""):gsub("_%d+$", "")
other[nm] = (other[nm] or 0) + 1
end
end
end
table.sort(segs, function(a, b) return a.i < b.i end)
local ol = {}
for k, v in pairs(other) do table.insert(ol, k .. "=" .. v) end
table.sort(ol)
if #segs > 0 then
-- heading of first and last segment: which way does it run?
local h1 = segs[1].cf.LookVector
local hN = segs[#segs].cf.LookVector
-- curvature: total heading change
local turn = 0
for j = 2, #segs do
local a = segs[j - 1].cf.LookVector
local b = segs[j].cf.LookVector
turn += math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
end
say(("%s: segs=%d walls=%d lines=%d startY=%.1f endY=%.1f"):format(s, #segs, walls, lines, segs[1].p.Y, segs[#segs].p.Y))
say((" heading first=(%.2f,%.2f) last=(%.2f,%.2f) totalTurn=%.1fdeg"):format(h1.X, h1.Z, hN.X, hN.Z, turn))
say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format(
segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z))
say(" other parts: " .. (#ol > 0 and table.concat(ol, " ") or "none"))
end
end
return table.concat(out, "\n")
+55
View File
@@ -0,0 +1,55 @@
-- The require sweep passing only proves the modules LOAD. What runs on Play is
-- MapBuilder actually building. Execute it for real and capture the error.
local SS = game:GetService("ServerScriptService")
local out = {}
local function say(s) table.insert(out, s) end
local serverScript = SS:FindFirstChild("Server")
local mb
for _, d in ipairs(SS:GetDescendants()) do
if d:IsA("ModuleScript") and d.Name == "MapBuilder" then mb = d end
end
say("MapBuilder module: " .. (mb and mb:GetFullName() or "NOT FOUND"))
if not mb then return table.concat(out, "\n") end
local ok, mod = pcall(require, mb)
say("require: " .. (ok and "ok" or ("FAIL " .. tostring(mod))))
if not ok then return table.concat(out, "\n") end
say("exports: " .. type(mod))
if type(mod) == "table" then
local keys = {}
for k, v in pairs(mod) do table.insert(keys, ("%s(%s)"):format(tostring(k), type(v))) end
table.sort(keys)
say(" " .. table.concat(keys, ", "))
end
-- Clear any existing map so we build from scratch, exactly like a fresh run.
local existing = workspace:FindFirstChild("Map")
say("existing Map in workspace: " .. (existing and "yes" or "no"))
local buildFn = type(mod) == "table" and (mod.Build or mod.build or mod.BuildMap)
if type(buildFn) ~= "function" then
say("NO BUILD FUNCTION EXPORTED -- cannot test build path")
return table.concat(out, "\n")
end
if existing then existing:Destroy() end
local t0 = os.clock()
local bok, berr = pcall(buildFn)
local dt = os.clock() - t0
say(("build pcall: %s (%.2fs)"):format(bok and "OK" or "FAIL", dt))
if not bok then
say("BUILD ERROR: " .. tostring(berr):sub(1, 600))
return table.concat(out, "\n")
end
local m = workspace:FindFirstChild("Map")
say("Map after build: " .. (m and "present" or "MISSING"))
if m then
local n = #m:GetDescendants()
say("descendants: " .. n)
if n == 0 then say("CONTROL FAIL: map built but empty") end
end
return table.concat(out, "\n")
+108
View File
@@ -0,0 +1,108 @@
-- Building-clip probe, CORRECTLY SCOPED.
--
-- The previous version tested only PAEntryRamp*/PAExitRamp* against buildings
-- and reported 0/1200 -- but its control ALSO returned 0, which makes the
-- result vacuous. Worse, the part visibly speared by a tower is a Road_*
-- segment, i.e. it was sitting in the control group, not the test group.
--
-- This version:
-- * tests EVERY drivable part (Road_*, ramps, PA surfaces) -- 21k parts
-- * proves the metric can detect a hit BEFORE trusting a zero (positive
-- control: a synthetic part deliberately placed inside a building)
-- * reports the worst offenders with coordinates so they can be shot
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local BUILDING = {
Building = true,
TokyoTower = true,
TowerDeck = true,
TowerAntenna = true,
ShiodomeTower = true,
TowerCrown = true,
DietTower = true,
RoppongiHills = true,
TokyoStation = true,
StationDome = true,
Skytree = true,
GinzaBlock = true,
NationalDiet = true,
BridgeTower = true,
TowerCrossbeam = true,
PalaceWall = true,
}
local function isDrivable(n)
return n:match("^Road_")
or n:find("Ramp")
or n == "PAFloor"
or n:match("^Apron")
or n:match("^Slip")
or n:match("^Merge")
end
local drive, bld = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
if BUILDING[d.Name] then
table.insert(bld, d)
elseif isDrivable(d.Name) then
table.insert(drive, d)
end
end
end
if #bld == 0 or #drive == 0 then
return ("SCOPE FAIL drive=%d bld=%d"):format(#drive, #bld)
end
-- Build an OverlapParams limited to building parts only.
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Include
op.FilterDescendantsInstances = bld
op.MaxParts = 12
-- POSITIVE CONTROL: put a probe cube dead-centre inside a building and
-- confirm the metric reports it. If this returns 0, the metric is broken and
-- every other number below is meaningless.
local target = bld[math.floor(#bld / 2)]
local ctrlHits = ws:GetPartBoundsInBox(CFrame.new(target.Position), Vector3.new(4, 4, 4), op)
local controlOK = #ctrlHits > 0
local hits, samples = 0, {}
for _, p in ipairs(drive) do
-- shrink slightly so parts merely TOUCHING a facade aren't flagged;
-- we want genuine interpenetration
local probe = p.Size - Vector3.new(1.5, 1.5, 1.5)
if probe.X > 0.5 and probe.Y > 0.5 and probe.Z > 0.5 then
local h = ws:GetPartBoundsInBox(p.CFrame, probe, op)
if #h > 0 then
hits += 1
if #samples < 6 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f in %s"):format(
p.Name,
p.Position.X,
p.Position.Y,
p.Position.Z,
h[1].Name
)
)
end
end
end
end
return ("CONTROL=%s (%d hits on a part inside a building) || drivable=%d bld=%d CLIPPING=%d [%s]"):format(
controlOK and "PASS" or "FAIL-METRIC-BROKEN",
#ctrlHits,
#drive,
#bld,
hits,
table.concat(samples, " ; ")
)
+146
View File
@@ -0,0 +1,146 @@
-- CLIP PROBE v3 -- INVERTED SCOPE.
--
-- v2 tested drivable parts against a HARDCODED WHITELIST of 16 building
-- names (Building, TokyoTower, GinzaBlock, ...). It reported CLIPPING=0 with
-- a passing control, and I reported that as fact. The user can see ramps
-- going through buildings, so the zero was false.
--
-- The bug is the whitelist: any obstacle whose name isn't one of those 16 is
-- invisible to the test. The PA structures (shops, kiosks, canopies) are the
-- prime suspect -- they sit exactly where the ramp ends land.
--
-- v3 inverts the scope: an obstacle is ANY part that is not drivable and not
-- infrastructure (pier/rail/sign/ground). No name list to go stale.
-- It also reports offenders GROUPED BY OBSTACLE NAME, so the answer names
-- the thing being speared instead of just counting.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local function isDrivable(n)
return n:match("^Road_")
or n:find("Ramp")
or n == "PAFloor"
or n:match("^Apron")
or n:match("^Slip")
or n:match("^Merge")
end
-- things that are ALLOWED to intersect a road (structure, decor, markings)
local function isInfra(n)
return n:find("Pier")
or n:find("Pillar")
or n:find("Column")
or n:find("Support")
or n:find("Beam")
or n:find("Rail")
or n:find("Guard")
or n:find("Barrier")
or n:find("Sign")
or n:find("Light")
or n:find("Lamp")
or n:find("Line")
or n:find("Mark")
or n:find("Stripe")
or n:find("Deck")
or n:find("Water")
or n == "Ground"
or n:find("Grounds")
or n:find("Median")
or n:find("Curb")
or n:find("Wall")
end
local drive, obst = {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local n = d.Name
if isDrivable(n) then
table.insert(drive, d)
elseif not isInfra(n) then
table.insert(obst, d)
end
end
end
if #obst == 0 or #drive == 0 then
return ("SCOPE FAIL drive=%d obst=%d"):format(#drive, #obst)
end
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Include
op.FilterDescendantsInstances = obst
op.MaxParts = 12
-- POSITIVE CONTROL: probe dead-centre of a known obstacle must report a hit.
local target = obst[math.floor(#obst / 2)]
local ctrl = ws:GetPartBoundsInBox(CFrame.new(target.Position), Vector3.new(4, 4, 4), op)
local controlOK = #ctrl > 0
-- SECOND CONTROL: a box far out in empty sky must report ZERO. Guards the
-- opposite failure -- a filter so broad everything "hits".
local negCtrl = ws:GetPartBoundsInBox(CFrame.new(0, 40000, 0), Vector3.new(8, 8, 8), op)
local negOK = #negCtrl == 0
local hits = 0
local byObst, byRamp = {}, {}
local samples = {}
for _, p in ipairs(drive) do
local probe = p.Size - Vector3.new(1.5, 1.5, 1.5)
if probe.X > 0.5 and probe.Y > 0.5 and probe.Z > 0.5 then
local h = ws:GetPartBoundsInBox(p.CFrame, probe, op)
if #h > 0 then
hits += 1
local on = h[1].Name
byObst[on] = (byObst[on] or 0) + 1
local rn = p.Name:gsub("_%d+$", "")
byRamp[rn] = (byRamp[rn] or 0) + 1
if #samples < 10 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f IN %s"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z, on)
)
end
end
end
end
local out = {}
table.insert(
out,
("CONTROL+=%s(%d) CONTROL-=%s(%d) -> probe %s"):format(
controlOK and "PASS" or "FAIL",
#ctrl,
negOK and "PASS" or "FAIL",
#negCtrl,
(controlOK and negOK) and "TRUSTWORTHY" or "BROKEN-IGNORE-RESULTS"
)
)
table.insert(out, ("drivable=%d obstacles=%d CLIPPING=%d"):format(#drive, #obst, hits))
local function dump(t, label)
local l = {}
for k, v in pairs(t) do
table.insert(l, ("%s=%d"):format(k, v))
end
table.sort(l, function(a, b)
return a > b
end)
local keep = {}
for i = 1, math.min(#l, 12) do
table.insert(keep, l[i])
end
table.insert(out, label .. ": " .. table.concat(keep, " "))
end
if hits > 0 then
dump(byObst, "speared obstacles")
dump(byRamp, "offending road groups")
for _, s in ipairs(samples) do
table.insert(out, " " .. s)
end
end
return table.concat(out, "\n")
+125
View File
@@ -0,0 +1,125 @@
-- CLIP PROBE v4 -- FULL-WORKSPACE SCOPE. This is the one that finds the
-- defect the user can see.
--
-- WHY v2 AND v3 BOTH RETURNED A FALSE ZERO:
-- both scoped obstacles to the `Map` folder. The PA buildings are NOT in
-- Map -- they are Creator-Store models parented directly to Workspace as
-- siblings: McDonald's (1666 parts), Gas Station 1 (1463), 7 eleven (627),
-- Restaurant1, Updated Ramen Shop, Fast Food Restaurant, すき家, Parking lot.
-- The probes literally could not see the things being speared. A passing
-- positive control did NOT save me: the control proved the metric works,
-- not that the SCOPE covers the real obstacles.
--
-- v4 collects obstacles from the ENTIRE workspace, excluding only the Map
-- folder's own drivable/infra parts. Reports per-model hit counts so we know
-- exactly which shop each ramp is driving through.
local ws = game:GetService("Workspace")
local map = ws:FindFirstChild("Map")
if not map then
return "NO MAP"
end
local function isDrivable(n)
return n:match("^Road_")
or n:find("Ramp")
or n == "PAFloor"
or n:match("^Apron")
or n:match("^Slip")
or n:match("^Merge")
end
-- collect drivable parts (ramps especially) from Map
local drive = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and isDrivable(d.Name) then
table.insert(drive, d)
end
end
-- collect obstacles: every BasePart in workspace that is NOT inside Map.
-- These are the imported building models.
local obst = {}
local modelOf = {}
for _, top in ipairs(ws:GetChildren()) do
if top ~= map and not top:IsA("Terrain") and not top:IsA("Camera") then
if top:IsA("BasePart") then
-- loose parts like stray PAPerimeter/PARampKerb: skip, they're infra
if not isDrivable(top.Name) and not top.Name:find("Perimeter") and not top.Name:find("Kerb") then
table.insert(obst, top)
modelOf[top] = top.Name
end
else
for _, d in ipairs(top:GetDescendants()) do
if d:IsA("BasePart") then
table.insert(obst, d)
modelOf[d] = top.Name
end
end
end
end
end
if #obst == 0 then
return "SCOPE FAIL: no obstacles found outside Map"
end
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Include
op.FilterDescendantsInstances = obst
op.MaxParts = 20
-- CONTROL+ : a box at a known obstacle's centre must report a hit
local tgt = obst[math.floor(#obst / 2)]
local cPos = ws:GetPartBoundsInBox(CFrame.new(tgt.Position), Vector3.new(4, 4, 4), op)
-- CONTROL- : empty sky must report zero
local cNeg = ws:GetPartBoundsInBox(CFrame.new(0, 40000, 0), Vector3.new(8, 8, 8), op)
local trustworthy = #cPos > 0 and #cNeg == 0
local hits = 0
local byModel, byRamp = {}, {}
local samples = {}
for _, p in ipairs(drive) do
local probe = p.Size - Vector3.new(1.0, 1.0, 1.0)
if probe.X > 0.5 and probe.Y > 0.5 and probe.Z > 0.5 then
local h = ws:GetPartBoundsInBox(p.CFrame, probe, op)
if #h > 0 then
hits += 1
local mn = modelOf[h[1]] or h[1].Name
byModel[mn] = (byModel[mn] or 0) + 1
local rn = p.Name:gsub("_%d+$", "")
byRamp[rn] = (byRamp[rn] or 0) + 1
if #samples < 10 then
table.insert(
samples,
("%s@%.0f,%.0f,%.0f IN %s"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z, mn)
)
end
end
end
end
local out = {}
table.insert(
out,
("CONTROL+=%d CONTROL-=%d -> %s"):format(#cPos, #cNeg, trustworthy and "TRUSTWORTHY" or "BROKEN-IGNORE")
)
table.insert(out, ("drivable=%d obstacles(outside Map)=%d CLIPPING=%d"):format(#drive, #obst, hits))
local function dump(t, label)
local l = {}
for k, v in pairs(t) do
table.insert(l, ("%s=%d"):format(k, v))
end
table.sort(l)
table.insert(out, label .. ": " .. table.concat(l, " | "))
end
if hits > 0 then
dump(byModel, "MODELS SPEARED")
dump(byRamp, "offending ramp groups")
for _, s in ipairs(samples) do
table.insert(out, " " .. s)
end
end
return table.concat(out, "\n")
+106
View File
@@ -0,0 +1,106 @@
-- Re-verify after un-pinning index 2, and settle the 4 "bare" samples:
-- 525L/526L/549L/550L sit at the ramp MOUTHS (west join=525, east join=551).
-- An open parapet at a mouth is correct -- that is the doorway cars drive
-- through, and the ramp deck's own walls take over. So check whether ramp deck
-- + ramp wall actually cover those samples. If yes: not a hole. If no: a hole.
local SS = game:GetService("ServerScriptService")
local out = {}
local function say(s) table.insert(out, s) end
local mod = SS:FindFirstChild("MapBuilder", true)
if not mod then return "MapBuilder NOT FOUND" end
local fresh = mod:Clone()
fresh.Name = "MB_fresh_" .. tostring(math.random(1e6))
fresh.Parent = SS
local ok, MB = pcall(require, fresh)
if not ok then fresh:Destroy() return "require FAILED: " .. tostring(MB) end
local ok2, err = pcall(function() MB.build() end)
fresh:Destroy()
if not ok2 then return "build ERROR: " .. tostring(err) end
say("build OK")
local map = workspace:FindFirstChild("Map")
local parts = 0
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then parts += 1 end
end
say(("parts=%d"):format(parts))
local deck, bars, slipAll = {}, {}, {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
if k then deck[tonumber(k)] = d end
if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end
if d.Name:match("^PA[EW][ae]stSlip") then table.insert(slipAll, d) end
end
end
local UP = Vector3.new(0, 1, 0)
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
local function nearest(list, p, ytol)
local best = math.huge
for _, b in ipairs(list) do
if math.abs(b.Position.Y - p.Y) < (ytol or 14) then
local dd = (flat(b.Position) - flat(p)).Magnitude
if dd < best then best = dd end
end
end
return best
end
say("--- edge audit 520..555 (L side) ---")
local realHoles = {}
for i = 520, 555 do
local d = deck[i]
if d then
local right = d.CFrame.LookVector:Cross(UP).Unit
local edge = d.Position - right * (d.Size.X / 2 + 2)
local bd = nearest(bars, edge)
local sd = nearest(slipAll, edge, 20)
local covered = bd < 12 or sd < 30
if not covered then table.insert(realHoles, i) end
if bd >= 12 then
say((" %d: barrier=%.1f slipStruct=%.1f -> %s"):format(
i, bd, sd, covered and "OK (ramp mouth)" or "HOLE"))
end
end
end
say(("REAL holes: %d %s"):format(#realHoles,
#realHoles > 0 and table.concat(realHoles, ",") or "(none)"))
say("--- smoothness ---")
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
local segs = {}
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") then
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
end
end
table.sort(segs, function(a, b) return a.i < b.i end)
local total, worst, worstAt, over = 0, 0, -1, 0
for j = 2, #segs do
local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector
local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
total += dth
if dth > worst then worst = dth; worstAt = segs[j].i end
if dth > 5 then over += 1 end
end
local topY, level = segs[1].p.Y, 0
for _, sg in ipairs(segs) do
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
end
say(("%s: total=%.1fdeg worst=%.1f@%d over5=%d level=%d"):format(
s, total, worst, worstAt, over, level))
say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format(
segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z))
end
local aux = 0
for _, d in ipairs(map:GetDescendants()) do
if d:IsA("BasePart") and d.Name:match("^AuxLane_%d+$") then aux += 1 end
end
say(("AuxLane total=%d (want 30)"):format(aux))
return table.concat(out, "\n")

Some files were not shown because too many files have changed in this diff Show More