Surface survey spike (findings note)¶
Phase 0 of the track-survey work (#35,
spike #37): validate
the surface_types encoding and the wheel-contact derivation on a real PS5
before designing the per-track classification grid
(#38). The bundled
simulator proves nothing here — it only ever emits T and C.
Status: template — run the spike on real hardware and fill in the tables.
How to run it¶
Open the Survey tab in the app (with the console on packet format C, the
default), pick the circuit being surveyed (defaults to the session's
auto-identified track) and press Start survey. The capture runs
server-side in the 60 Hz packet path (app/processing/survey.py) — the
browser's ~30 Hz live stream would miss single-tick transitions — so any
phone or tablet on the LAN works as the display while you drive. Lap
recording continues alongside: laps saved during the run keep their per-tick
surface column, and the JSONL records carry the session id and lap number so
both datasets join up offline.
Drive a few laps that deliberately touch every surface the track offers —
kerbs on both sides, painted run-off, grass, gravel traps, sand if the track
has it. The view shows the live per-wheel surface, the char histogram, a
loud banner for any char the mapping doesn't know, and each transition's
derived wheel-contact points on a scatter map. Download JSONL grabs the
full transition log (position, velocity, raw orientation floats, per-wheel
contact points) for offline analysis; the server also keeps it next to the
database as data/surface_survey_<timestamp>.jsonl.
Suggested tracks for coverage: one kerb-heavy road course (e.g. Suzuka or Interlagos), one with gravel traps (Monza / Red Bull Ring), one dirt track (Fisherman's Ranch) and, if snow chars are real, Lake Louise.
1. Surface enum (fill in)¶
The decoder (packet.py)
reads 4 ASCII chars, one per wheel, FL FR RL RR. Community docs claim
T / C / D / G / S / s; app/processing/surface.py maps exactly those and
flags anything else as SURFACE_OTHER.
| Char | Assumed meaning | Confirmed on PS5? | Where seen (track / feature) |
|---|---|---|---|
T |
tarmac | ☐ | |
C |
curb / kerb | ☐ | |
D |
dirt | ☐ | |
G |
grass | ☐ | |
S |
sand | ☐ | |
s |
snow | ☐ | |
| ? | (new chars the spike reports) |
Open questions to answer while driving:
- Does painted run-off / painted kerb flat report
T,C, or something new? - Gravel trap vs dirt vs sand — distinct chars or all one?
- Is the per-wheel ordering really FL FR RL RR? (Clip one specific wheel over a kerb and check which position flips.)
- Does GT7 broadcast its own track-limits judgment anywhere? No packet field is documented for it; the only place one could hide is the four undocumented upper bits of the 16-bit flags field. The survey raises a banner if any of bits 12–15 ever activate, and every transition record carries the raw flags — trigger an in-game track-limits warning (time invalidated / penalty) during the run and check whether a bit correlates.
2. Wheel-contact derivation (fill in)¶
Contact point = car position + heading rotation of (±wheelbase/2, ±track/2). Wheelbase is broadcast in packet C; track width is not broadcast directly — but it is derivable, and every corner derives it. The outer wheels of an axle cover a larger arc than the inner ones, so their rolling speeds differ by exactly the yaw rate times the axle track:
|v_outer - v_inner| = |yaw rate| * track_width v = wheel_rps * tire_radius
wheel_rps, tire_radius and angular_velocity_y are all broadcast and
were already decoded, so this costs nothing and needs no special driving. It
reached a trusted 1.74 m within ~12 seconds of ordinary laps on real
hardware. Taking magnitudes means GT7's yaw sign convention never has to be
pinned down.
Two things learned doing it on hardware:
- A locked/spool differential makes its axle useless here. The test car's
rear wheels report identical speeds to the centimetre even coasting
(
-82.31 / -82.31at zero throttle), so the rear axle answers ~0. Both axles are therefore offered each tick and the plausible range picks the free one — no drivetrain layout ever has to be declared, and a locked axle self-rejects. - Braking corrupts it; throttle does not. ABS modulates wheels individually: the same capture that gave a steady 1.7–1.8 m produced 1.22, 2.03 and 4.87 m under brake pressure. Throttle needs no gate, because wheelspin lifts an axle's mean off the car's speed and is caught by the slip check — gating throttle would discard most of a racing lap.
The older fallback still exists: ride all four wheels over one edge and back.
The same wheel's out/back crossings pin the edge's direction, opposite-side
crossings of the same line fix the width, and remaining same-side crossings
must agree the points are collinear (which rejects two-edged strips, curved
kerbs and mid-corner crossings). It is exact when it fires, but it demands a
deliberate manoeuvre and across a full real session of heavy edge riding it
accepted zero samples — which is why cornering outranks it. The status
line names whichever is in force, and every JSONL record carries the tw_m
it was derived with.
Scale check, before anyone plans a backfill: the measured 1.74 m against
the 1.6 m assumption is a 0.14 m width error, so points laid under the
assumption sit 7 cm off laterally — 7% of the 1 m dedup cell. Recording
tw per point keeps correction possible, but at this magnitude the grid
cannot represent the correction and re-deriving old points is not worth
doing. It would take a width error above ~2 m to move a point a full cell.
Heading comes from ground-plane velocity; the raw rotation floats +
rel_orientation_to_north are logged for offline comparison.
Method: drive slowly over a kerb whose edge is visible on the race-line map, one wheel at a time, from both directions. The transition record pins where GT7 thinks the wheel met the kerb; measure the offset between the derived contact point and the kerb edge across passes.
| Question | Finding |
|---|---|
| Is the position field the car's midpoint (or an axle/CoG)? | |
Velocity-heading vs rel_orientation_to_north agreement |
|
Right-vector sign (f_z, -f_x) correct, or mirrored? |
|
| Positional error at ~1.6 m assumed track width (narrow car) | ± __ m |
| Positional error at ~1.6 m assumed track width (wide car, e.g. Gr.3) | ± __ m |
| Lag between visible contact and the char flipping | __ ticks |
Border tagging and the paved-runoff problem¶
Transitions where one side's wheels touch kerb/loose while the whole other
side stays on tarmac are tagged border: L/R (relative to travel direction;
left/right stays consistent lap after lap where inner/outer would swap at
every corner). The survey map draws each border contact as a short edge
tick along the local travel direction (blue = left, pink = right) — the two
perimeters trace themselves — and wherever a left point has a right point
directly across from it (perpendicular to travel, plausible road width
apart, same direction of driving), the span between them is filled as
confirmed road.
Edge points accumulate server-side for the whole run and stream to the map incrementally, so the track genuinely appears lap by lap — reloading the page or surveying for an hour loses nothing (50k-point backstop; the JSONL always has everything).
Track knowledge also outlives the run: each circuit's perimeter evidence and
finish crossings merge into a track bundle
(data/track-bundles/<slug>.json, one record per meter per side so it
converges instead of growing). A new survey on the same circuit resumes from
its bundle — the map opens with everything ever mapped — and saves back on
stop and on circuit changes (a run's evidence is flushed and cleared when
the label changes, so one circuit can never pollute another's bundle).
Bundles are versioned, self-describing documents downloadable via
/api/track-bundles/{slug}, designed to graduate into their own repo and be
imported at build time like data/tracks.json. Width calibration stays out:
it belongs to the car, not the circuit.
A meter of border is one fact, voted on (format v2). Each record carries
votes[kind][source] = [count, last_run], and the kind it resolves to
follows one rule: hand-marked kinds beat inferred ones outright, majority inside
each tier. That is not a tie-break preference — the surface chars are
blind to walls and paved run-off (both read as plain T), so an
auto/straddle point at a marked meter is not evidence against the mark, only
evidence that the char stream could not see it. Majority within the manual
tier is the way back from a mis-mark (mark it correctly twice and it wins).
Format v1 keyed on kind as well, so contradictions were stored side by side
instead of resolved, and the consumer kept both: it drops runoff points
from the road fill, but the co-located twin survived the filter and held the
meter in the road anyway. Measured on the author's real bundles, v1 →v2
found 892 of 4634 contested cells at Lago Centre (19%), including 105
meters where a hand-marked run-off limit had been silently overruled by an
auto/straddle point. Bundles upgrade in place on load, voting everything v1
recorded as run 0 so the next real run outranks it.
Votes count runs, not samples — the ~60 s autosave re-merges the same run's evidence repeatedly, and without the run stamp a long session would inflate its own votes by however many times it happened to autosave.
They also count runs per source (format v4). Run ordinals are local: my
run 7 and your run 7 are unrelated facts, so a merge keyed on the ordinal
alone would double-count one and silently drop the other depending on which
ordinals happened to collide, and the counts would stop being a census of
independent observations — which is the only reading under which "majority"
resolves anything. Every installation stamps a source id (12 hex
characters, generated once into data/source-id.json) on the votes it casts,
and a merge advances each source's own highest run. Two people who each drove
a meter once have seen it twice between them; re-pulling the same shared
bundle changes nothing. That is what made import and cross-machine merge
possible (#47) —
see the format reference.
Bundles also carry authored knowledge from v4: corners and sections labelled by hand in the Tracks view, anchored to world positions rather than lap distances. Authored data outranks derived data — the same principle the voting already follows — so a labelled circuit's corner numbering stops being re-inferred from each lap's curvature (#48).
Records also carry the provenance needed to second-guess them: run (which
run first evidenced the meter) and tw (the axle track width in use when it
was laid). tw earns its bytes because straddle points — 52% of Lago
Centre, 88% of East End — sit at ±tw/2 from the car centre and carry the
whole width-estimate error; recording it keeps open the option of correcting
their lateral offset offline once a better width is known. Position itself
stays first-seen, which keeps file diffs small.
The Track completeness card answers "is it ready?": per-border coverage of the driven loop (percent + the largest remaining gap, i.e. where to drive next), road coverage where both borders are known, and the finish line — located from lap rollovers (GT7 increments the lap counter exactly on the line), drawn dashed after one crossing and solid once repeat crossings agree within meters.
Straddle tracing is the workhorse: surface flips only pin the border at crossing moments, so a lap driven with one side's wheels HELD off the track would otherwise leave the border untraced between them. Instead, whenever both wheels of one side sit off the tarmac while the other side is fully on it, the survey samples a border point from the off-side wheel line every ~2 m automatically. Survey recipe: one lap hugging the left edge with the left wheels just off, one lap mirrored on the right — both perimeters trace themselves continuously and the road fill appears between them.
Manual boundary marking covers what surface chars cannot see: arm
Mark boundary: left/right + a kind (edge, run-off edge, wall) and
drive along the boundary — the survey records an edge point from that side's
wheel line every ~2 m. Wall-lined track produces no surface transitions at
all, and the outer limit of paved run-off reads as plain tarmac, so the
driven line is the only reliable source for both. Marked points render in
their own colors (purple = run-off edge, red = wall) and are stamped into
the JSONL as mark records.
Which tag to use¶
All three tags mark the same thing: the edge of the racing surface, in places the surface chars cannot see it because there is tarmac on both sides. They differ only in what lies beyond — which is exactly what lap-validity judging needs, since running wide onto pavement is not the same as running wide into gravel.
| Tag | Marks | Beyond it |
|---|---|---|
edge |
the track edge | nothing notable |
runoff |
the track edge | paved run-off |
wall |
the track edge | a wall, barrier or fence |
All three count as road, so the fill reaches all of them.
runoff originally meant the outer limit of the paved area, and was
excluded from the road fill on the reading that it bounded the run-off
rather than the road. Nobody used it that way. Measured across the author's
three circuits, all 946 run-off marks sit with the opposite border 12-14 m
across, against a measured road width of 12.7 m — every one of them is a
track edge. The exclusion meant the map drew no road at all through exactly
the corners someone had taken the trouble to survey, so the tag now means
what it was being used for.
Marking is the one input nothing can check against, and since v2 a
hand-marked kind deliberately outranks automatic evidence — so a mis-tag
beats the auto/straddle points that were right. Recovering one: majority
applies within the manual tier, so re-driving a metre and marking it
correctly twice outvotes a stale mark.
Raw packet inspector: the collapsible panel at the bottom of the Survey view shows every decoded packet field live (2 Hz), highlighting values that changed between samples — drive over anything interesting and call out what moves. Single-tick blips can slip between samples; the undocumented flag bits are watched server-side at 60 Hz regardless.
Paved runoff reads as plain T, indistinguishable per tick from the
racing surface. Things to establish on hardware, in order of leverage:
- Does paint get its own char? If the white boundary line or painted
run-off reports something other than
T, the track edge is directly observable and the problem mostly disappears. (Open question above.) - If not: runoff tarmac is still topologically separated from racing tarmac by the kerb/paint/loose band the border-tagged points trace. A live per-wheel "crossed the band vs bounced back" verdict is genuinely ambiguous (driving straight across an angled kerb produces zero lateral displacement in the car frame — there is no local signal to project against), so the verdict has to come from the track's shape rather than from any single tick.
In practice the runoff tag already answers this by hand: it marks the
track edge at places with pavement beyond, which is precisely the fact a
per-tick reading cannot supply. 946 metres of it are mapped across three
circuits.
For deriving it automatically, this note used to call for flood-filling
tarmac cells from the racing corridor in a grid build (#38). That is one way,
but it is not the only one and it is the expensive one — a filled grid at the
0.25 m cell size suggested below is ~92× more records than the border store
(~406k cells for Deep Forest). The argument is about connectivity across a
boundary, and a closed border curve answers it just as well: order the
border points into the racing-surface region, then any trail point reading
all-T while sitting outside that region is paved run-off. That needs
only what the bundles already hold, plus the point ordering that drawing a
real outline (#41) needs anyway. Both approaches share one hard limit: run-off
that was never driven on cannot be classified either way.
3. Verdict → no filled grid; the border curve instead¶
Resolved (2026-08, #38/#40): the filled classification grid was not built,
and the blanks this section used to carry are moot. The border store stays the
only evidence store, and the ordering argued for above is what shipped —
app/processing/track_compile.py walks each side's border cells into ordered
polylines (96–99% of cells chain on the surveyed circuits, usually into one
closed loop per side), pairs the left border across to the right border
curve to produce a centerline with width and elevation, and tiles the road
surface between them as quads.
That one ordering pass is what the grid was going to be for:
- Road region: the quad strip answers point-inside-road directly (used by edge-based lap validity, #41) — no flood fill needed.
- Coverage is measured against the boundary itself — surveyed metres over total boundary metres, gaps and loop closure in the denominator — rather than against the driven trail (#38's metric).
- Paved run-off derivation stays possible exactly as described above: a
point outside the road region reading all-
T. Not yet implemented; therunofftag keeps answering it by hand.
Costs that made the grid the wrong call, for the record: ~92× the records of
the border store at the 0.25 m cell size this section contemplated, a second
Alembic-managed store, and no answer the ordering doesn't already give. The
compiled geometry is derived data (data/track-bundles/compiled/, format in
the bundle reference) and is
recompiled whenever the bundle changes.