Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e50f9718d | ||
|
|
c297b20aed | ||
|
|
1d23c187ad | ||
|
|
badba0f7a4 | ||
|
|
6c753505f0 | ||
|
|
f4f95ec9cc | ||
|
|
4a702a4fdb | ||
|
|
16cba4ac32 | ||
|
|
3a74e15f6e | ||
|
|
fed0c0ed20 | ||
|
|
e997f0f73d | ||
|
|
1e41de92fd | ||
|
|
6b0e3b75e4 | ||
|
|
c8dabea53a | ||
|
|
8808b919c3 | ||
|
|
7d368d5444 | ||
|
|
967385261d | ||
|
|
85730f0038 | ||
|
|
37a83e0e8f |
@@ -15,3 +15,4 @@ wally.lock
|
||||
.DS_Store
|
||||
*.log
|
||||
audio-work/
|
||||
tools/shots/
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 303 KiB |
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
rojo = "rojo-rbx/rojo@7.5.1"
|
||||
rojo = "rojo-rbx/rojo@7.7.0"
|
||||
wally = "UpliftGames/[email protected]"
|
||||
selene = "Kampfkarren/[email protected]"
|
||||
stylua = "JohnnyMorganz/[email protected]"
|
||||
|
||||
@@ -163,11 +163,7 @@ function ChassisController.init()
|
||||
-- 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)
|
||||
)
|
||||
and (drivingSeat ~= seatPart or seatPart == nil or not seatPart:IsDescendantOf(workspace))
|
||||
if seated and (not heartbeatConn or stale) then
|
||||
stopDriving()
|
||||
startDriving(seatPart :: VehicleSeat)
|
||||
|
||||
@@ -51,7 +51,10 @@ 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).
|
||||
local SHELL_DROP = 0.6
|
||||
-- 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
|
||||
@@ -63,6 +66,13 @@ local SHELL_DROP = 0.6
|
||||
-- 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()
|
||||
@@ -73,6 +83,7 @@ local function ensureCollisionGroups()
|
||||
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.
|
||||
@@ -80,6 +91,23 @@ local function ensureCollisionGroups()
|
||||
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 ---------------------------------------------------------------------
|
||||
@@ -127,7 +155,21 @@ end
|
||||
-- Build one corner of the suspension. `isFront` corners get a steering servo.
|
||||
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
|
||||
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
|
||||
local isFront = zSign < 0 -- car forward is -Z
|
||||
-- FORWARD CONVENTION: car forward is body-local -Z. This is the Roblox
|
||||
-- standard -- CFrame.LookVector IS the -Z basis vector.
|
||||
--
|
||||
-- DO NOT "fix" this to `zSign > 0`. That was tried on 2026-08-02 and made
|
||||
-- things strictly worse: 2 of 5 cars were backwards before, all 5 after.
|
||||
-- It was justified by tools/diag_seatdir.luau reporting "throttle drives
|
||||
-- toward local +Z", which was a SIGN ERROR in the diagnostic, not a fact
|
||||
-- about the game: expressing a part's own LookVector in its own local
|
||||
-- frame is (0,0,-1) BY DEFINITION, so the script mixed world and local
|
||||
-- space and then "confirmed" the inversion it was looking for.
|
||||
--
|
||||
-- Lesson: a measurement that merely restates a definition cannot be
|
||||
-- evidence. Before trusting a diagnostic, ask what result would DISPROVE
|
||||
-- the hypothesis -- if no possible output would, the check is worthless.
|
||||
local isFront = zSign < 0
|
||||
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
|
||||
|
||||
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
|
||||
@@ -161,13 +203,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
||||
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
|
||||
local wheelParent: Part = hub
|
||||
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({
|
||||
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,
|
||||
CanCollide = false,
|
||||
Parent = model,
|
||||
})
|
||||
knuckle.Transparency = 1
|
||||
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
|
||||
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
|
||||
|
||||
@@ -188,6 +237,15 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
||||
end
|
||||
|
||||
-- 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({
|
||||
Name = "Wheel_" .. cornerName,
|
||||
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
|
||||
@@ -203,6 +261,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
||||
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
|
||||
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
|
||||
|
||||
-- STEERING/MOTOR CONFLICT -- UNRESOLVED, see docs note below.
|
||||
--
|
||||
-- A steered front corner is two hinges in series: Hub --SteerServo(Y)-->
|
||||
-- Knuckle --WheelMotor(X)--> Wheel. Measured (tools/diag_steer4.luau,
|
||||
-- gtr_r34, target -20 deg): the servo tracks to within 0.01 deg ONLY when
|
||||
-- the front wheel is detached or its hinge disabled; with the wheel
|
||||
-- attached it stays pinned near 0 deg even hoisted in free air with
|
||||
-- ServoMaxTorque up to 1e9. So it is NOT grip and NOT a torque contest.
|
||||
--
|
||||
-- Do NOT "fix" this by skipping the front motor: the motor hinge is the
|
||||
-- only joint attaching Wheel_F* to Knuckle_F*, so omitting it leaves the
|
||||
-- front wheels physically detached (verified, tools/diag_frontjoint.luau)
|
||||
-- -- the servo then turns freely because it carries no load, and the car
|
||||
-- generates no front lateral force at all.
|
||||
local motor = Instance.new("HingeConstraint")
|
||||
motor.Name = Chassis.WHEEL_MOTOR_NAME
|
||||
motor.Attachment0 = parentAtt
|
||||
@@ -260,10 +332,13 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
||||
seat.HeadsUpDisplay = false
|
||||
seat.CanCollide = false
|
||||
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
|
||||
-- in the cabin, high enough that the avatar's legs never interpenetrate
|
||||
-- the slab (which causes physics launches).
|
||||
seat.CFrame = origin * CFrame.new(0, Chassis.BODY_SIZE.Y / 2 + 0.3, 0.2)
|
||||
-- Seat pan on the CABIN FLOOR, measured up from the ground plane the wheels
|
||||
-- rest on -- NOT up from the body slab. The slab sits at the car's vertical
|
||||
-- middle, so the old "slab top + 0.3" placement put the driver's hips at the
|
||||
-- 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
|
||||
local seatWeld = Instance.new("WeldConstraint")
|
||||
seatWeld.Part0 = body
|
||||
@@ -288,10 +363,219 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
||||
if shellTemplate then
|
||||
local shell = shellTemplate:Clone()
|
||||
shell.Name = "Shell"
|
||||
-- Seat the shell on the chassis: drop it so the mesh's underside sits at
|
||||
-- roughly the body plane rather than floating at the body's centreline.
|
||||
local ext = shell:GetExtentsSize()
|
||||
shell:PivotTo(body.CFrame * CFrame.new(0, ext.Y / 2 - Chassis.BODY_SIZE.Y / 2 - SHELL_DROP, 0))
|
||||
-- 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
|
||||
@@ -311,6 +595,110 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
||||
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()
|
||||
@@ -445,8 +833,22 @@ function CarManager.spawn(player: Player, carId: string?)
|
||||
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.wait(SETTLE_TIME) -- let Sit() + the solver settle
|
||||
if not primary:IsDescendantOf(Workspace) then
|
||||
@@ -581,8 +983,11 @@ function CarManager.init()
|
||||
end
|
||||
if gone or fell then
|
||||
warn(
|
||||
("[CarManager] recovering car for %s (destroyed=%s fell=%s)")
|
||||
:format(player.Name, tostring(gone), tostring(fell))
|
||||
("[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
|
||||
@@ -634,4 +1039,9 @@ function CarManager.getRoot(player: Player): BasePart?
|
||||
return model.PrimaryPart
|
||||
end
|
||||
|
||||
-- Test seam: build a bare chassis with no player, seat or network ownership.
|
||||
-- Used by tools/diag_steering.luau to measure handling headlessly. Not part of
|
||||
-- the game path -- CarManager.spawn is the only way a player ever gets a car.
|
||||
CarManager._buildForDiag = buildCarModel
|
||||
|
||||
return CarManager
|
||||
|
||||
@@ -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
|
||||
|
||||
-- Geometry (studs) ------------------------------------------------------------
|
||||
Chassis.BODY_SIZE = Vector3.new(6, 1.4, 12)
|
||||
Chassis.WHEEL_RADIUS = 1.4
|
||||
-- Sized to REAL imported car meshes, not the other way around. The donor models
|
||||
-- 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.TRACK_HALF = 3.35 -- lateral distance body centre -> wheel centre
|
||||
Chassis.WHEELBASE_HALF = 4.2 -- longitudinal distance body centre -> axle
|
||||
Chassis.SUSPENSION_DROP = 1.6 -- rest distance from body centre down to wheel centre
|
||||
Chassis.TRACK_HALF = 3.55 -- lateral distance body centre -> wheel centre
|
||||
Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle
|
||||
Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre
|
||||
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 -------------------------------------------
|
||||
export type Tuning = {
|
||||
MaxSpeed: number, -- studs/sec target top speed
|
||||
@@ -71,6 +143,14 @@ export type Tuning = {
|
||||
-- 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
|
||||
|
||||
@@ -873,6 +873,78 @@ for _, r in ipairs(RAW) do
|
||||
bridge = (r[5] :: number) == 1,
|
||||
})
|
||||
end
|
||||
-- DAIKOKU PA UNDERPASS ELEVATION ----------------------------------------------
|
||||
-- Reference (Commons "Aerial view of Daikoku Junction", CC0, plus the
|
||||
-- ground-level PA shots): the parking apron is at GROUND level and the
|
||||
-- expressway flies OVER it on pillars. Cars park underneath the decks; the
|
||||
-- PA's circulation road passes beneath the mainline. The elevated roadway
|
||||
-- above a flat lot is the iconic look, and it is also what makes the PA
|
||||
-- drivable.
|
||||
--
|
||||
-- BUG this fixes (2026-08-02): the mainline and the PA were BOTH baked at
|
||||
-- y=46, and the PA ring road (radius 390) reaches 60 studs past the mainline
|
||||
-- centreline (PA centre is only 330 studs off it). Same height + overlapping
|
||||
-- footprint meant the ring's parapet stood in the middle of the carriageway --
|
||||
-- anyone driving the wangan past the PA was destroyed by a wall in the road.
|
||||
-- Vertical separation is what resolves it; the footprints are SUPPOSED to
|
||||
-- overlap, exactly as they do in the aerial.
|
||||
--
|
||||
-- Applied here as a smooth profile rather than by hand-editing 1050 baked
|
||||
-- heights. Everything downstream (deck, median, barrier, kill wall, lane
|
||||
-- paint, ramps) is built from sample.pos, so lifting the samples lifts the
|
||||
-- whole structure coherently.
|
||||
-- 2026-08-02: raised 38 -> 56. 38 studs (~10.6 m) was less than the height of
|
||||
-- the structures standing on the apron, so the mainline deck cut straight
|
||||
-- through them -- PAHonkan (13 m / 46.4 studs) surfaced through Road_536..539,
|
||||
-- which is the "building sitting on the road" artefact. Clearance must exceed
|
||||
-- the TALLEST thing under the deck, not just a car. Honkan top sits 46.4 studs
|
||||
-- above the apron, so the slab underside (46 + PA_LIFT - 1) needs PA_LIFT > 48.
|
||||
-- 56 gives underside 101 -- 7.6 studs of daylight over the roof. This is also
|
||||
-- closer to the real Bayshore Route viaduct, which clears the PA by ~15-20 m,
|
||||
-- not 10. Verify with tools/diag_clearance.luau after changing.
|
||||
Circuit.PA_LIFT = 56 -- studs of clearance over the apron (tallest = Honkan 46.4)
|
||||
local LIFT_FLAT = 20 -- samples held at full lift, centred on the PA
|
||||
-- Widened 18 -> 26 alongside the lift increase: the climb is spread over more
|
||||
-- samples so the grade stays where it was instead of getting 47% steeper.
|
||||
local LIFT_RAMP = 26 -- samples of smooth transition on each side
|
||||
do
|
||||
local c = Circuit.PA_CENTER
|
||||
local n = #samples
|
||||
-- Same "nearest non-tunnel sample" rule planPA uses, so the lift and the
|
||||
-- ramp junctions can never disagree about where the PA is.
|
||||
local bestIdx, bestD = 1, math.huge
|
||||
for i, s in ipairs(samples) do
|
||||
local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude
|
||||
if not s.tunnel and d < bestD then
|
||||
bestIdx, bestD = i, d
|
||||
end
|
||||
end
|
||||
Circuit.PA_NEAREST = bestIdx
|
||||
|
||||
local function circDist(i, j)
|
||||
local d = math.abs(i - j)
|
||||
return math.min(d, n - d)
|
||||
end
|
||||
|
||||
for i, s in ipairs(samples) do
|
||||
local d = circDist(i, bestIdx)
|
||||
local t
|
||||
if d <= LIFT_FLAT then
|
||||
t = 1
|
||||
elseif d >= LIFT_FLAT + LIFT_RAMP then
|
||||
t = 0
|
||||
else
|
||||
-- Smoothstep: zero slope at both ends, so the grade eases in and
|
||||
-- out instead of kinking the deck at the seam.
|
||||
local u = (d - LIFT_FLAT) / LIFT_RAMP
|
||||
t = 1 - (u * u * (3 - 2 * u))
|
||||
end
|
||||
if t > 0 then
|
||||
s.pos = Vector3.new(s.pos.X, s.pos.Y + Circuit.PA_LIFT * t, s.pos.Z)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Circuit.samples = samples
|
||||
|
||||
-- Cumulative arc length at each sample, and total lap length ------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 151 KiB |
@@ -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")
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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, ",")
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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) },
|
||||
})
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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, " ; ")
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,123 @@
|
||||
-- Studio caches required modules, which is why the previous rebuild silently
|
||||
-- ran the OLD code (east slip still measured 216.0deg, unchanged). Force a
|
||||
-- fresh load: clone the ModuleScript so require() sees a new instance and
|
||||
-- recompiles from current source.
|
||||
--
|
||||
-- Verifies three things at once:
|
||||
-- (1) smoothing landed -> east total heading change << 216deg, zero >5deg kinks
|
||||
-- (2) aux lane stays deleted -> AuxLane run 527..548 ABSENT, total ~30 not 52
|
||||
-- (3) barrier gap closed -> parapet present along 527..548
|
||||
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
|
||||
if not mod:IsA("ModuleScript") then return "MapBuilder is " .. mod.ClassName end
|
||||
|
||||
local fresh = mod:Clone()
|
||||
fresh.Name = "MapBuilder_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 t0 = os.clock()
|
||||
local ok2, err = pcall(function() MB.build() end)
|
||||
local dt = os.clock() - t0
|
||||
fresh:Destroy()
|
||||
if not ok2 then return "build ERROR: " .. tostring(err) end
|
||||
say(("build OK (%.2fs) via fresh module"):format(dt))
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "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"):format(parts))
|
||||
|
||||
-- (2) aux 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, tonumber(k)) end
|
||||
end
|
||||
end
|
||||
table.sort(aux)
|
||||
local inRange = 0
|
||||
for _, i in ipairs(aux) do if i >= 527 and i <= 548 then inRange += 1 end end
|
||||
say(("AuxLane total=%d inside 527..548 = %d (want 0)"):format(#aux, inRange))
|
||||
|
||||
-- (3) barrier coverage across the formerly-bare run
|
||||
local deck, bars = {}, {}
|
||||
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
|
||||
end
|
||||
end
|
||||
local UP = Vector3.new(0, 1, 0)
|
||||
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||
local bare = {}
|
||||
for i = 525, 550 do
|
||||
local d = deck[i]
|
||||
if d then
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
for _, sgn in ipairs({ -1, 1 }) do
|
||||
local edge = d.Position + right * sgn * (d.Size.X / 2 + 2)
|
||||
local best = math.huge
|
||||
for _, b in ipairs(bars) do
|
||||
if math.abs(b.Position.Y - edge.Y) < 14 then
|
||||
local dd = (flat(b.Position) - flat(edge)).Magnitude
|
||||
if dd < best then best = dd end
|
||||
end
|
||||
end
|
||||
if best > 12 then table.insert(bare, i .. (sgn < 0 and "L" or "R")) end
|
||||
end
|
||||
end
|
||||
end
|
||||
say(("bare edge samples in 525..550: %d %s"):format(#bare,
|
||||
#bare > 0 and ("-> " .. table.concat(bare, ",")) or "(want 0)"))
|
||||
|
||||
-- (1) 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)
|
||||
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 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
|
||||
local lmin, lmax = math.huge, -math.huge
|
||||
for j = 2, #segs do
|
||||
local L = (segs[j].p - segs[j - 1].p).Magnitude
|
||||
lmin = math.min(lmin, L); lmax = math.max(lmax, L)
|
||||
end
|
||||
say(("%s: total=%.1fdeg worst=%.1f@%d over5=%d level=%d spacing=%.1f..%.1f"):format(
|
||||
s, total, worst, worstAt, over, level, lmin, lmax))
|
||||
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
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Rebuild with AUX_SUPPRESS widened to 525..550, then verify BOTH marked
|
||||
-- defects are gone:
|
||||
-- (A) left oval -> zero samples with no left barrier
|
||||
-- (B) right circle -> no 26-stud lateral snap at the suppression boundary
|
||||
-- The 6.3-stud steps at 512/513/563 are the aux lane's own smooth taper
|
||||
-- (width 26 -> 19 -> 13 -> 7 -> 0) and are CORRECT; the parapet rides the
|
||||
-- taper. Only steps much larger than that are real juts.
|
||||
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 UP = Vector3.new(0, 1, 0)
|
||||
local deck, aux, bars = {}, {}, {}
|
||||
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
|
||||
local a = d.Name:match("^AuxLane_(%d+)$")
|
||||
if a then aux[tonumber(a)] = d end
|
||||
if d.Name == "Barrier" then table.insert(bars, d) end
|
||||
end
|
||||
end
|
||||
|
||||
say("sample | auxW | left barrier lat | step")
|
||||
local prevLat, jogs, gaps = nil, {}, {}
|
||||
for i = 505, 570 do
|
||||
local d = deck[i]
|
||||
if d then
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
local c = d.Position
|
||||
local best, bestLat = math.huge, nil
|
||||
for _, b in ipairs(bars) do
|
||||
if math.abs(b.Position.Y - c.Y) < 14 then
|
||||
local rel = b.Position - c
|
||||
local along = math.abs(rel:Dot(d.CFrame.LookVector))
|
||||
local lat = rel:Dot(right)
|
||||
if along < 22 and lat < 0 and along < best then best = along; bestLat = lat end
|
||||
end
|
||||
end
|
||||
local auxW = aux[i] and aux[i].Size.X or 0
|
||||
if bestLat then
|
||||
local step = prevLat and math.abs(math.abs(bestLat) - math.abs(prevLat)) or 0
|
||||
if step > 10 then table.insert(jogs, ("%d(%.1f)"):format(i, step)) end
|
||||
if step > 10 or i % 5 == 0 then
|
||||
say((" %d | aux=%.0f | lat=%.1f | step=%.1f%s"):format(
|
||||
i, auxW, bestLat, step, step > 10 and " <<< JOG" or ""))
|
||||
end
|
||||
prevLat = bestLat
|
||||
else
|
||||
table.insert(gaps, i)
|
||||
say((" %d | aux=%.0f | NO LEFT BARRIER <<< GAP"):format(i, auxW))
|
||||
prevLat = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
say(("BIG JOGS (>10): %d %s"):format(#jogs, #jogs > 0 and table.concat(jogs, " ") or "(none)"))
|
||||
say(("GAPS: %d %s"):format(#gaps, #gaps > 0 and table.concat(gaps, ",") or "(none)"))
|
||||
|
||||
-- full-loop unguarded sweep, same rule as before
|
||||
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||
local allBars = {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail") then
|
||||
table.insert(allBars, d)
|
||||
end
|
||||
end
|
||||
local slipParts = {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") and d.Name:match("Slip") then table.insert(slipParts, d) end
|
||||
end
|
||||
local holes, swept = {}, 0
|
||||
for i, d in pairs(deck) do
|
||||
swept += 1
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
for _, sgn in ipairs({ -1, 1 }) do
|
||||
local edge = d.Position + right * sgn * (d.Size.X / 2 + 2)
|
||||
local bd = math.huge
|
||||
for _, b in ipairs(allBars) do
|
||||
if math.abs(b.Position.Y - edge.Y) < 14 then
|
||||
local dd = (flat(b.Position) - flat(edge)).Magnitude
|
||||
if dd < bd then bd = dd end
|
||||
end
|
||||
end
|
||||
if bd >= 12 then
|
||||
local ok2b = false
|
||||
local a = aux[i]
|
||||
if a then
|
||||
local outer = a.Position + right * sgn * (a.Size.X / 2 + 2)
|
||||
local bd2 = math.huge
|
||||
for _, b in ipairs(allBars) do
|
||||
if math.abs(b.Position.Y - outer.Y) < 14 then
|
||||
local dd = (flat(b.Position) - flat(outer)).Magnitude
|
||||
if dd < bd2 then bd2 = dd end
|
||||
end
|
||||
end
|
||||
if bd2 < 14 then ok2b = true end
|
||||
end
|
||||
if not ok2b then
|
||||
local sd = math.huge
|
||||
for _, p in ipairs(slipParts) do
|
||||
local dd = (flat(p.Position) - flat(edge)).Magnitude
|
||||
if dd < sd then sd = dd end
|
||||
end
|
||||
if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
say(("FULL SWEEP: %d samples, unguarded=%d %s"):format(swept, #holes,
|
||||
#holes > 0 and table.concat(holes, ",") or "(none)"))
|
||||
|
||||
local auxN = 0
|
||||
for _ in pairs(aux) do auxN += 1 end
|
||||
say(("AuxLane total=%d (want 30)"):format(auxN))
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,139 @@
|
||||
-- FLOATING SCENERY PROBE.
|
||||
--
|
||||
-- The wide captures show grey slabs/towers hanging in the sky with nothing
|
||||
-- under them. No existing probe catches this: the clip probe only asks
|
||||
-- "does road intersect building", which is 0 and correct. Floating geometry
|
||||
-- is the opposite failure -- parts that touch NOTHING at all.
|
||||
--
|
||||
-- Metric: for each candidate scenery part, raycast straight DOWN from its
|
||||
-- underside and report the distance to the first thing hit. A part sitting
|
||||
-- on ground/deck returns ~0. A floating part returns a large number or nil
|
||||
-- (nothing beneath it at all).
|
||||
--
|
||||
-- CONTROL: the same ray cast from a part known to be seated (a Road part)
|
||||
-- must return a small distance. If the control comes back nil/huge the
|
||||
-- raycast itself is broken and every "floating" result is meaningless --
|
||||
-- the silent-zero trap that produced two phantom defects earlier.
|
||||
|
||||
local ws = game:GetService("Workspace")
|
||||
local map = ws:FindFirstChild("Map")
|
||||
if not map then
|
||||
return "NO MAP"
|
||||
end
|
||||
|
||||
local candidates, roads = {}, {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local nm = d.Name
|
||||
if nm:sub(1, 5) == "Road_" then
|
||||
table.insert(roads, d)
|
||||
elseif
|
||||
nm:find("Building")
|
||||
or nm:find("Tower")
|
||||
or nm:find("Skyline")
|
||||
or nm:find("Block")
|
||||
or nm:find("Slab")
|
||||
or nm:find("Distant")
|
||||
then
|
||||
table.insert(candidates, d)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local params = RaycastParams.new()
|
||||
params.FilterType = Enum.RaycastFilterType.Exclude
|
||||
|
||||
-- distance from a part's underside straight down to the first hit
|
||||
local function dropDist(p)
|
||||
params.FilterDescendantsInstances = { p }
|
||||
local origin = p.Position - Vector3.new(0, p.Size.Y / 2 + 1, 0)
|
||||
local res = ws:Raycast(origin, Vector3.new(0, -6000, 0), params)
|
||||
if not res then
|
||||
return nil
|
||||
end
|
||||
return (origin - res.Position).Magnitude
|
||||
end
|
||||
|
||||
local out = {}
|
||||
|
||||
-- CONTROL: a road part must find something close beneath it (deck/pier/ground).
|
||||
local ctl, ctlN = nil, "none"
|
||||
for _, rd in ipairs(roads) do
|
||||
local d = dropDist(rd)
|
||||
if d then
|
||||
ctl, ctlN = d, rd.Name
|
||||
break
|
||||
end
|
||||
end
|
||||
if ctl == nil then
|
||||
table.insert(out, "CONTROL=FAIL raycast found nothing under ANY road -- probe is broken, ignore results below")
|
||||
else
|
||||
table.insert(out, ("CONTROL=PASS %.1f studs under %s (raycast works)"):format(ctl, ctlN))
|
||||
end
|
||||
|
||||
-- Classify candidates.
|
||||
local FLOAT_MIN = 60 -- studs of empty air below = floating
|
||||
local floating, seated, voidBelow = 0, 0, 0
|
||||
local worst, worstNm, worstPos = -1, "none", nil
|
||||
local samples = {}
|
||||
for _, p in ipairs(candidates) do
|
||||
local d = dropDist(p)
|
||||
if d == nil then
|
||||
voidBelow += 1
|
||||
if #samples < 8 then
|
||||
table.insert(
|
||||
samples,
|
||||
("%s@%.0f,%.0f,%.0f NOTHING-BELOW"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z)
|
||||
)
|
||||
end
|
||||
elseif d > FLOAT_MIN then
|
||||
floating += 1
|
||||
if d > worst then
|
||||
worst, worstNm, worstPos = d, p.Name, p.Position
|
||||
end
|
||||
if #samples < 8 then
|
||||
table.insert(
|
||||
samples,
|
||||
("%s@%.0f,%.0f,%.0f drop=%.0f"):format(p.Name, p.Position.X, p.Position.Y, p.Position.Z, d)
|
||||
)
|
||||
end
|
||||
else
|
||||
seated += 1
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(
|
||||
out,
|
||||
("candidates=%d roads=%d | seated=%d floating(>%d)=%d nothingBelow=%d"):format(
|
||||
#candidates,
|
||||
#roads,
|
||||
seated,
|
||||
FLOAT_MIN,
|
||||
floating,
|
||||
voidBelow
|
||||
)
|
||||
)
|
||||
if worstPos then
|
||||
table.insert(
|
||||
out,
|
||||
("WORST %s drop=%.0f at %.0f,%.0f,%.0f"):format(worstNm, worst, worstPos.X, worstPos.Y, worstPos.Z)
|
||||
)
|
||||
end
|
||||
for _, s in ipairs(samples) do
|
||||
table.insert(out, " " .. s)
|
||||
end
|
||||
|
||||
-- Name histogram so we can see WHICH generator made them.
|
||||
local hist = {}
|
||||
for _, p in ipairs(candidates) do
|
||||
local base = p.Name:gsub("_%d+$", "")
|
||||
hist[base] = (hist[base] or 0) + 1
|
||||
end
|
||||
local hl = {}
|
||||
for k, v in pairs(hist) do
|
||||
table.insert(hl, ("%s=%d"):format(k, v))
|
||||
end
|
||||
table.sort(hl)
|
||||
table.insert(out, "byName: " .. table.concat(hl, " "))
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,104 @@
|
||||
-- FLYOVER PROBE. Two things the connectivity check cannot tell you:
|
||||
--
|
||||
-- 1. Does the crossing actually pass OVER the mainline deck, with real
|
||||
-- vertical clearance? A "flyover" that intersects the deck is just a
|
||||
-- wall. Measured as: for every Cross part, find mainline Road parts
|
||||
-- directly beneath it in plan, and report the worst (smallest) gap
|
||||
-- between the ramp underside and the road topside.
|
||||
--
|
||||
-- 2. Does the descent land tangent and flush on the FAR carriageway?
|
||||
-- Reported as the dY at the last descent part vs the road beneath it.
|
||||
--
|
||||
-- Every metric carries a CONTROL that must come back non-zero, because a
|
||||
-- silent 0 from a mis-scoped query is indistinguishable from a genuine pass
|
||||
-- -- that exact trap produced two phantom "defects" earlier in this build.
|
||||
|
||||
local ws = game:GetService("Workspace")
|
||||
local map = ws:FindFirstChild("Map")
|
||||
if not map then
|
||||
return "NO MAP"
|
||||
end
|
||||
|
||||
local cross, descent, roads = {}, {}, {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local nm = d.Name
|
||||
if nm:find("RampCross_") then
|
||||
table.insert(cross, d)
|
||||
elseif nm:find("RampNear_") then
|
||||
table.insert(descent, d)
|
||||
elseif nm:sub(1, 5) == "Road_" then
|
||||
table.insert(roads, d)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- plan-view overlap (ignores Y entirely)
|
||||
local function planOverlap(a, b)
|
||||
return math.abs(a.Position.X - b.Position.X) < (a.Size.X + b.Size.X) / 2
|
||||
and math.abs(a.Position.Z - b.Position.Z) < (a.Size.Z + b.Size.Z) / 2
|
||||
end
|
||||
|
||||
local out = {}
|
||||
|
||||
-- CONTROL 1: a road part must plan-overlap itself.
|
||||
local ctlSelf = (#roads > 0 and planOverlap(roads[1], roads[1])) and 1 or 0
|
||||
table.insert(out, ("CONTROL planOverlap-self=%d (want 1)"):format(ctlSelf))
|
||||
|
||||
-- CLEARANCE over the deck.
|
||||
local worst, worstAt, nOver = 1e9, "none", 0
|
||||
for _, cp in ipairs(cross) do
|
||||
for _, rd in ipairs(roads) do
|
||||
if planOverlap(cp, rd) then
|
||||
nOver += 1
|
||||
-- ramp underside minus road topside
|
||||
local gap = (cp.Position.Y - cp.Size.Y / 2) - (rd.Position.Y + rd.Size.Y / 2)
|
||||
if gap < worst then
|
||||
worst, worstAt = gap, cp.Name .. " over " .. rd.Name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if nOver == 0 then
|
||||
table.insert(out, "CROSSING: 0 road parts under any Cross part -- ramp does NOT span the deck")
|
||||
else
|
||||
table.insert(out, ("CROSSING: %d overlaps | worst vertical gap=%.1f at %s"):format(nOver, worst, worstAt))
|
||||
end
|
||||
|
||||
-- LANDING: last descent part of each ramp vs the road under it.
|
||||
local function tailOf(prefix)
|
||||
local best, bestIdx = nil, -1
|
||||
for _, p in ipairs(descent) do
|
||||
if p.Name:sub(1, #prefix) == prefix then
|
||||
local i = tonumber(p.Name:match("_(%d+)$")) or 0
|
||||
if i > bestIdx then
|
||||
best, bestIdx = p, i
|
||||
end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
for _, pre in ipairs({ "PAEntryRampNear", "PAExitRampNear" }) do
|
||||
local t = tailOf(pre)
|
||||
if not t then
|
||||
table.insert(out, pre .. ": no tail part")
|
||||
else
|
||||
local bestGap, bestNm = 1e9, "none"
|
||||
for _, rd in ipairs(roads) do
|
||||
if planOverlap(t, rd) then
|
||||
local dy = (t.Position.Y - t.Size.Y / 2) - (rd.Position.Y + rd.Size.Y / 2)
|
||||
if math.abs(dy) < math.abs(bestGap) then
|
||||
bestGap, bestNm = dy, rd.Name
|
||||
end
|
||||
end
|
||||
end
|
||||
if bestNm == "none" then
|
||||
table.insert(out, ("%s LANDING: tail is over NO road -- lands off the carriageway"):format(pre))
|
||||
else
|
||||
table.insert(out, ("%s LANDING: dY=%.1f onto %s"):format(pre, bestGap, bestNm))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(out, ("counts cross=%d descent=%d roads=%d"):format(#cross, #descent, #roads))
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,97 @@
|
||||
-- The 50-sample "unbarriered run" (513..562, LEFT) overlaps the aux-lane region
|
||||
-- (510..565) exactly. In that stretch the parapet was deliberately RELOCATED
|
||||
-- outboard, to the aux lane's outer edge -- so a 12-stud probe off the mainline
|
||||
-- edge would report "missing" when the barrier is simply further out.
|
||||
-- Do not claim a hole until this is disambiguated. Report ACTUAL distances.
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" end
|
||||
|
||||
local bars = {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail"
|
||||
or d.Name == "KillWall" or d.Name:match("Parapet")) then
|
||||
table.insert(bars, d)
|
||||
end
|
||||
end
|
||||
say(("barrier-ish parts: %d"):format(#bars))
|
||||
|
||||
local aux = {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") and d.Name:match("^AuxLane_(%d+)$") then
|
||||
table.insert(aux, d)
|
||||
end
|
||||
end
|
||||
say(("aux lane parts: %d"):format(#aux))
|
||||
|
||||
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 deck[tonumber(k)] = d end
|
||||
end
|
||||
end
|
||||
|
||||
local UP = Vector3.new(0, 1, 0)
|
||||
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||
|
||||
-- For a probe point, distance to nearest barrier AND nearest aux part.
|
||||
local function nearest(list, p, ymax)
|
||||
local best, bi = math.huge, nil
|
||||
for _, b in ipairs(list) do
|
||||
if math.abs(b.Position.Y - p.Y) < (ymax or 16) then
|
||||
local d2 = (flat(b.Position) - flat(p)).Magnitude
|
||||
if d2 < best then best = d2; bi = b end
|
||||
end
|
||||
end
|
||||
return best, bi
|
||||
end
|
||||
|
||||
say("sample | edgeDist->barrier | auxHere | verdict")
|
||||
local realGaps = {}
|
||||
for i = 505, 570 do
|
||||
local d = deck[i]
|
||||
if d then
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
-- LEFT side probe (side = -right)
|
||||
local edge = d.Position - right * (d.Size.X / 2 + 2)
|
||||
local bd = nearest(bars, edge)
|
||||
local ad = nearest(aux, edge)
|
||||
local hasAux = ad < 20
|
||||
-- A barrier is "present" if within 12 of the mainline edge, OR the aux
|
||||
-- lane is here and a barrier sits within 12 of the aux OUTER edge.
|
||||
local ok = bd < 12
|
||||
if not ok and hasAux then
|
||||
local auxEdge = d.Position - right * (d.Size.X / 2 + 26)
|
||||
local bd2 = nearest(bars, auxEdge)
|
||||
if bd2 < 14 then ok = true end
|
||||
if i % 6 == 0 then
|
||||
say((" %d | %.1f | aux=%s | auxEdgeBarrier=%.1f | %s"):format(
|
||||
i, bd, tostring(hasAux), bd2, ok and "OK(relocated)" or "GAP"))
|
||||
end
|
||||
elseif i % 6 == 0 then
|
||||
say((" %d | %.1f | aux=%s | %s"):format(i, bd, tostring(hasAux), ok and "OK" or "GAP"))
|
||||
end
|
||||
if not ok then table.insert(realGaps, i) end
|
||||
end
|
||||
end
|
||||
|
||||
say(("REAL gaps after accounting for relocated parapet: %d samples"):format(#realGaps))
|
||||
if #realGaps > 0 then
|
||||
local runs, cur = {}, nil
|
||||
for _, i in ipairs(realGaps) do
|
||||
if cur and i - cur.last <= 2 then cur.last = i
|
||||
else if cur then table.insert(runs, cur) end cur = { first = i, last = i } end
|
||||
end
|
||||
if cur then table.insert(runs, cur) end
|
||||
for _, r in ipairs(runs) do
|
||||
local d = deck[r.first]
|
||||
say((" GAP RUN %d..%d at (%.0f,%.0f,%.0f)"):format(
|
||||
r.first, r.last, d.Position.X, d.Position.Y, d.Position.Z))
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,119 @@
|
||||
-- GROUND-TRUTH PROBE for the floating-scenery finding.
|
||||
--
|
||||
-- probe_floating reported 664 parts with "nothing below". That number alone
|
||||
-- is ambiguous: it reads identically whether (a) the buildings genuinely
|
||||
-- hang in the air, or (b) the map has no ground/terrain plane at all, so
|
||||
-- NOTHING can ever return a hit. Distinguish before calling it a defect.
|
||||
--
|
||||
-- Measure:
|
||||
-- 1. Does the map contain terrain / a baseplate / a water or ground slab?
|
||||
-- 2. Bottom-face Y of each scenery part vs the road deck Y and vs 0.
|
||||
-- A building whose bottom is at/below ~0 is "planted" even with no
|
||||
-- ground mesh; one whose bottom is hundreds of studs up is FLOATING.
|
||||
|
||||
local ws = game:GetService("Workspace")
|
||||
local map = ws:FindFirstChild("Map")
|
||||
if not map then
|
||||
return "NO MAP"
|
||||
end
|
||||
|
||||
local out = {}
|
||||
|
||||
-- 1. terrain / ground candidates
|
||||
local terr = ws:FindFirstChildOfClass("Terrain")
|
||||
local terrHas = false
|
||||
if terr then
|
||||
local ok, reg = pcall(function()
|
||||
return terr.MaxExtents
|
||||
end)
|
||||
-- MaxExtents is a Region3int16; its Min/Max are Vector3int16 which have
|
||||
-- no .Magnitude. Compare components instead.
|
||||
if ok and reg then
|
||||
local mn, mx = reg.Min, reg.Max
|
||||
terrHas = (mx.X - mn.X) > 1 or (mx.Y - mn.Y) > 1 or (mx.Z - mn.Z) > 1
|
||||
end
|
||||
end
|
||||
table.insert(out, ("Terrain present=%s hasVoxels=%s"):format(tostring(terr ~= nil), tostring(terrHas)))
|
||||
|
||||
local groundish = {}
|
||||
for _, d in ipairs(ws:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local n = d.Name:lower()
|
||||
if
|
||||
n:find("ground")
|
||||
or n:find("base")
|
||||
or n:find("water")
|
||||
or n:find("sea")
|
||||
or n:find("bay")
|
||||
or n:find("floor")
|
||||
then
|
||||
local sz = d.Size
|
||||
if sz.X > 200 or sz.Z > 200 then
|
||||
table.insert(
|
||||
groundish,
|
||||
("%s size=%.0fx%.0f topY=%.1f"):format(d.Name, sz.X, sz.Z, d.Position.Y + sz.Y / 2)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(out, ("large ground-like slabs=%d"):format(#groundish))
|
||||
for i = 1, math.min(#groundish, 6) do
|
||||
table.insert(out, " " .. groundish[i])
|
||||
end
|
||||
|
||||
-- 2. bottom-face Y of scenery vs deck
|
||||
local roadY, roadN = {}, 0
|
||||
local scen = {}
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local nm = d.Name
|
||||
if nm:sub(1, 5) == "Road_" then
|
||||
roadN += 1
|
||||
table.insert(roadY, d.Position.Y)
|
||||
elseif
|
||||
nm:find("Building")
|
||||
or nm:find("Tower")
|
||||
or nm:find("Ginza")
|
||||
or nm:find("Shiodome")
|
||||
or nm:find("Diet")
|
||||
then
|
||||
table.insert(scen, d)
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(roadY)
|
||||
local medRoad = roadY[math.max(1, math.floor(#roadY / 2))] or 0
|
||||
table.insert(out, ("median road Y=%.1f (n=%d)"):format(medRoad, roadN))
|
||||
|
||||
local planted, floatHigh = 0, 0
|
||||
local hist = {}
|
||||
local worst, worstNm, worstP = -1e9, "none", nil
|
||||
for _, p in ipairs(scen) do
|
||||
local btm = p.Position.Y - p.Size.Y / 2
|
||||
if btm <= 5 then
|
||||
planted += 1
|
||||
else
|
||||
floatHigh += 1
|
||||
local base = p.Name:gsub("_%d+$", "")
|
||||
hist[base] = (hist[base] or 0) + 1
|
||||
if btm > worst then
|
||||
worst, worstNm, worstP = btm, p.Name, p.Position
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(out, ("scenery=%d | bottom<=5 (planted)=%d | bottom>5 (airborne)=%d"):format(#scen, planted, floatHigh))
|
||||
if worstP then
|
||||
table.insert(
|
||||
out,
|
||||
("HIGHEST bottom: %s btmY=%.0f at %.0f,%.0f,%.0f"):format(worstNm, worst, worstP.X, worstP.Y, worstP.Z)
|
||||
)
|
||||
end
|
||||
local hl = {}
|
||||
for k, v in pairs(hist) do
|
||||
table.insert(hl, ("%s=%d"):format(k, v))
|
||||
end
|
||||
table.sort(hl)
|
||||
table.insert(out, "airborne byName: " .. (#hl > 0 and table.concat(hl, " ") or "(none)"))
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,93 @@
|
||||
-- The 10 flagged samples all read barrier=22.9 -- the exact signature of the
|
||||
-- RELOCATED parapet (aux lane still exists at 510..524 and 551..565, and its
|
||||
-- outer edge carries the rail). probe_final's audit did not re-probe the aux
|
||||
-- outer edge, so it mislabelled them. Settle it: for each flagged sample,
|
||||
-- measure distance from the AUX OUTER edge to the nearest barrier.
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" end
|
||||
|
||||
local deck, bars, aux = {}, {}, {}
|
||||
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
|
||||
local a = d.Name:match("^AuxLane_(%d+)$")
|
||||
if a then aux[tonumber(a)] = 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(p, ytol)
|
||||
local best = math.huge
|
||||
for _, b in ipairs(bars) 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
|
||||
|
||||
local flagged = { 520, 521, 522, 523, 524, 551, 552, 553, 554, 555 }
|
||||
local trueHoles = {}
|
||||
say("sample | auxHere | barrier@auxOuterEdge | verdict")
|
||||
for _, i in ipairs(flagged) do
|
||||
local d, a = deck[i], aux[i]
|
||||
if d then
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
local verdict, dist
|
||||
if a then
|
||||
-- probe just outboard of the AUX lane, where the rail was moved to
|
||||
local outer = a.Position - right * (a.Size.X / 2 + 2)
|
||||
dist = nearest(outer)
|
||||
verdict = dist < 14 and "OK (parapet relocated to aux edge)" or "HOLE"
|
||||
else
|
||||
local edge = d.Position - right * (d.Size.X / 2 + 2)
|
||||
dist = nearest(edge)
|
||||
verdict = dist < 12 and "OK" or "HOLE"
|
||||
end
|
||||
if verdict == "HOLE" then table.insert(trueHoles, i) end
|
||||
say((" %d | aux=%s | %.1f | %s"):format(i, a and "yes" or "no", dist, verdict))
|
||||
end
|
||||
end
|
||||
|
||||
say(("TRUE UNGUARDED SAMPLES: %d %s"):format(#trueHoles,
|
||||
#trueHoles > 0 and table.concat(trueHoles, ",") or "(none)"))
|
||||
|
||||
-- Full-loop sweep so nothing elsewhere is missed.
|
||||
local swept, holes = 0, {}
|
||||
for i, d in pairs(deck) do
|
||||
swept += 1
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
for _, sgn in ipairs({ -1, 1 }) do
|
||||
local edge = d.Position + right * sgn * (d.Size.X / 2 + 2)
|
||||
if nearest(edge) >= 12 then
|
||||
local a = aux[i]
|
||||
local ok = false
|
||||
if a then
|
||||
local outer = a.Position + right * sgn * (a.Size.X / 2 + 2)
|
||||
if nearest(outer) < 14 then ok = true end
|
||||
end
|
||||
if not ok then
|
||||
-- ramp mouth? slip structure nearby counts as guarded
|
||||
local sd = math.huge
|
||||
for _, p in ipairs(map:GetDescendants()) do
|
||||
if p:IsA("BasePart") and p.Name:match("Slip") then
|
||||
local dd = (flat(p.Position) - flat(edge)).Magnitude
|
||||
if dd < sd then sd = dd end
|
||||
end
|
||||
end
|
||||
if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
say(("FULL SWEEP: %d deck samples, unguarded=%d %s"):format(swept, #holes,
|
||||
#holes > 0 and ("-> " .. table.concat(holes, ",")) or "(none)"))
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Slip walls measured CLEAN (0 missing, 0 deviation, 0.00 lateral step), so
|
||||
-- neither marked defect is on the ramp decks themselves. Prime suspect is the
|
||||
-- suppression boundary I introduced: across 527..548 the parapet is forced back
|
||||
-- to the mainline edge, while 510..526 and 549..565 still carry it relocated
|
||||
-- OUT on the aux lane's outer edge (~26 studs further). At samples 527 and 549
|
||||
-- that is a ~26-stud lateral JOG -- a barrier that visibly sticks out and ends
|
||||
-- in a stub. And the gore between mainline barrier and ramp wall start may be
|
||||
-- open -- the missing-barrier oval.
|
||||
--
|
||||
-- Measure signed lateral offset of every Barrier from the mainline centreline,
|
||||
-- walk 505..570, and report steps + gaps.
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" end
|
||||
local UP = Vector3.new(0, 1, 0)
|
||||
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||
|
||||
local deck, aux = {}, {}
|
||||
local bars = {}
|
||||
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
|
||||
local a = d.Name:match("^AuxLane_(%d+)$")
|
||||
if a then aux[tonumber(a)] = d end
|
||||
if d.Name == "Barrier" then table.insert(bars, d) end
|
||||
end
|
||||
end
|
||||
|
||||
-- For each mainline sample, find the barrier nearest to that sample's plane on
|
||||
-- the LEFT side, and report its lateral offset from the centreline.
|
||||
say("sample | auxW | barrier lateral offset from centreline | step")
|
||||
local prevLat = nil
|
||||
local jogs, gaps = {}, {}
|
||||
for i = 505, 570 do
|
||||
local d = deck[i]
|
||||
if d then
|
||||
local right = d.CFrame.LookVector:Cross(UP).Unit
|
||||
local c = d.Position
|
||||
-- gather barriers within half a sample spacing along the road axis
|
||||
local best, bestLat = math.huge, nil
|
||||
for _, b in ipairs(bars) do
|
||||
if math.abs(b.Position.Y - c.Y) < 14 then
|
||||
local rel = b.Position - c
|
||||
local along = math.abs(rel:Dot(d.CFrame.LookVector))
|
||||
local lat = rel:Dot(right)
|
||||
if along < 22 and lat < 0 then -- LEFT side only
|
||||
if along < best then best = along; bestLat = lat end
|
||||
end
|
||||
end
|
||||
end
|
||||
local auxW = aux[i] and aux[i].Size.X or 0
|
||||
if bestLat then
|
||||
local step = prevLat and math.abs(math.abs(bestLat) - math.abs(prevLat)) or 0
|
||||
if step > 6 then
|
||||
table.insert(jogs, ("%d(step %.1f)"):format(i, step))
|
||||
end
|
||||
if i % 4 == 0 or step > 6 then
|
||||
say((" %d | aux=%.0f | lat=%.1f | step=%.1f%s"):format(
|
||||
i, auxW, bestLat, step, step > 6 and " <<< JOG" or ""))
|
||||
end
|
||||
prevLat = bestLat
|
||||
else
|
||||
table.insert(gaps, i)
|
||||
say((" %d | aux=%.0f | NO BARRIER FOUND on left <<< GAP"):format(i, auxW))
|
||||
prevLat = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
say(("JOGS (lateral step >6): %d %s"):format(#jogs, #jogs > 0 and table.concat(jogs, " ") or "(none)"))
|
||||
say(("GAPS (no left barrier): %d %s"):format(#gaps, #gaps > 0 and table.concat(gaps, ",") or "(none)"))
|
||||
|
||||
-- Gore check: distance from each slip's FIRST wall to the nearest mainline
|
||||
-- barrier. A large value means an open mouth corner -- the missing-barrier oval.
|
||||
say("--- gore / mouth junction ---")
|
||||
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||
local first, firstK = nil, math.huge
|
||||
local last, lastK = nil, -1
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local wk = d.Name:match("^" .. s .. "_Wall_(%d+)$")
|
||||
if wk then
|
||||
local k = tonumber(wk)
|
||||
if k < firstK then firstK = k; first = d end
|
||||
if k > lastK then lastK = k; last = d end
|
||||
end
|
||||
end
|
||||
end
|
||||
if first then
|
||||
local bd = math.huge
|
||||
for _, b in ipairs(bars) do
|
||||
if math.abs(b.Position.Y - first.Position.Y) < 16 then
|
||||
local dd = (flat(b.Position) - flat(first.Position)).Magnitude
|
||||
if dd < bd then bd = dd end
|
||||
end
|
||||
end
|
||||
say(("%s: first wall seg %d at (%.0f,%.0f,%.0f) -> nearest mainline barrier %.1f"):format(
|
||||
s, firstK, first.Position.X, first.Position.Y, first.Position.Z, bd))
|
||||
say((" last wall seg %d at (%.0f,%.0f,%.0f)"):format(
|
||||
lastK, last.Position.X, last.Position.Y, last.Position.Z))
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,73 @@
|
||||
-- PAEastSlip turns 216deg vs PAWestSlip's 56.5deg -- nearly 4x. That is the
|
||||
-- "looks terrible" the user means. Find WHERE the turn concentrates: is it a
|
||||
-- smooth long curve, or a few violent kinks? Per-segment heading deltas tell us.
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" end
|
||||
|
||||
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, sz = d.Size }) 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 deltas = {}
|
||||
for j = 2, #segs do
|
||||
local a = segs[j - 1].cf.LookVector
|
||||
local b = segs[j].cf.LookVector
|
||||
local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1)))
|
||||
table.insert(deltas, { i = segs[j].i, d = dth, p = segs[j].p })
|
||||
end
|
||||
|
||||
-- worst kinks
|
||||
local sorted = {}
|
||||
for _, v in ipairs(deltas) do table.insert(sorted, v) end
|
||||
table.sort(sorted, function(x, y) return x.d > y.d end)
|
||||
|
||||
local sum = 0
|
||||
for _, v in ipairs(deltas) do sum += v.d end
|
||||
|
||||
say(("%s: total=%.1fdeg mean=%.2fdeg/seg n=%d"):format(s, sum, sum / #deltas, #deltas))
|
||||
local worst = {}
|
||||
for j = 1, math.min(8, #sorted) do
|
||||
table.insert(worst, ("seg%d=%.1f"):format(sorted[j].i, sorted[j].d))
|
||||
end
|
||||
say(" worst kinks: " .. table.concat(worst, " "))
|
||||
|
||||
-- how many segments exceed a smooth-curve threshold?
|
||||
local over = 0
|
||||
for _, v in ipairs(deltas) do if v.d > 5 then over += 1 end end
|
||||
say((" segments turning >5deg: %d"):format(over))
|
||||
|
||||
-- segment length consistency (uneven lengths also read as ugly)
|
||||
local lmin, lmax, lsum = math.huge, -math.huge, 0
|
||||
for j = 2, #segs do
|
||||
local L = (segs[j].p - segs[j - 1].p).Magnitude
|
||||
lmin = math.min(lmin, L); lmax = math.max(lmax, L); lsum += L
|
||||
end
|
||||
say((" seg spacing: min=%.1f max=%.1f mean=%.1f totalRun=%.0f"):format(
|
||||
lmin, lmax, lsum / (#segs - 1), lsum))
|
||||
|
||||
-- vertical profile: where does it drop?
|
||||
local topY = segs[1].p.Y
|
||||
local level = 0
|
||||
for _, sg in ipairs(segs) do
|
||||
if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end
|
||||
end
|
||||
local steep = 0
|
||||
for j = 2, #segs do
|
||||
local dy = math.abs(segs[j].p.Y - segs[j - 1].p.Y)
|
||||
local dxz = (Vector3.new(segs[j].p.X, 0, segs[j].p.Z) - Vector3.new(segs[j - 1].p.X, 0, segs[j - 1].p.Z)).Magnitude
|
||||
if dxz > 0.1 and (dy / dxz) > 0.12 then steep += 1 end
|
||||
end
|
||||
say((" level-run=%d segments steeper than 12%%: %d"):format(level, steep))
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,61 @@
|
||||
-- East slip kinks hard at segs 6,7,11,12 (27.8/17.5/58.2/50.1 deg). The slip
|
||||
-- follows the mainline via centreAt(f), so either (a) the mainline bends
|
||||
-- viciously at those samples, or (b) the slip's own path math misbehaves.
|
||||
-- East runs join=551 -> land=539 (backwards, span=-12); west runs 525 -> 537.
|
||||
-- Sample the mainline heading across BOTH spans to tell which.
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" 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+)$")
|
||||
or d.Name:match("^RoadDeck_(%d+)$")
|
||||
if k then table.insert(deck, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end
|
||||
end
|
||||
end
|
||||
table.sort(deck, function(a, b) return a.i < b.i end)
|
||||
local byIdx = {}
|
||||
for _, d in ipairs(deck) do byIdx[d.i] = d end
|
||||
say(("mainline parts=%d idx %d..%d"):format(#deck, deck[1].i, deck[#deck].i))
|
||||
|
||||
local function report(lo, hi, label)
|
||||
say(("--- mainline heading, samples %d..%d (%s) ---"):format(lo, hi, label))
|
||||
local prev, acc = nil, 0
|
||||
local worst, worstAt = 0, -1
|
||||
for i = lo, hi do
|
||||
local d = byIdx[i]
|
||||
if d then
|
||||
local h = d.cf.LookVector
|
||||
if prev then
|
||||
local dth = math.deg(math.acos(math.clamp(prev:Dot(h), -1, 1)))
|
||||
acc += dth
|
||||
if dth > worst then worst = dth; worstAt = i end
|
||||
if dth > 4 then
|
||||
say((" sample %d: turn %.1fdeg at (%.0f,%.0f,%.0f)"):format(i, dth, d.p.X, d.p.Y, d.p.Z))
|
||||
end
|
||||
end
|
||||
prev = h
|
||||
end
|
||||
end
|
||||
say((" total=%.1fdeg worst=%.1fdeg@%d"):format(acc, worst, worstAt))
|
||||
end
|
||||
|
||||
report(525, 537, "WEST slip span - smooth one")
|
||||
report(539, 551, "EAST slip span - ugly one")
|
||||
|
||||
-- Also: does the east span cross the sample-wrap boundary or reverse direction?
|
||||
say("--- endpoint geometry ---")
|
||||
for _, pair in ipairs({ { 525, 537, "west" }, { 551, 539, "east" } }) do
|
||||
local a, b = byIdx[pair[1]], byIdx[pair[2]]
|
||||
if a and b then
|
||||
local straight = (Vector3.new(a.p.X, 0, a.p.Z) - Vector3.new(b.p.X, 0, b.p.Z)).Magnitude
|
||||
say(("%s: join@%d=(%.0f,%.0f) land@%d=(%.0f,%.0f) straightDist=%.0f"):format(
|
||||
pair[3], pair[1], a.p.X, a.p.Z, pair[2], b.p.X, b.p.Z, straight))
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,103 @@
|
||||
-- READ-ONLY inventory of the Daikoku Futo PA area so the capture camera can be
|
||||
-- anchored to real geometry instead of guessed coordinates.
|
||||
--
|
||||
-- Wanted:
|
||||
-- * what top-level containers/models exist for the PA
|
||||
-- * every distinct part-name PREFIX in the PA region, with counts
|
||||
-- * the PA's world-space bounding box + centre (for framing)
|
||||
-- * key landmarks: slip ramps, apron/parking deck, any building/structure
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" end
|
||||
|
||||
-- 1. Top-level structure of Map
|
||||
say("=== Map children ===")
|
||||
for _, c in ipairs(map:GetChildren()) do
|
||||
local n = 0
|
||||
for _, d in ipairs(c:GetDescendants()) do
|
||||
if d:IsA("BasePart") then n += 1 end
|
||||
end
|
||||
say((" %s (%s) parts=%d"):format(c.Name, c.ClassName, n))
|
||||
end
|
||||
|
||||
-- 2. Anything whose name mentions PA / Daikoku / parking
|
||||
say("=== named PA-ish objects (models/folders) ===")
|
||||
local seen = 0
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if not d:IsA("BasePart") then
|
||||
local nm = d.Name
|
||||
if nm:match("PA") or nm:lower():match("daikoku") or nm:lower():match("park")
|
||||
or nm:lower():match("apron") or nm:lower():match("rest") then
|
||||
local n = 0
|
||||
for _, x in ipairs(d:GetDescendants()) do
|
||||
if x:IsA("BasePart") then n += 1 end
|
||||
end
|
||||
say((" %s (%s) parts=%d parent=%s"):format(d.Name, d.ClassName, n, d.Parent.Name))
|
||||
seen += 1
|
||||
if seen > 40 then say(" ...(truncated)") break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if seen == 0 then say(" (none found by name)") end
|
||||
|
||||
-- 3. Locate the PA by its slip ramps: the apron is where the ramps land.
|
||||
local land = {}
|
||||
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||
local best, bestK = nil, -1
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||
if k and tonumber(k) > bestK then bestK = tonumber(k); best = d end
|
||||
end
|
||||
end
|
||||
if best then
|
||||
land[s] = best.Position
|
||||
say(("%s landing: (%.0f, %.0f, %.0f)"):format(s, best.Position.X, best.Position.Y, best.Position.Z))
|
||||
end
|
||||
end
|
||||
|
||||
local anchor
|
||||
if land.PAWestSlip and land.PAEastSlip then
|
||||
anchor = (land.PAWestSlip + land.PAEastSlip) / 2
|
||||
elseif land.PAWestSlip then
|
||||
anchor = land.PAWestSlip
|
||||
elseif land.PAEastSlip then
|
||||
anchor = land.PAEastSlip
|
||||
end
|
||||
if not anchor then return table.concat(out, "\n") .. "\nNO SLIP LANDINGS -> cannot anchor" end
|
||||
say(("PA anchor (between landings): (%.0f, %.0f, %.0f)"):format(anchor.X, anchor.Y, anchor.Z))
|
||||
|
||||
-- 4. Every part within 1400 studs of the anchor, grouped by name prefix.
|
||||
local function flat(v) return Vector3.new(v.X, 0, v.Z) end
|
||||
local counts, mins, maxs = {}, {}, {}
|
||||
local lo = Vector3.new(math.huge, math.huge, math.huge)
|
||||
local hi = Vector3.new(-math.huge, -math.huge, -math.huge)
|
||||
local total = 0
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
if (flat(d.Position) - flat(anchor)).Magnitude < 1400 then
|
||||
total += 1
|
||||
-- prefix = name with trailing _<digits> stripped
|
||||
local pre = d.Name:gsub("_%d+$", "")
|
||||
counts[pre] = (counts[pre] or 0) + 1
|
||||
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))
|
||||
end
|
||||
end
|
||||
end
|
||||
say(("=== %d parts within 1400 studs of PA anchor ==="):format(total))
|
||||
local keys = {}
|
||||
for k in pairs(counts) do table.insert(keys, k) end
|
||||
table.sort(keys, function(a, b) return counts[a] > counts[b] end)
|
||||
for idx, k in ipairs(keys) do
|
||||
if idx > 30 then say((" ...and %d more prefixes"):format(#keys - 30)) break end
|
||||
say((" %-28s %d"):format(k, counts[k]))
|
||||
end
|
||||
say(("bbox: (%.0f,%.0f,%.0f) .. (%.0f,%.0f,%.0f)"):format(lo.X, lo.Y, lo.Z, hi.X, hi.Y, hi.Z))
|
||||
say(("bbox size: %.0f x %.0f x %.0f"):format(hi.X - lo.X, hi.Y - lo.Y, hi.Z - lo.Z))
|
||||
local ctr = (lo + hi) / 2
|
||||
say(("bbox centre: (%.0f, %.0f, %.0f)"):format(ctr.X, ctr.Y, ctr.Z))
|
||||
|
||||
return table.concat(out, "\n")
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Minimal PA locator. The full inventory probe returned an empty error, so
|
||||
-- start small and confirm the basics work before adding anything back.
|
||||
local out = {}
|
||||
local function say(s) table.insert(out, s) end
|
||||
|
||||
local map = workspace:FindFirstChild("Map")
|
||||
if not map then return "Map MISSING" end
|
||||
|
||||
say("=== Map children ===")
|
||||
for _, c in ipairs(map:GetChildren()) do
|
||||
local n = 0
|
||||
for _, d in ipairs(c:GetDescendants()) do
|
||||
if d:IsA("BasePart") then n += 1 end
|
||||
end
|
||||
say((" %s (%s) parts=%d"):format(c.Name, c.ClassName, n))
|
||||
end
|
||||
|
||||
local land = {}
|
||||
for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do
|
||||
local best, bestK = nil, -1
|
||||
for _, d in ipairs(map:GetDescendants()) do
|
||||
if d:IsA("BasePart") then
|
||||
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||
if k and tonumber(k) > bestK then
|
||||
bestK = tonumber(k)
|
||||
best = d
|
||||
end
|
||||
end
|
||||
end
|
||||
if best then
|
||||
land[s] = best.Position
|
||||
say(("%s landing: (%.0f, %.0f, %.0f)"):format(s, best.Position.X, best.Position.Y, best.Position.Z))
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(out, "\n")
|
||||