Commit Graph
191 Commits
Author SHA1 Message Date
CydandClaude Opus 5 e029113ade A replicant is measured in its own step
Three previous attempts at "is the watched pod's motion uniform" all
sampled it from somewhere else - the camera's step grid, or an arriving
packet's timestamp - and two independent clocks alias against each other
whatever the game is doing. Those numbers could never separate a real
hitch from the measurement's own beat, and each of them cost a deploy to
find that out.

This one has a single frame of reference: consecutive steps of the entity
being asked about, taken inside Mover::DeadReckon, which IS a replicant's
own per-step performance.

It also reports the mechanism rather than only the symptom. percent is how
far each step moves toward the projected position:

    percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)

so it depends on nextUpdate being a decent guess at when the next packet
lands. A poor guess makes the fraction swing, and a swinging fraction is
uneven motion however clean the packets were. The trace reports its range,
how many steps blended rather than snapped, and spikes and stalls against
the entity's own running mean.

One entity only, the first replicant seen, because the counters are shared
and a full grid would blend into noise.

What confirms: spikes or stalls in the entity's OWN steps, or a percent
range that swings. Either is real, because there is no second clock here to
blame. What refutes: uniform steps and a steady percent - then a replicant's
motion is fine and the tick is in presentation, and the remaining suspects
are frame delivery and the map raster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:07:46 -05:00
CydandClaude Opus 5 9a674e9241 The camera measures its own smoothness
The last instrument was mis-specified: it compared an arriving update
against our current position, but those are at different times, so most of
the 0.40 m it reported was latency times speed rather than prediction
error. It could not have shown a visual jump even if one existed. The
follow trace agreed - per-step motion stayed tight at 1.36 to 1.68 m and
tracked speed, with no outliers riding on top.

It did settle one thing: corrections arrive 43 times a second, near the
50 Hz step rate, so whatever ticks a few times a second is not one per
correction.

So measure the symptom instead of a theory about its cause. A visible tick
IS a step that moves much further, or much less, than the steps around it,
and that is now counted directly: spikes above 2.5x a short running mean,
stalls below 0.4x, judged against the mean rather than an absolute distance
because a pod at 75 m/s moves 1.5 m per step and one against a wall moves
nothing. Respawns are counted and excluded - they teleport hundreds of
metres and are meant to be discontinuities.

Stated in advance, so the result cannot be read to taste: spikes at a few
per second confirms the tick is in entity motion and gives its rate; spikes
and stalls near zero refutes it, and points at frame delivery or the
newly-active map raster instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 07:53:44 -05:00
CydandClaude Opus 5 5c7e218c98 A room cannot mix builds, and the log stops hiding things
Three guards, all of them paid for by an actual wasted afternoon.

THE BUILD GUARD. kNetRevision is hand-maintained and only bumped when
someone judges that a change alters the simulation, so two different builds
normally carry the same revision and will race each other happily - which
is how a 4.12.184 machine and a 4.12.187 machine sat in the same room all
day. The exact build now travels as lobby data and member data, and it is
checked in two places:

  - on JOIN, before the room is ever entered, with a dialog naming both
    builds. The owner's launch check would have caught it eventually, but
    only after everyone had picked a loadout and pressed go, and all it can
    do then is decline to start - which reads as the host's button being
    broken.
  - at LAUNCH, beside the revision check, so a member seated before the
    guard existed still cannot race.

The room screen shows BUILD <version> against an offending row, because a
host is owed the reason as well as the refusal. An absent value counts as a
mismatch: a build older than the key cannot be trusted to match. Both
guards stay - the revision still refuses a mix known to simulate
differently even where the build strings agree.

THE DUPLICATE KEY WARNING. environ.ini is applied line by line, so a second
copy of a key silently beats the first. A TARGETFPS added at the top of the
file was overridden by the one the template ships further down, and the test
it was written for looked as though it had failed rather than never having
run. Now: "TARGETFPS is set twice - line 1 and line 8; the LATER one wins",
naming the key and both lines, because which and where is the whole value.

THE MISSING FACTS. TARGETFPS appeared nowhere in the log, so no run could
be checked afterwards against what it was actually asked for, and
interpolation only announced itself when switched off - there was no way to
confirm from a log that it was on. Both are now stated outright, with a
warning when a frame target differs from the physics rate AND interpolation
is off, which is the combination that steps.

Verified: the duplicate warning names lines 1 and 8 of a file carrying both,
the frame line reads "144 fps, drawing on exact physics steps" with the
mismatch note when interpolation is off, and "60 fps, drawing interpolated
across the physics step" with no note when it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:58:27 -05:00
CydandClaude Opus 5 46aef0691e The replicant says how hard each update moves it
A tick a few times a second survived render interpolation, and it would:
interpolation smooths WITHIN a fixed step, and this is a discontinuity in
the stepped values themselves. Between updates a replicant is dead-reckoned
from updateOrigin over (lastPerformance - lastUpdate); when the next update
lands the basis is replaced and the position jumps by however far the
prediction had drifted. A few updates a second is a tick a few times a
second.

So Entity::ReadUpdateRecord now measures the correction before applying it:
how many arrived in the last five seconds, and the mean and worst distance
between where we had the entity and where the update says it was. Replicants
only, behind RP412CAMLOG, on a clock so a busy race cannot bury the log.

If that reads a few per second at tens of centimetres, the tick is named
and the fix is to damp the correction in rather than apply it instantly -
which is a real piece of work and worth sizing on evidence. If the
corrections are tiny, the tick is something else and this rules it out.

Also worth recording: RP412GAUGEDIAG is blind on a camera station. It only
reports when a full display sweep completes, and the missing-MFD bail
resets that counter every cycle, so the gauge theory could not be tested
that way. The camera's own symptom description did the work instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:49:17 -05:00
CydandClaude Opus 5 adcb81e7fc Replicants interpolate too
The first cut hung the snapshot off Mover::BeginStep, which is inside
Entity::PerformAndWatch's fixed-step interleave - and that interleave sits
entirely inside "if (GetInstance() != ReplicantInstance)". A replicant
never runs it; it reaches the step loop through Simulation::PerformAndWatch
instead. Every remote pod is a replicant, so on a Live Cam the camera was
being interpolated while the car it was watching still stepped. Smoother,
and most of the way to nowhere - which is exactly what "still some
hitching" was.

So the hooks move to Simulation::PerformTo, where both paths meet:
SnapshotRenderOrigin before each Perform, SetRenderStepFraction after the
loop, two virtuals that do nothing by default and are overridden by Entity
because Entity owns the origin. Mover::BeginStep goes back to what it was,
so there is now one mechanism instead of two.

Taking the snapshot inside the step loop is also strictly better placed
than BeginStep was: it lands immediately before the integration, and still
after any BeginStep teleport, so a VTV's scheduled respawn stays a cut.

Entity::PerformAndWatch keeps computing the fraction itself after its
interleave, because there PerformTo is called once per step with a till
one step ahead and so sees no leftover at all - it needs the FRAME's till,
which only the interleave has.

Determinism re-proved, and more thoroughly than the first time. The
scripted lap at 240 fps, interpolation on and off, on both the old build
and this one: all four runs agree to the last decimal at the same
simulation time - pos -15.06739 3.01541 388.02603 at t=15.260. The one
"differing" sample in the raw comparison was the trace sampling at t=1.260
in one run and t=1.280 in the other and then realigning, not divergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:36:44 -05:00
CydandClaude Opus 5 9e8a94c436 RP412INTERP is in the documented file
Every knob the code reads belongs in environ.ini's template, and the
interpolation switch was missing from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:15:18 -05:00
CydandClaude Opus 5 449ed5d297 Drawing interpolates across the fixed step
The simulation advances in whole 50 Hz steps and the renderer draws
whenever it can, so the drawn position only changed fifty times a second
and was held for however many frames fell inside a step. That is visible
as stepping, and it got WORSE the faster the machine: at 240 fps each
position is held for nearly five frames, which is why a fast PC looked
like a rabbit on crack while TARGETFPS=50 looked perfect. Matching the two
rates hid it, but locking the frame rate to the physics rate throws away
the entire point of having a fixed step.

So drawing now blends. Entity keeps renderPreviousOrigin - its origin at
the start of the step it is in, snapshotted by Mover::BeginStep - and
renderStepFraction, how far through that step the frame falls, which is
the leftover Entity::PerformAndWatch deliberately does not simulate.
GetRenderToWorld blends the two with Origin::Lerp, which already did
position and shortest-arc quaternion with normalisation.

It is RENDER ONLY. localOrigin and localToWorld are untouched, so physics,
collision, scoring, the nav map's queries and the network update records
all still see exact stepped values.

Three call sites. RootRenderable::Execute, which was the single place a
vehicle's transform reached the matrix stack - the renderable already ran
per frame and simply re-read a value that changed at the physics rate. The
eye needed its gate widened as well: it rebuilt the view only when
localToWorld CHANGED, so the world would have glided while the camera went
on stepping and the judder would have moved rather than gone. And a
teleport must stay a cut - that falls out free, because VTV::BeginStep
applies a scheduled respawn and THEN calls Mover::BeginStep, so the
snapshot lands post-teleport and the blend has nothing to travel.

The picture trails the simulation by up to one step, 20 ms at 50 Hz. That
is the standard price of interpolating rather than extrapolating, and much
the lesser evil: guessing forward overshoots and shimmers every time the
guess is corrected.

RP412INTERP=0 turns it off so the stepping can be seen again without a
rebuild.

Determinism proved rather than asserted: a scripted lap (RP412INPUTSCRIPT,
throttle and steering and pitch) at 240 fps with interpolation on and off,
90 PHYSTRACE samples over 22 seconds of driving, zero differ. Two earlier
attempts at that comparison were invalid and both were my method - the
first did not pin RP412SPAWNZONE so the runs began on different pads, and
the second had no input script, so a joystick sitting on the desk drove
the two runs differently. The template warns about the first of those in
as many words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:14:39 -05:00
CydandClaude Opus 5 55e751648f The camera says whether it is tracking a staircase
Janky tracking on a Live Cam, and the rotation is a spring-damper on the
fixed simulation step, so it should glide. Which leaves the input rather
than the smoothing: the camera follows a REMOTE pod, whose position only
changes on this host when an update lands. If the watched point sits still
for most steps and then leaps, the camera is tracking a staircase
faithfully and nothing in the rotation can hide it.

So FollowGoal now reports, every five seconds: how many simulation steps
it ran, how many of those saw the target move at all, the largest single
jump in metres, and how many times the trackside camera was cut to a
different one. At 50 Hz that is about 250 steps per report, so:

  moved near 250      the target moves every step - look elsewhere for
                      the jank, most likely frame pacing
  moved near 50       the target changes about ten times a second and the
                      camera is stepping between arrivals
  biggest jump large  confirms leaps rather than drift
  several cuts        the trackside camera is flip-flopping, which snaps
                      rather than glides and is its own kind of jank

The cut count is worth having because timeOnCamera is 0 for a race - the
director sets it to 0 outside football - so the closest-camera choice is
re-evaluated every step and only hysteresis stops it oscillating.

Not baselined locally: FollowGoal only runs on a camera station with a
peer, so unlike the nav and copy traces this one goes out unverified
against real numbers. The counters are simple enough to trust; the
interpretation above is what to hold it to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:26:15 -05:00
CydandClaude Opus 5 7a3b117f61 The sweep steps past a display it cannot draw
A Live Cam's map was blank and its score frozen at 1000 while the gauge
canvas underneath was being drawn perfectly - 50 static entities and one
mover, every sample, centre tracking the camera. The trace that settled it
found zero "map copy running" lines against that: the canvas was alive and
simply never reached the pane.

SVGA16::Update services one display per call and steps mDisplayToUpdate at
the END of the function. Both of its early bails returned before ever
getting there. A camera's cameraInit page configures the secondary port
and nothing else - no auxUL2, auxC, auxUR2, auxLL or auxLR - so the MFD
branch could never be serviced, and the first time the counter landed on
an MFD slot it stopped dead. Display 0's copy is the map, so it ran once,
early, and never again. The pane kept that one frame for the whole race: a
blank map, because nothing had registered with the renderer that early,
and a score showing its opening value. "It had name and score at the start
of the mission" was the tell, and it was accurate.

Both bails now step the rotation on the way out, so the sweep moves past a
display it cannot service instead of parking on it. A station with no MFDs
therefore copies its secondary every third call, which is the same cadence
a pod gets.

Pod regression: the map copy still runs every pass at the same ~9900 lit
pixels as before. A pod has all five MFD ports and never takes either
bail, so that path is untouched.

Three wrong diagnoses preceded this one - the gauge page, the renderer
link, and a snapshot theory - and each died to a measurement rather than
an argument. The trace that found it was worth more than any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:05:49 -05:00
CydandClaude Opus 5 d08404bf50 The room calls you what you typed
The lobby published SteamFriends()->GetPersonaName() as each member's
name, so the callsign box on the setup screen changed nothing anybody
could see - the room and the race both showed Steam personas instead. And
because the name never came from a file, wiping the install directory did
not shake it loose either, which is what made it look like stale data
being read from somewhere.

It now publishes the callsign. The persona stays as the fallback for a
player who has never set one, on the grounds that appearing as yourself
beats appearing as "Pilot".

This fixes the race as well as the room: a member's name in the egg comes
from the same published field, so the owner was building eggs full of
Steam personas too. The owner's own entry already used the typed callsign,
so the two were inconsistent in the same race.

RPL4FrontEnd_Callsign exposes what the front end already keeps and
persists in pilot.cfg. Verified the accessor reads it: a pilot.cfg
carrying callsign=TESTCALL logs FrontEnd: callsign "TESTCALL". The lobby
publish itself needs a room with a member in it, so that part rides on the
next two-machine run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:52:15 -05:00
CydandClaude Opus 5 0371544b88 The map copy says whether the canvas went dark
A Live Cam's secondary display carries its name and score at mission start
and is black later on. Placement is not the cause - a POD with
L4RADARPOS=LEFT fills the same corner properly, track lines, labels,
mini-map and all - so the pane and its position are fine and something
stops.

Three faults wear that one symptom: the per-frame copy stopping, the
source gauge canvas going blank underneath it, or the pane not repainting
what it was given. The copy now reports, every five seconds, that it ran
and how many non-zero pixels the source canvas holds (one row in sixteen
sampled, enough to tell blank from not). So:

  no line at all          the copy stopped
  line, lit falls to ~0   the gauge canvas went blank
  line, lit stays high    the pane is not showing what it was handed

Baseline from a pod, whose map demonstrably works: a steady 9900 or so lit
pixels every pass, 640x480 source, mask 0xff. Verified before shipping
this time, rather than after drawing a conclusion from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:32:04 -05:00
CydandClaude Opus 5 2adbeba6c4 The nav trace was measuring the wrong five seconds
It sampled the first five sweeps, which run while the mission is still
coming up and nothing has registered with the renderer yet. So it reported
"0 static, 0 moving" on a POD, whose map demonstrably works - and I took
that reading at face value and concluded the map was broken for everyone.
It was the instrument, not the game.

Now sampled on a clock like the ranking trace, every five seconds, and the
bounds line repeats too so the centre can be seen tracking. The real
baseline from a pod race:

  CamLog: nav in bounds - 0 static, 0 moving      (first sample, loading)
  CamLog: nav in bounds - 44 static, 1 moving     (running, and stays)

44 static is the track, 1 moving is the player, and the centre walks with
the vehicle. That is what a working nav display looks like, so a camera
station can now be compared against something real rather than against a
startup artefact.

Found by putting the pod's map on screen next to its own trace, which is
what should have happened before the first conclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:43:07 -05:00
CydandClaude Opus 5 04f72d4e3f A camera's map is placed by its own knob
The host picks Racer or Live Cam from the setup screen, so the two roles
cannot share one placement setting - switching would mean editing
environ.ini every time, which is not a thing to ask of anyone. L4MAPPOS
places the camera's map and L4MAPSCALE sizes it, taking the same values as
L4RADARPOS and L4RADARSCALE and defaulting to the bottom-left corner. A
pod keeps its own pair and its dead-centre default, which is where the
cabinet had it. Each role remembers its own; one file serves both.

The scale is split for the same reason as the position: a host who wanted
a bigger map while camming would otherwise have hit exactly the same
problem the next time out.

The log now names which variable it read - "map on the bottom left
(L4MAPPOS)" - because a setting that silently loses to another one is how
this went unnoticed in the first place. Both new keys are in the shipped
environ.ini template, so they are discoverable without reading the source.

Nothing to edit on an existing install: a file with L4RADARPOS set and no
L4MAPPOS gives the pod its centre and the camera its corner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:39:01 -05:00
CydandClaude Opus 5 1cd8911df0 The nav display says what it can see
A Live Cam's map came up as furniture with nothing in it, and two
confident explanations for that turned out to be wrong in a row - the
cameraInit gauge page exists, and the gauge renderer IS linked to the
camera ship, so both the page and its centre were fine all along. Two
falsified guesses is the signal to measure instead, so RP412CAMLOG now
traces the nav display: the scale and centre it resolves, the bounds it
asks about, how many static and moving entities come back, and whether the
sweep reaches the phase that actually draws. The ranking widget reports
the players it can see with their rank and score, every five seconds so
movement shows without flooding the log.

It answered both questions on the first run, from a plain POD race:

  CamLog: nav scale 1000m across, 0.448 px/m, centre 12,416
  CamLog: nav in bounds - 0 static, 0 moving
  CamLog: nav drew (phase 3 reached)

A pod's nav map is as empty as a camera's. Sane scale, resolved centre,
drawing phase reached, and nothing registered to draw - so this is not a
Live Cam defect at all, it is a hole in whatever should be filling the
gauge renderer's staticEntities and movingEntities, and it has been there
for every station all along. A camera only made it obvious by having
nothing else on the glass.

The score, by contrast, tracks properly on a pod - 1000, 1005, 1148 across
half a minute - so a camera host frozen at 1000 is genuinely camera
shaped, and the ranking trace will say whether it sees the racer at all.

Also here: a camera station's map defaults to the bottom-left corner
rather than the pod's dead centre. Centre is where a cabinet wanted it and
the worst place to put a panel on a picture. L4RADARPOS still overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:16:29 -05:00
CydandClaude Opus 5 af82cac5d2 A Live Cam keeps the map and drops the panes
A Live Cam host raced with the full glass cockpit over its view: five
instrument MFD panes with no pod behind them, compositing as black
rectangles across the viewscreen, and the map stood on end. Both are
cabinet artefacts. The panes belong to a pod; the map is portrait because
that is how the glass was bolted into the cabinet. A camera has no
cabinet, so it keeps the map - landscape - and nothing else. The arcade
got the same result by running its camera cabinet with -lc.

The role is picked in the lobby, so there is no -lc on the command line to
carry it to the renderers. Application::IsCameraStation joins
suppressGauges, set by the front end where the egg settles
owner_is_camera - before the renderers exist, since the single-binary race
loop builds a fresh application per race after the menu. Set on every
launch either way: a stale True would strip the cockpit off the next pod.

In the compositor: the five MFD panes are not built, which LayoutCockpit
and FillSplitMFD already tolerate, so no layout surgery; the map's glass
swaps to 432x324 while still honouring L4RADARSCALE and L4RADARPOS; and
the per-frame copy runs straight through rather than rotating 90 degrees
clockwise. The pane's source dimensions swap to match, so the buffer the
copy fills is the size the pane allocated - checked both ways.

Untested on screen, unlike the rest of this work: it only engages with a
lobby member present and that needs the second machine. What is checked is
that it builds, that the buffer arithmetic agrees, and that every pane
access on the glass-cockpit path is NULL-guarded.

L4MFDSPLIT=2 and =0 are left alone. The exploded view still opens five
empty MFD windows for a camera; it is a diagnostic view where that is
arguably the point, and its layout derefs the panes unguarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:56:58 -05:00
CydandClaude Opus 5 aa2ca0376a The camera HUD only reads the slots it filled
A Live Cam host launched into a real race took the process down with an
access violation. WER put the fault at image offset 0x76239, which with
this build's fixed base resolves against the shipped PDB to
CameraShipHUDRenderable::Execute - the dereference in the ranking loop.

Two defects, both older than Live Cam. playerRank is SPARSE: it is sized
for the racing players plus the camera players and filled by
playerBitmapIndex - 1, exactly as the constructor's own comment says. But
it was allocated with new[], which does not zero, and Execute walked it
densely to playerCount and dereferenced every slot. Any bitmap index that
nobody claimed was therefore uninitialised heap read as an int*.

Live Cam is simply the first thing that can leave a gap at the front. The
host takes bitmap index 1 and, being the camera, is not in the "Players"
group, so slot 0 was never claimed while playerCount was still 1 and the
loop still ran. The arcade never hit it because its camera cabinets sat
after the pods in the egg, so slot 0 belonged to a real racer and the
dense walk was accidentally safe.

So: clear the array on allocation, remember its real length as rankCount,
and iterate that while skipping the unclaimed slots. The playerCount
bound was wrong on its own terms too - a race whose bitmap indices run
past it would have missed the tail.

Everything before the crash worked on the first try, which is the other
half of the news: with a live racing peer the camera director builds, the
camera ship comes up and starts directing. The map-load hang that stopped
a camera host before was an artefact of it having no peers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:34:22 -05:00
CydandClaude Opus 5 1a5a4a220c A Live Cam host brings no vehicle
Picking Live Cam and then hosting still showed the host in the room with a
VTV and a colour, because the room row draws whatever loadout the member
published and nothing on the wire said the host had given up its grid
slot. The lobby now publishes a cam key with the rest of the member data,
carries it in MemberInfo, and the owner's row reads LIVE CAM where a
loadout would go.

The loadout itself still goes out unchanged. Live Cam is a role, not a
vehicle, so vh/cl/bd keep carrying what was picked and switching back to
Racer finds it all still there.

Only the owner's row shows it. The host is the one that writes the egg, so
the host's pick is the only one acted on; a member who set Live Cam is
still going to race, and its row goes on saying so. Painting every cam=1
row as LIVE CAM would have the room screen lying about the grid. Letting
members spectate too is a real feature - the same hostType=1 on their egg
entry, plus a guard that one racer is left - but it is not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:14:18 -05:00
CydandClaude Opus 5 487eaa3a36 The host can watch instead of racing
YOUR ROLE joins the setup screen at the head of the loadout column -
Racer or Live Cam - so the host still picks the track, the conditions and
the length, and still owns the lobby and marshals every pod, but can hand
its own grid slot back and watch. It rides the existing group machinery
(kRoles through GroupSize/GroupTitle/ItemName), so there is no new UI
code, and it persists in pilot.cfg beside the rest of the loadout.

Offered only when Steam is configured, the same condition the HOST and
JOIN buttons use: the role means nothing without a lobby to host.

In the egg the host's own entry becomes hostType=1 and vehicle=camera.
Those two are what RPRegistry::MakePlayer reads to build a camera
director rather than a racer, and they are all it takes - the arcade
selected its camera cabinet exactly this way, from egg data alone.

The pick is honoured only when other pods are actually in the race.
pilot.cfg remembers Live Cam, so someone who set it for a lobby race and
later launched a single-player one would otherwise hand the map load a
camera host with no peers, which is the one configuration known to hang
it. With no extras the choice is logged and ignored and the host races,
which is what pressing LAUNCH on a solo game meant anyway.

Still open: a camera host with real racing peers has never been tried, so
whether the map-load hang survives contact with a live race is the next
thing to find out; and nothing yet tells lobby members that the host is
spectating, so their room screen just shows one fewer car.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:01:10 -05:00
CydandClaude Opus 5 a5c1e0291c The camera bring-up says how far it got
A Live Cam station is selected entirely by egg data - hostType=1 on that
host's entry plus vehicle=camera - so no code path announces itself and a
station that fails to come up leaves a log that simply stops. RP412CAMLOG=1
traces the sequence: the stand-alone host and its type, the local player
node and game model, the interest-arena and interest-manager loads, the
registry choosing a director, the director making its camera ship, and the
launch handshake it waits on. Off by default.

What it found immediately: a camera host wedges inside
InterestManager::LoadMission - the map-entity load - and never returns.
Everything upstream is correct (the egg parses, hostType 1 is adopted,
gameModel reads 'camera'), and the same call with a racing egg passes
straight through to making the player and launching. Same map both times,
so it is the local host's TYPE that the map load cannot digest, not the
map. That is a defect to fix before a lobby has anything to switch on.

Also recorded while chasing it: a racing -egg run sits in application
state 11 with the low-priority queue never empty for its whole life, and
still simulates - so CheckLoad's no-console self-launch is not what
starts a stand-alone race. Worth knowing before trusting that path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:04:26 -05:00
CydandClaude Opus 5 ef4a5e501d The wireframe stands against black
Alt+W held the sky dome solid so the edges had something to read
against. Looking at it, the dome was the problem: lit, fogged and
filling the upper half, it washed out everything behind the near
geometry. A whole structure over the track in the middle distance was
invisible until the sky came away.

So the view now clears to BLACK under wireframe and the sky pass is
skipped entirely. Skipping costs nothing - the dome only ever covers
pixels the clear already owns - and it removes the two fill-mode
brackets that used to wrap the pass, so the frame is simpler than it
was. Both changes are conditional on gWireframe; the solid path clears
to the fog colour and draws its sky exactly as before.

Fog still applies to the edges, which is why the middle distance tints
toward the fog colour instead of staying bright. That is depth
information, so it stays.

environ.ini's template and BUILD.md both said the sky stayed solid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:50:44 -05:00
CydandClaude Opus 5 0f4592c39a The wireframe key works and Steam is optional
Alt+W had been a no-op since the 2007 DPL->Direct3D port, which stubbed
DPLToggleWireframe along with every other dpl_ call. D3D9 has no global
wireframe property, so the toggle now records the intent in gWireframe
and ExecuteImplementation applies D3DRS_FILLMODE once per frame - which
also means it re-asserts itself after a device Reset. The sky dome and
the 2D pass are held solid: a wireframed dome buries the geometry you
turned the key on to look at, and the gunsight would otherwise come out
as bare diagonals.

Proving it needed a working copy, and that turned up a separate problem.
steam_api.dll was a hard import, so a machine without it died at load
time with 0xC0000135 - before a window, before a log line. It is now
delay-loaded, and because delay loading only moves that failure to the
first call, every Steam path is gated on
SteamNetTransport_ClientLibraryPresent(): Install and the two lobby
entries, with everything else downstream of one of them. Absent DLL
boots and races; absent DLL with RP412STEAM=1 logs the reason and stays
on TCP; DLL present brings the transport up exactly as before.

The documentation now says which debug keys are real. Five of the seven
are still 2007 stubs and always have been, so they are named as inert in
environ.ini's template and at the dispatch site - that beats letting the
next person debug a dead key, which is how this started. BUILD.md gains
a debug-key table, the delay-load contract for anyone adding a Steam
call site, and the environ.ini BOM trap that silently reverts
L4CONTROLS to KEYBOARD and then fail-fasts for want of a pod mapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 11:15:11 -05:00
CydandClaude Fable 5 34abf40e7f Fifty hertz is the physics
RP412PHYSICSHZ defaults to 50: the simulation advances in fixed 20 ms
steps whatever the display does, and every machine plays the same race.
The proof preceded the promotion - a scripted lap with a crash, a burn,
a tumble and two respawns runs bit-identical at 30, 60 and 144 fps, and
identical runs reproduce exactly, neither of which was ever true of
this engine at any frame rate.

Fifty because it is exact on the engine's millisecond clock (a rate
like 60 quietly becomes 17 ms steps wearing the wrong name), and
because its settled hover ride height measured closest to the
frame-coupled physics the game has always run - the least change of
feel for the most change of correctness. The pods' 25 and the smoother
100 stay one line away for the play testers, and 0 keeps the original
frame-coupled behaviour for comparison, where the frame rate is part of
the simulation.

Carried-over environ files do not mention the option, so existing
testers get 50 on their next build and rpl4.log names both the option
they have not heard of and the mode every launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:05:45 -05:00
CydandClaude Fable 5 67d57452ba The podium hold asks whether there is a podium
RP412PODIUM=0 promises "straight to the results" and delivered eleven
seconds of black screen first: the winners' circle hold was applied
unconditionally at the buzzer, and the timer never asked whether there
was a stand to hold the mission open FOR. Found by the -egg harness,
which could reach the end of a race unattended and noticed the promise
not being kept.

With the podium off, the hold now stands aside and the base 3-second
race fade runs the show. With it on, RP412PODIUMHOLD tunes the length
(1-60 seconds, default the same 11 as always) - eleven seconds of one
parked pod is a long look in single player, and that is now a choice
rather than a constant. The decision point logs which path it took and
the value it applied, verified all three ways:

  podium off - the race fade stands (3s) and the results come straight up
  holding the mission open 5s for the stand
  holding the mission open 11s for the stand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:04:18 -05:00
CydandClaude Fable 5 c479cd48e9 The death cycle is deterministic
A scripted lap - full throttle, a steer, a crash at speed, the burn,
the tumble, death, respawn, a second crash, a second respawn - now
plays out bit-identical between identical runs and across 30 and 144
fps under RP412PHYSICSHZ. Ninety of ninety samples exact in the repro
pair, sixty of sixty across frame rates, max difference 0.000000. The
crash was already deterministic; this makes the RECOVERY deterministic,
and it took five pieces, every one found by measurement:

- The respawn teleport moves onto the vehicle's own step grid.
  VTV::ScheduleRespawn stores it and BeginStep applies it at the first
  step whose clock reaches the due time, teleport and turn-toward-goal
  together, because the goal flip reads the POST-reset heading. The old
  path applied the Reset from the event queue, which runs on wall
  clock, and identical runs diverged on the first step after the pod
  stood back up.

- The handler keeps its Reset for the FIRST spawn of a mission, gated
  by a flag rather than by mode. A Mover is born in StasisState and the
  first Reset is what wakes it; gating on "is fixed stepping on" - the
  first attempt - skipped that wake-up and parked the pod frozen at its
  spawn point for an entire race. The scripted-lap harness caught it in
  one run.

- The vehicle stamps its own death clock, at the single site that sets
  BurningState - inside the step machinery, which is why the crash
  measured exact. The schedule anchors to the death, the last
  step-exact event in the chain.

- The due time is quantized to a half-second grid ANCHORED AT THE
  DEATH. The instrument showed the naive anchor was four seconds stale
  by scheduling time: the fry chain reposts itself at wall-clock
  Now()+2.0 and the drop-zone reply lands about five sim-seconds after
  death, jittered by a few steps of queue timing. Firing "next step"
  inherited that jitter whole. Rounding up to the next half-second
  after the death puts hundredths of jitter against tenths of headroom,
  so every run lands in the same cell - and the felt delay stays the
  six-ish seconds it has always been.

- The out-of-world tumble draws from a per-vehicle random stream seeded
  by creation order. The global Random is shared with the frame loop's
  consumers - particles, mostly - so its position at the moment a
  burning pod drew from it depended on how many frames had rendered,
  and the kick went straight into angular velocity. Last wall-clocked
  input in the whole death cycle.

The respawn scheduling and firing log under RP412PHYSTRACE in
run-comparable terms - pad identity, due offset, lateness - because
those lines are what cracked this: "due in -4.06 sim-s" said more in
one glance than three rounds of hypothesis.

Still outside the claim: multi-vehicle contact (DynamicBounce writes
the victim's state from the striker's step) and network play. That is
the lockstep frontier, and it now has a harness waiting for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:42:04 -05:00
CydandClaude Fable 5 af52476603 The pod can drive a scripted lap
RP412INPUTSCRIPT names a timeline file - one row per change, throttle,
stick X and Y, pedals, held until the next row's time - and the pod
drives it instead of listening to the controls. Times are SIMULATION
seconds from the green light, evaluated per step in the one place every
mapper funnels through (VTVControlsMapper::InterpretControls), so the
same script is the same lap at any frame rate. Rows hold rather than
interpolate on purpose: interpolation would sample differently at
different physics rates, and nothing on this path is allowed to.

The script shares the green-light anchor with RP412PHYSTRACE - its
clock starts at the instant the vehicle is stopped dead - because a
timeline that starts when the loader happens to finish is a different
lap every run.

A race is only deterministic if somebody DRIVES it, and a human cannot
drive the same lap twice. The first scripted lap - full throttle, a
steer, a crash at speed - earned the harness immediately:

- The drive, the crash, the death and the respawn teleport were all
  BIT-EXACT between identical runs, through t=13.5. Collisions with
  world geometry and the damage path are step-deterministic, which is
  better news than the code reading suggested.

- The first divergence is the step AFTER the respawn: the DropZoneReply
  that stands a dead pod back up is posted at wall-clock Now()+1.0
  (RPPLAYER.cpp), so the reset lands on a different sim step every run
  and everything after is time-shifted. The crash is deterministic; the
  RECOVERY is not. That is the next fix, and it is now a measurement,
  not a theory.

Values are clamped at load, once and visibly, so a script asking for
throttle 2.0 cannot trip the mapper's own range Verifies. Off unless
the environment names a file; it would be a cheat in a real race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:03:03 -05:00
CydandClaude Fable 5 c2e2df1dce Every knob the code reads is in the documented file
A sweep of every getenv() in the tree against the environ.ini template
found five options the code answers to that the file never mentioned:
the physics trace, the spawn-zone pin, the gauge profiler, the renderer
diagnostic and the joystick-scan log. They were deliberately env-only
once - scaffolding, not settings - but scaffolding that cannot be found
is scaffolding that gets rebuilt, and RP412RENDERDIAG had already been
forgotten thoroughly enough that this sweep is what rediscovered it.

They get their own section, between the shipped configuration and the
optional extras, with the header saying what they are for: making a
claim about the game testable instead of arguable. All five ship
commented out, cost nothing when off, and none belongs in a real race.

The sweep now closes empty - there is no environment variable the game
reads that the file does not document - and the mention-check keeps it
honest from here: a build that grows a new option names it in rpl4.log
for every carried-over file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 20:36:14 -05:00
CydandClaude Fable 5 9643e02198 The physics rate is the play testers' question now
RP412PHYSICSHZ documented in environ.ini, under TARGETFPS where it
belongs, with the three rates worth testing: 25, the arcade pods' rate
and the step the original handling was tuned against; 50, the middle
road; 100, the smoothest contact response. All three divide the
engine's millisecond clock exactly and all three are verified
bit-identical across frame rates.

The entry says what to feel for - hover bounce, wall hits, how the pod
takes a hill crest - and asks for the rate alongside the verdict,
because whichever one the testers pick becomes the canonical physics
for PC and pods alike. It ships commented out: the default stays the
frame-coupled game everyone knows until that decision is made on
purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:20:03 -05:00
CydandClaude Fable 5 2a679ab571 A race can be replayed exactly, so physics claims can be tested
Three pieces of harness, all env-gated and inert in normal play, that
turn "do two frame rates play the same race?" from an argument into a
number:

- RP412PHYSTRACE=1 samples the player vehicle's position on the
  SIMULATION's own clock - the vehicle's lastPerformance, which advances
  in whole fixed steps - so two runs sample at identical step counts and
  their traces compare exactly. Frame-time sampling compares different
  instants and calls the difference physics; an earlier version of this
  trace did exactly that, and its noise was chased as if it were drift.
  At the green light it stops the vehicle dead, because the pod
  simulates on its pad while the mission loads and a load is never the
  same length twice: two runs reached the start 776 and 599 steps in,
  same position, different velocity.

- RP412SPAWNZONE pins which drop zone is tried first. The pick is
  Random(), and Random() is seeded - but a seed only repeats a run if
  the same NUMBER of draws precedes the pick, and that count rides on
  load timing. Same seed, different pad, incomparable traces. Pinned,
  the zone is tried first and falls back to the random walk if taken,
  so it cannot wedge and changes nothing unless set.

- The trace prints the global step counter, which is what caught the
  force-accumulator bug: the position columns can look plausible while
  the step column says the physics ran a different number of times.

With these three and RANDOM= (which already existed), a race is
repeatable to the bit, and the determinism matrix - rates by frame
rates by repeats, run as parallel sandboxed instances - is a regression
suite: any mismatch in any cell is a real bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:20:03 -05:00
CydandClaude Fable 5 5e47987508 The simulation steps at a fixed rate
RP412PHYSICSHZ names a rate and the simulation advances in whole steps
of exactly that size on every machine, whatever the display does. 0 -
the default, and the shipped behaviour until the play testers have
spoken - is the game as it has always run: the step is however long the
last frame took, which makes the frame rate part of the physics.
Measured over two seconds of free fall, a 30 fps machine's pod fell
three times further than a 144 fps machine's. Two players on the same
track were not in the same gravity.

With a rate set, the same race is bit-identical across frame rates:
30, 60 and 144 fps produce the same trajectory to the last printed
digit, and identical runs reproduce exactly - which was never true of
this engine before, at any frame rate.

It took three pieces, and every one was found by measuring, not by
reading:

- Simulation::PerformTo turns lastPerformance into the accumulator it
  always secretly was: whole steps while time remains, the remainder
  carried to the next frame. Watchers and update records stay once per
  frame - stepping is physics, watching is I/O.

- Entity::PerformAndWatch interleaves subsystems and entity per STEP.
  The frame loop ran all subsystems to the frame boundary and then the
  entity, indistinguishable from correct at one step per frame - which
  is why thirty years of code never noticed - and wrong at two: the
  thrusters raycast twice from a vehicle that had not moved, and the
  hover spring fired twice on one stale height sample. The subsystems
  are also snapped onto their entity's step grid; each Simulation
  anchors its grid at its own creation time, a per-run phase no seed
  could pin.

- Mover::BeginStep clears the force accumulator per step. It was
  cleared once per frame while the thrusters ADD per step, so step two
  of a frame integrated step one's thrust again - and how many steps a
  frame holds rides on wall-clock jitter, which is why identical
  configs measured a quarter-metre apart. The quaternion renormalise
  counts steps now too, for the same reason.

The catch-up clamp is a quarter second of simulation whatever the rate,
so a machine that cannot keep up slows down rather than seizing, and
does so identically everywhere. The engine's clock counts milliseconds,
so rates that do not divide 1000 - 60 among them - quietly run at the
neighbouring millisecond step; the log now says so and names the exact
ones. 25, 50 and 100 are exact, and all three are verified bit-identical
across frame rates and across runs.

Verified for a single vehicle settling under gravity and hover. Driving,
collisions and the network are the next frontiers, in that order: the
collision path writes the victim's state with wall-clock stamps and a
hard-coded 0.1 s bounce, which single-player survives and lockstep will
not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:19:43 -05:00
CydandClaude Opus 5 74aa5ae98d A hand-fed egg can run a whole race
'-egg' skips the menu and drops straight into a mission, which is the
developer shortcut - and it installs no console, so nothing ever ends the
race. Everything after the chequered flag was therefore unreachable from
the command line: the buzzer, the fade, the winners' circle, the
teardown. All of it could only be exercised by hand through the menu.

RP412MISSIONSECONDS now marshals a hand-fed run as well, so a whole race
plays out unattended. That is the difference between a shortcut that can
be watched and one that can be TESTED, and it immediately earned itself:
it caught RP412PODIUM=0 holding the mission open for its full eleven
seconds with the podium switched off. The environ file promises "straight
to the results"; the hold is applied without asking whether the podium is
on, so what you actually get is the same wait against a black screen.
That one is not fixed here - it wants a decision about the hold's length
as well - but it is now reproducible in one command.

pack-dist keeps frontend.egg for the same reason. It is written on launch
and holds the menu's last selection, so it is what lets '-egg
frontend.egg' drop back into the track under test - and a repack was
wiping it, which turns the next run into a zero-byte file and an abort on
"no map in egg".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:13:16 -05:00
CydandClaude Opus 5 1dd40be0a3 The map draws on every step of the rate wheel
The renderer walks a sixteen-step rate wheel: one step per full pass over
the gauge list, shifted right each pass and reset at the bottom. A gauge
redraws only on the step its configured rate names, so the map - on one
step - waited a whole turn of the wheel however cheap its redraw was.

With the frame budget fixed the wheel turns about fifty times a second
and one-in-sixteen would be tolerable. It is still the wrong shape for
the map: the thing a pilot reads to navigate should not be the display
that updates least often, and RP412MAPRATE says how many of the sixteen
steps it draws on. Sixteen by default, one for the old data-driven
behaviour. Each extra step costs one gauge's redraw against a pass that
runs ninety of them, which measured as nothing.

The write has to be QUALIFIED, and that is worth recording because it
cost hours. GPS's constructor takes its rate as a parameter also called
'rate', which shadows the inherited Gauge::rate for the whole body - so a
bare assignment sets the parameter and leaves the member holding whatever
the gauge data asked for. oldRate is not shadowed, so it took the value,
and the pair then disagreed: rate=2000, old=ffff. That looked exactly
like something writing the member from outside, and there is no such
writer - Gauge touches rate in three places, none of which can produce
that pair. A hardware write-watch on the member settled it by reporting
an address on the STACK.

Also here, the terrain-arrival work on the map background. It draws one
placement into the cached picture when the static bounds are unchanged,
and rebuilds the whole thing only when they move - the bounds set the
scale, and the scale is what everything already on the picture was drawn
at. It is honest to say this fires rarely: the logs show terrain arriving
in one burst at mission load, not streaming in as you drive, so the
incremental path is mostly insurance. What it does close is real, though
- departures now order a rebuild. Nothing listened for those before, and
they had been swept up by the rebuild the next ARRIVAL ordered, which on
a track whose terrain all arrives at load is a rebuild that never comes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:13:03 -05:00
CydandClaude Opus 5 b7b2c3b148 The GPU transforms the vertices
The cockpit displays were updating every two to three seconds while the
3D view held a perfectly smooth 55 fps. This is why, and it is one line.

Every device was created D3DCREATE_SOFTWARE_VERTEXPROCESSING - every
vertex on the track transformed and lit on the CPU, on the one core this
game uses for everything. That was not a choice when the engine was
written; there was no hardware to hand it to. The error message beneath
the call still says "Couldn't create HARDWARE_VERTEXPROCESSING device",
so the flag was changed at some point and the message left behind.

Measured on the biggest track, 1920x1080:

  software   foreground 17.2 ms   background 1.2 ms   2.4 gauge passes/s
  hardware   foreground  0.2 ms   background 17.9 ms  50.0 gauge passes/s

The frame loop runs the foreground and then spends whatever is LEFT on
the background gauge work. A foreground costing 17.2 ms of an 18 ms frame
leaves nothing, so the gauge loop got the single pass it is guaranteed
and no more. A pass needs about twenty steps - eighteen gauges and three
display copies - so the cockpit ran at two passes a second, and since the
renderer walks a sixteen-step rate wheel, a gauge on one step redrew once
per SIXTEEN of those. Three seconds. The map, the clock, the boost gauge
and the sim still running after the fade to black were all that one
number.

Hardware T&L is now the default and sw is the way back. Fixed-function
lighting and fog are not bit-identical between the old software path and
a driver, so the escape hatch stays - but the picture was checked against
both and the difference is not the one worth defending. A cockpit whose
instruments update twice a second is. It falls back to software by itself
if the adapter has no hardware T&L.

The instruments that found it stay in, because nothing about this was
visible from outside:

- FrameSplit, under RP412GAUGEDIAG, reports foreground against background
  against whole frame. APPMGR has computed those four timestamps every
  frame since forever and never reported one of them; it would have
  pointed here on the first day.
- FrameDiag reports frames per second on the same window, so the gauge
  sweep rate can be read against the frame rate rather than guessed at.
- ProfileReport, which already existed and was only reachable through F11
  on the RIO controls mapper - not the mapper a desktop player runs, so
  in practice unreachable - now runs on a timer under RP412GAUGEPROFILE.
  Its per-gauge line gains the rate mask and tier, which is what names a
  display as one-in-sixteen rather than merely slow.
- The winners' circle logs what its exterior and name-plate rebuilds
  cost, since nothing else runs while they do.

RP412VSYNC is here too, and it is honest about itself: presenting
IMMEDIATE was measured and made no difference to the frame budget,
because the frame was full of work rather than waiting. It stays as a
latency-against-tearing preference, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:12:34 -05:00
CydandClaude Opus 5 827c5b295b The controls answer only while the game is the window in front
Testers taking notes in another window were flying the pod while they
typed. RP412INPUTFOCUS=1 is the new default; 0 restores the old
behaviour.

The pod was the only thing running on its cabinet, so the virtual RIO
reads the key state directly rather than waiting on the message pump.
That is the right call for latency and it is why the pedals feel like
pedals - but a direct read is a read of the WHOLE keyboard, whatever has
focus. On a cabinet that distinction did not exist. On a desktop it is
the difference between writing a bug report and steering into a wall
while you write it.

One choke point does the whole job: PadRIO::PollInputs is where the
keyboard, the XInput pad and the DirectInput stick are all read, so a
single flag covers the three of them. The joystick needs no change of
its own - unfocused the resolve block is skipped, every device slot
stays at -1, and the button, hat and axis loops find no device and read
released on their own. It is opened DISCL_BACKGROUND on purpose, or it
would stop answering the moment a cockpit pane took focus, so declining
to poll it is what makes it go quiet.

Each source reads as RELEASED rather than the poll returning early, and
that is the part worth keeping: bail out instead and whatever was held
at the moment you switched away stays held until you come back, which is
the stuck throttle this is meant to prevent rather than cause. Reading
released lets the diffs already in there turn it into proper release
events.

The throttle accumulator is the deliberate exception. It is the pod's
one sticky axis and it integrates what the controls ask for, so controls
asking for nothing simply stop moving it - you come back to the speed
you left rather than to a dead stop.

Focus is tested per PROCESS, not against one window handle. The cockpit
is a shell full of child panes, the exploded view is six windows of its
own and the plasma glass another; matching a single HWND would drop the
controls the moment somebody clicked an MFD.

Real RIO cockpit hardware is untouched - this is the keyboard, pad and
joystick path only. The volume and bass keys in L4CTRL were already
gated this way, unconditionally, which is where the idiom comes from.

On by default because the alternative is every tester editing a file
before the fix reaches them: an environ.ini written by an older build
does not carry the line, so the built-in default is what they get. The
log says which way it is set, and the option-mention check names it as
one they have not heard of.

Verified against the built exe both ways: a fresh run writes the
documented default and applies 14 settings where it applied 13, and a
file with the line removed reports exactly one unknown option and falls
back to focus-gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:24:40 -05:00
CydandClaude Opus 5 4410febd4c The particle engine hands the device back at the end of the race
A fresh renderer is built per mission, and the particle engine's vertex
buffer is D3DPOOL_DEFAULT with a texture to match - both bound to the
device that made them. Initialize overwrote the two pointers with the
new device's resources without releasing the old ones, so the old device
kept a reference from resources nothing could reach any more.
~DPLRenderer's SAFE_RELEASE(mDevice) therefore never took it to zero.

Every race left a whole live device behind it - back buffer, depth
buffer and all, at whatever the render target is, which on the tester's
machine is 2560x1440. The next race's Initialize was the only thing that
ever let one go, so quitting from the front end let it go never.

Measured rather than assumed, with a standalone test using the same pool
and usage: release the device with the buffer outstanding and it reports
1 reference left, still alive. Release the buffer first and it reports 0.

Three parts to it:

- Destroy is null-safe now, and clears what it drops. It was neither,
  and it runs on the device-lost path AHEAD OF A RESET - so a texture
  that never loaded, which a missing VIDEO\particles.png is enough to
  cause, took the Reset down with it. A released pointer left in place
  is a dangling one the moment anything looks again.

- Initialize calls it first. The device-lost path already released
  before re-initialising; this is the same contract for the case where
  the device is not lost but REPLACED, which is what a new race is.

- ~DPLRenderer calls it before releasing the device, next to the texture
  cache flush that is there for exactly this reason and had missed this
  one. The device now dies with the mission that made it.

Destroy clearing mDevice is what makes the gap between it and the next
Initialize safe: the paint paths already test that pointer before they
touch anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:56:13 -05:00
CydandClaude Opus 5 3482de5147 The renderer stops when it cannot get a device
PostQuitMessage is a message, not a return. The fallback CreateDevice
posted one and then carried straight on into the Clear below it, so a
machine that could not give us the mode we asked for dereferenced a
device that was never created and died on an access violation instead of
saying what had happened. The quit message it had just posted would not
be read until someone pumped the queue, which by then nobody would.

Both attempts are now judged once, and the line names the size that was
REFUSED. That is the question this failure raises rather than an
incidental detail: the back buffer is the requested size windowed as
well as full-screen since the render target went back to being the size
that was asked for, so a request the adapter will not meet is the first
thing to look at.

  DPLRenderer: no D3D device for a 2560x1440 windowed back buffer
  (hr=0x8876086c) - giving up

mDevice was also never in the initialiser list, so until CreateDevice
wrote it the member held whatever was on the stack - and the
mPrimaryIndex bail-out above has always returned through that into the
destructor's SAFE_RELEASE. It is nulled before either exit can be taken.

Found while reading the constructor for an unrelated crash, which turned
out to be on another thread. This one is latent - no report of it yet -
but it is the difference between a tester sending a dump and a tester
sending a line that says what to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:55:52 -05:00
CydandClaude Opus 5 de9a163f37 The lamp worker clears its factory cache before the apartment goes
A tester's second race died on an access violation with nothing in the
log after the monitor setup, which is only where the MAIN thread had got
to - the fault was on another thread entirely, and the crash filter
writes no line of its own, so the truncation named the wrong suspect.

The dump named the right one. Thread 19, inside the Dynamic Lighting
worker, calling through a vtable at an address that lm shows falling in
the GAP between two loaded modules - an unloaded DLL, not corruption:

  rpl4opt!...ILampArrayStatics::GetDeviceSelector+0x23
    [inlined in rpl4opt!`anonymous namespace'::Worker+0x121]
  call dword ptr [eax+18h]  ds:002b:6fd72eb8=????????

C++/WinRT caches an activation factory the first time a type is used and
that cache is PROCESS-wide. The apartment is not: the worker init'd one,
asked LampArray for its device selector, and exited without clearing the
cache, so COM tore the apartment down at thread detach and unloaded the
Lights server with it - nothing else in the process held a reference.
The cached pointer stayed, aimed at an address range that no longer had
a module in it. The next race started a fresh worker, which found the
cache populated, did not re-activate, and called straight through it.

So this could only ever fire on the second race, and only because the
worker is started per race - KeyLight_Start() runs from the PadRIO
constructor. A machine with no Dynamic Lighting keyboard is not spared:
asking for the device selector is enough to populate the cache, and the
tester's log says plainly that nothing was found.

The guard is RAII and declared BEFORE the DeviceWatcher, so it runs LAST
- the watcher's COM release still happens inside a live apartment. It
also covers the early return when Dynamic Lighting is unavailable, which
was the other way out of the function.

Confirmed both directions with a standalone reproducer of the same
pattern - worker thread, init_apartment, GetDeviceSelector, exit, thrice.
As shipped it dies on pass 2 with 0xC0000005, matching the dump. With
this, three passes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:55:21 -05:00
CydandClaude Opus 5 aa294071f9 The render target is the size that was asked for
A crosshair off-centre on the second race, and underneath it every race
after the first was a different race.

Windowed, BackBufferWidth/Height were left at zero, so D3D sized the back
buffer to the device window's client area at the moment the device was
created. Everything downstream is built from the size we ASKED for
instead - the projection matrix takes its aspect from it, the reticle is
centred on it - so a window that was not exactly that size rendered at
the wrong shape and got rescaled on the way to the viewscreen pane.

-fit decided which window that was, and it decided differently for the
first mission than for the rest. Its borderless full-monitor placement
lived only in SVGA16's cockpit build, which does not run until a mission
starts - just after that mission has built its device. So race one was
set up against a still-bordered client and every race after it against
the borderless monitor. On a 3440x1440 panel that is a 1.778 image drawn
across a 2.389 target, against 1.816 the first time.

That is not a cosmetic difference. The simulation advances on wall-clock
deltas, so frame cost is physics: two render targets that size and scale
differently are two different races from one lobby and one set of
settings. A racing sim does not get to do that.

So: the back buffer is the requested size windowed as well as
full-screen, and -fit takes its shape at startup rather than four
screens later. SVGA16 still applies the same rect when it builds the
cockpit - that call is now a no-op instead of a change, which is the
point. The first lobby also stops being the only one with a title bar.

The reticle keeps its own share of the blame and is fixed on its own
terms, so it cannot drift again if a target ever does move:

- It is measured against the viewport at draw time and rebuilt when that
  changes, rather than baked once in the constructor from the renderer's
  requested size. One GetViewport a frame, no rewrite until it moves.
- The arms are quads, not lines. D3D9 line rasterisation follows the
  diamond-exit rule and is free to differ between drivers on a segment
  running along a pixel boundary, which is how a crosshair loses one
  pair of arms and keeps the other - and full-screen, where both
  dimensions are usually even and both pairs sit on boundaries, how it
  can lose the lot.
- Arm thickness follows the target rather than being one pixel whatever
  the resolution. One pixel is a width the presentation can throw away
  in a downscale, and it was a hairline at 1440 next to the pod's line
  at 480.

The log names the viewport, the requested size and where the crosshair
landed, and says TARGET DISAGREES with both aspects when the first two
do not match - so the next report of this arrives with its own diagnosis.

Window creation cleaned up while in there: it computed a style and then
handed CreateWindowEx a literal WS_OVERLAPPEDWINDOW regardless, so the
full-screen path never got the WS_POPUP it thought it was asking for.
Borderless modes are now born borderless instead of being restyled a
moment after. The requested size also goes through AdjustWindowRect,
because -res is a render size and was being used as the OUTER rectangle
with the chrome taken out of the middle - which is how -res 640 480 came
to present into a 624x441 client and started all of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:19:39 -05:00
CydandClaude Opus 5 769407ca24 The mode lamps follow the mode
Selecting NOV, STD, VET or EXP on the Upper Right MFD lit nothing and
dimmed nothing. Two separate faults had to line up for that.

SetControlsMode announced the change as
L4VTVControlsMapper::NotifyOfControlModeChange - explicitly qualified,
which suppresses the virtual call and lands on the base class no-op. The
code that drives the four lamps is VTVRIOMapper's override, so a mode
change never reached it. Its neighbour has always gone out unqualified
from VTVControlsMapper::SetConfigurationState, which is why the
configuration lamps behaved and these did not.

previousControlMode is the lamp the next change dims, and nothing wrote
it after construction set it to -1. Even once the call arrived, the dim
step would have matched nothing and the panel would have accumulated
lamps rather than following the selection.

The one call that did dispatch is the one in VTVRIOMapper's own
constructor, where the vtable is already the derived one - which is why
NOV lit at the start and then nothing ever moved.

B / S / V / M are gone from the Thrustmaster mapper's key handler. The
driving mode is a panel decision, four buttons carrying the lamps that
say which one you are in, and a bare letter key changing it behind the
player's back is not that. It reads worse in 4.12 than it ever did in
the pod: the whole letter board is the MFD banks now, so on that path
those four letters would have fired their bank button and silently
changed the driving mode as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:21:00 -05:00
Cyd 28790de901 Every VTV card carries its performance
Acceleration, top speed, impact speed, armor, boosts, chutes and each
tool's charges, decoded from the resource file rather than transcribed.

GameModel is a fixed 180-byte block: mass at +0, drag at +36, acceleration
at +64, impact speed at +100. Top speed is NOT stored - it is terminal
velocity, acceleration over drag, which is why it lands on the round
numbers the arcade quoted: 6.0/0.060 is Mule's 360 kph, 5.5/0.060 is
Bull's 330. Armor is a float in the DamageZones record past the "dz_vtv"
name, at +35.

Boosts and chutes come from the subsystem stream, which is now walked
properly: a record is name[32], a type id, its own length, and the charge
count sixteen bytes on. That replaces a regex that hunted for printable
names in the float tails and guessed where each one started - the new walk
matches every vehicle's declared subsystem count exactly.

Neutrino's two derived figures are withheld and the card says why. Its
drag is 0.008 against 0.052 on every other Lepton and its impact speed is
uninitialised, so the engine would give it a 2880 kph top speed. The data
is wrong, not the reading.
2026-08-07 21:04:08 -05:00
Cyd d35af59136 The joystick wizard works out the shape of your pedals
You are never asked what you own. Two controls cannot simply be watched,
so they are asked for differently.

Yaw is asked for twice, right then left, and which axis answers is the
measurement. The same axis both times is one control covering both
directions - a twist grip, a rudder bar, pedals the driver has already
mixed - and binds to the signed Pedals axis. Two different axes are two
real pedals, one per foot, which is what the pod had, so they bind to the
pod's own LeftPedal/RightPedal pair and the game does the mixing: both at
once then does what both at once did in the pod.

The throttle is zeroed first. A lever sits wherever it was last left,
possibly hard against the stop that reads +1, so watching it move says
nothing about which end means power. Close it, press SPACE, then open it,
and the direction it travels from a known idle is the direction that
means throttle.

CONTROLS.md, the handbook and the packaged README say all of this, and
joyconfig.bat's own header no longer promises "rudder-pedal setup" when
racing pedals work too.
2026-08-07 16:31:40 -05:00
CydandClaude Opus 5 6e829f815e Doors run on the mission clock instead of being replicated
A door's position was an integrated countdown owned by whichever machine
the map-entity round-robin happened to deal it to. That left doors one
one-way-latency behind on every other machine, re-acquired at each state
change; drifting permanently on any frame hitch over a second, which the
old code dropped outright rather than clamping; and frozen mid-cycle,
collision volumes included, when their owning peer left, since ownership
transfer is not implemented.

Doors are clockwork with no inputs, and door/VTV physics is already local
pointer access - VTV::ProcessCollision reads door->currentVelocity off
the local object and the crush test is local VTV state - so a door does
not need an owner at all. Door::SlideDoor is now a phase function of
Application::GetMissionElapsed(), anchored so phase zero reproduces the
original DefaultState entry: fully open, starting to close. Every host
builds its own doorframe out of the map stream as a HermitInstance, the
instance kind DynamicEntityCreation does not broadcast, so nothing is
sent, nothing is received, and a peer leaving takes no doors with it.

Verified against a copy of the old integrator at 25fps with the real 10s
travel / 3s dead timings: identical 26s cycle, a constant one-frame
offset, and no drift across a 3s stall that leaves the old code
permanently 3 seconds out of phase.

Also fixes a latent bug found on the way: UpdateManager iterates the
dynamic master socket, which holds Independant and Hermit instances as
well as masters, and handed all of them to EntityUpdateReplicants, which
asserts MasterInstance.

Doorframes no longer consume a slot in the map-entity ownership cursor,
which shifts who owns every map entity dealt after them, so this cannot
share a session with an older build. The lobby publishes a simulation
revision and refuses to launch a mixed room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:25:42 -05:00
Cyd 0e39075a20 The track plans are the map screen's drawing again
With the models resolving properly there is nothing left to infer, so the
inference goes. Out: the gate tracing, the route/field test, the collapse
of each wall bar to a centreline, the dropping of "isolated" placements.
Every one of those existed to make sense of a track that appeared to be
one model repeated, and it is not.

What is left is what the map screen does. Every placement, its model's
GaugeImage looked up by name, laid down rotated and positioned, at the
LOD the engine would pick for that scale, in the palette the display is
configured with, on the display's own black. So the walls are grey
because index 51 is grey and the score zones are amber because sc50 and
sc500a are drawn in 56 - nothing on the page is a styling choice.

Also right way round now: +X runs right and +Z up, matching the engine and
the map viewer. The nine console pictures are still here, below each
drawing where they exist, captioned as mirrored - they are illustrations
rather than screenshots, and the page no longer quietly adopts their
handedness for everything else.
2026-08-07 15:07:36 -05:00
Cyd 9f0a77cc16 The tracks were never built from one model
A map record is an Entity::MakeMessage (MUNGA/ENTITY3.h): classToCreate,
owningPlayerID, resourceID, instanceFlags, localOrigin. The origin ends
the 76-byte case, which puts classToCreate at +28, resourceID at +40 and
instanceFlags at +44.

I had been reading +44. That is instanceFlags, and it is 524 on every
scenery record - and 524 happens to be cn3's GaugeImage. So every track
resolved to cn3 repeated a few hundred times, consistently and wrongly,
and every conclusion drawn from that followed: the "one wall bar with a
gate", the ticks, the claim that the LOD machinery is never exercised.

The id at +40 varies per placement. The tracks use cn1, cn3, cn4, cn5,
cn7, br1, br3, ft1, cq1, cq2, md3, md4, the pits, and the score zones
sc50/sc50a/sc500a that gave the amber boxes at each end. A record names
the model's Model List; the GaugeImage is filed under the same model
name, so the name is the join - and models with no gauge image (oao,
snAwork, pz1) are skipped here exactly as DrawStatic skips them.

Found by following the map loader: InterestManager::LoadMapStream reads
the stream as MakeMessages and names the map entity classes, one of which
is 95 in these records - CulturalIconClassID.
2026-08-07 14:45:20 -05:00
Cyd 86bf6a934b Map instance records carry their own length
They are not a fixed 76 bytes. The first int of each record is its length,
and while most placements are 76, every track also has eight of 140, two
of 80 and one of 336 - 560 on Paingod's. Striding a fixed 76 landed
mid-record on those, and hunting forward for the next plausible
quaternion then locked onto arbitrary bytes: that is where the impossible
class ids came from, and the "resource id" 1065353216, which is
0x3F800000 - float 1.0.

Reading the length instead, all eighteen tracks parse to exactly their
declared instance count with no bytes left over. That is the check that
was missing before.

It does not change what gets drawn, because the extra records were never
drawable anyway. It does mean the parse is no longer guessing.
2026-08-07 13:23:37 -05:00
Cyd 4979193528 The viewer uses the engine's projection, not the console's
It was drawing the map mirrored. The X flip came from the setup console's
picture of Brewer's Bane, which is an illustration and not a screenshot -
the game does not draw it that way round.

What the game does: L4GaugeImagePrimitive::Draw plots
MoveToAbsolute(dest->x, dest->z), so the screen axes are view-space X and
Z, and the graphics view's origin is bottom left with Y increasing upward
- BackgroundLine draws endpoints.bottomLeft to endpoints.topRight, and
the port's zero-degree blit is documented with the origin in the bottom
left corner. So +X runs right and +Z runs up.

Also added the heading the real display has. NavDisplay centres on the
vehicle and turns with it, inverting the viewer's transform and taking
yaw only unless rockAndRoll is set; the viewer defaults to heading 0,
which is the north-up case, and Q/E/R turn it. Panning and dragging now
work in what you see rather than in world axes, so up stays up when the
map is turned.

TRACKS.html is left following the console pictures on purpose: nine of
those cards are the console pictures, so the reconstructed nine have to
sit beside them consistently. The two disagree by a mirror and each is
right for what it is, which both READMEs now say.
2026-08-07 13:12:57 -05:00
Cyd dc74fb3867 Refresh the tracked bytecode cache
Rewritten because build_mapview.py imports navmap. This is what tracking
a .pyc costs - it will churn whenever a tool that imports navmap runs,
without ever being a source change. One .gitignore line ends it.
2026-08-07 12:41:16 -05:00
Cyd b1b82d5da1 A map viewer that draws tracks the way the map screen does
Pan with the arrows, zoom with plus and minus, [ and ] for the next
track. Self-contained HTML with the track data and palette embedded;
nothing here ships, and pack-dist.ps1 does not look at it.

It follows the engine rather than approximating it. NavDisplay derives
metersPerPixel from the zoom and sets LODIndex to it; L4GaugeImage::Draw
takes the first LOD whose scale is at least that value and draws nothing
once the value runs past the largest, so objects vanish rather than
simplify. Both map gauges are here because they disagree - nav is the
448x416 radar screen with LOD following zoom, gps the 125x203 panel whose
config pins LOD at 1.0. The HUD reports what is dropped, and is honest
that this content barely exercises it: every placement in every track is
cn3 with one LOD at scale 1000.

The map is not a phosphor screen. Primitives carry palette indices and
the palette is whichever the port was configured with - for the pod's
secondary port, configure(0,sec,270,0x00ff,clut0,rgb,secpal.pcc). PCC is
PCX, so the palette is the last 769 bytes. Walls are grey because index
51 is #4b4b4b; background is index 0, black; a primitive with colour 0
keeps the display's staticColor, 0x3C. Per-file palettes, not a global
one - 39 of 40 gauge PCCs differ - so the port's configured palette is
the one that counts.
2026-08-07 12:40:52 -05:00
Cyd 53c4eac3fd Merge restore-cut-vtvs: the cut vehicles, and the reference to go with them
Started as a question about eleven vehicles and seven maps missing from
our resource file. They were in a community 4.11 build; verifying that
file as a strict superset and promoting it brought them back, and most of
what follows came out of having to prove things about the file rather
than guess.

  * The cut VTVs restored, and the content pipeline (RPL4TOOL -b) made to
    work in this tree for the first time.
  * The front end fits 800x600, uses dropdowns, and no longer paints
    Windows grey over its own green.
  * VTV-PRESETS.html: 38 vehicles, loadouts and six-preset tables decoded
    by resource id, tabbed by hull.
  * TRACKS.html: all 18 tracks. Nine now show the setup console's own
    maps, recovered rather than reconstructed; the other nine are the
    course traced through their gates.
  * The airlock archive tracked whole, so those promotions stay
    checkable, plus the console's track, vehicle and pod art.

Two corrections worth carrying: the vehicles were cut BY 4.11 rather than
never shipped - ALPHA_1/REL410 is a cockpit of the ALPHA wing and its
RPL4.RES is the retail file - and the top-down projection was mirrored,
which the console's picture of Brewer's Bane caught.
2026-08-07 12:30:04 -05:00
Cyd 911345703f Commit the working tree as it stands
Everything outstanding, uncurated: the bytecode cache Python wrote beside
navmap.py while building the pages. It is derived from a tracked source
and tagged to one interpreter (cpython-314), so it will go stale rather
than break anything - one .gitignore line drops it again if it becomes
noise.
2026-08-07 12:18:03 -05:00
Cyd 1b603045d2 Track the airlock archive whole
A community build of 4.11 from another site. Three things have already
been taken out of it - RPL4.RES and GAUGE/L4GAUGE.CFG promoted into
assets/RP411 in f7c7000, and the console configs copied to
tools/console-config - and with the archive untracked none of that was
checkable. Keep it whole so it is.

Nothing here is read at build time or ships in a release; a README says
so, says what was promoted and why, and warns that rpl4opt.exe in this
folder is the community's 4.11 binary and not what BUILD.md produces.

It costs much less than the 111 MB on disk suggests: 767 of the 998 files
are byte-identical to assets/RP411 and git stores a blob once. The real
additions are the 223 WAVs, every one of which differs from ours, and
four audio files with no counterpart at all. Whether those WAVs are
better masters or just different renderings is unestablished, so they
stay unpromoted. Thumbs.db was already covered by .gitignore.
2026-08-07 12:13:08 -05:00