Commit Graph
167 Commits
Author SHA1 Message Date
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
Cyd 5ad55aa1eb Nine tracks get the console's own map, and the projection is corrected
RPConfig.xml has always named a picture for nine of the eighteen tracks.
The pictures exist after all, so the page reads the mapping straight out
of the config and uses them: score zones, drop zone, the chambers drawn
properly and labelled. No reconstruction beats the real thing. The other
nine keep the course traced from their gates, and each card now says
which of the two it is showing.

The pictures also check the reconstruction. Brewer's Bane is the one
track shaped distinctively enough to be obviously wrong, and it matches
the console picture turn for turn - long leg up one side to Score Zone 1,
the corner, the run out to Score Zone 2, junction chambers spaced along
it. It matched MIRRORED. Seen from above with +Z up the page the engine's
+X runs to the left, and every plan here had been drawn the other way
round. Fixed, so the nine tracks without a picture are drawn the same way
round as the nine with one.
2026-08-07 12:08:11 -05:00
Cyd 8dcf738593 The console's reference art
Three sets, added to assets/: the setup console's nine track maps, its
nine vehicle hull pictures, and twenty-four pieces of pod art. The track
maps are the ones RPConfig.xml has always named and pointed at - the
pictures the console showed - and they had been presumed lost.
2026-08-07 12:07:59 -05:00
Cyd 56b2af5208 The track plans are the course, not the wall markers
Drawing what the map screen draws never was going to give a map. Nearly
every placement in every track is one piece, cn3, and its gauge image is
two 25x5 bars at x 19.5..44.5 and -44.5..-19.5 - not a wall along the
route but a wall across it with a 39 unit gate in the middle. The
collision solid agrees exactly. A few hundred of those is a row of ticks.

The gate is the point: cn3's origin sits in the opening, so every
placement marks somewhere the race passes through. Walking the gates
nearest to nearest, from the end furthest out, draws the track itself -
Brewer's Bane comes out as its L with the junction chambers, Zaxxis as a
circuit, and the small arena as the maze it always was.

Guarded, because chaining nearest neighbours across a regular grid
invents a maze-like path out of nothing but visit order. Each track is
tested first on how many neighbours a gate has within 1.6x the typical
spacing: a corridor gives 2, a floor of obstacles gives 4 or more. The
separation is not close - seventeen tracks score 1 or 2, the demolition
arena scores 8 on an exact 100 unit grid and keeps its wall blocks.

Most of the arcade tracks really are near-straight canyon runs, a few
hundred units wide and several thousand long. The plans say so now
rather than implying otherwise.
2026-08-07 11:31:25 -05:00
Cyd b12eaa8bb2 The blacker VTVs sort to the end, and Blacker Broccoli loses a note
They are palette variants of machines already in the list, so interleaved
they read as duplicates. The console's own name decides it - eleven of
the thirty-eight are called Black or Blacker something - so they sort
last within a hull tab as well as overall.

Blacker Broccoli's console picture is a Bug, but the game draws it on the
Mule hull like every other Broccoli: the console entry is simply wrong,
and saying so on the card raised a question the card could not answer.
Blacker Tarantula keeps its note, where the console and the game really
do disagree about the hull.
2026-08-07 11:19:27 -05:00
Cyd 4dc4f86702 The README describes the track plans as they are drawn
It promised higher ground shown brighter, which the flat masks never
did, and it predated the change that draws each wall bar as one line
rather than a box.
2026-08-07 11:08:14 -05:00
Cyd f32494f0b7 The VTV page tabs by hull
Thirty-eight cards in one run is a lot to read. The first letter of a
vehicle's two-letter art code is the hull the game actually draws it
with, and the console's class names are subdivisions of those five -
Bull and Roadblock are both 'b', Bug and Skeeter both 's' - so grouping
by the letter keeps kin together whatever the console called them.

Particles 6, Bugs 10, Mules 9, Bulls 8, Police 5, which is all of them.
The build prints a warning if any vehicle falls outside the five, so a
new hull cannot go missing from the page quietly. The tabs compose with
the armament filter and the search box rather than replacing them.
2026-08-07 11:05:25 -05:00
Cyd 5135476088 Track plans read as outlines, not hatching
Nearly every track is one model repeated: cn3, a wall bar whose gauge
image is two closed 25x5 rectangles. Five metres of wall thickness is
finer than the plan can resolve, so each bar was landing as two parallel
lines plus two end caps - several hundred times over, which is the
hatching that swamped the arenas. Collapse a thin closed quad to the
centreline between its short edges: one stroke for one wall.

Walls stacked to build height coincide seen from above, so draw each
distinct wall once. And drop placements standing alone more than 200
units from any other - fourteen tracks park a single bar at (1200,0,0)
well off the course, and that one placement stretched the frame to
twelve times the width of the track. The four tracks without it are
exactly the four that always framed correctly. A real branch keeps its
neighbours and stays: Paingod's second canyon is sixty bars out at
x=-400.
2026-08-07 11:05:16 -05:00
CydandClaude Opus 5 99030e4aac The track plans are the map screen's own drawing
The first version of these plans was a scatter of scenery positions - an
impression of a track rather than a picture of one. The game already draws
the real thing: the map screen in the pod renders the track from above every
race, so the plans now reconstruct that instead of approximating it.

NavDisplay::DrawStatic walks the static entities, looks up each one's
L4GaugeImage by resource id, and draws it through localToWorld x
worldToView. navmap.py does the same offline. The pieces that made it
possible:

  - a map instance carries its model's GaugeImage id at +44, beside the
    position at +48 and the quaternion at +60;
  - a GaugeImage is a vertex array plus per-LOD polylines through it, in
    world units - cn3 is an 89x5 wall segment, pit1 a 500x300 pit;
  - a placement whose model has no gauge image is skipped here exactly as
    DrawStatic skips it, which is why a card can report fewer placements
    carrying map art than the track contains.

The difference is not subtle. Wiseguy's Wake and Paingod's Passage resolve
into twin canyon walls running their length, Brewer's Bane into an L-shaped
route through junction chambers, and both arenas into a lattice of obstacles
inside a boundary wall. What is still missing is the driving surface: the
map draws what lines the route, never the tarmac.

tools/pages/navmap.py carries the reader, and the README documents both the
instance record and the gauge image stream. Regenerating from the committed
generators reproduces the committed page byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:42:03 -05:00
CydandClaude Opus 5 6c3127a94d Every track, seen from above
docs/tracks.html joins the roster page: all 18 tracks with a plan view, what
the console calls them, which scenarios offer them, and how big they are.

There are no track maps in the game's files. The console had pictures of
them and those pictures did not survive - RPConfig.xml still points at
"images/red planet maps/Wiseguy's Wake.bmp" and the folder is gone. So the
plans are drawn from the tracks themselves. A map's instance stream places
its scenery: 76-byte records carrying a position at +48 and a unit
quaternion at +60, a few of them longer, so the reader resyncs on an
unexpected class id rather than trusting the stride. The quaternion doubles
as a checksum - a mis-read almost never yields a unit one - and 17 of the 18
decode every instance the header promises. Trough gives up 631 of 633 and
the card says so.

Seen this way the tracks have obvious shapes: Brewer's Bane turns two
corners, Tour De Mars is one 23,000-unit run, and both arenas are a regular
lattice of obstacles rather than a route at all.

The eras come from the resource-file archaeology rather than a guess: 9
tracks shipped in the 4.10 cabinets, headoff and headmf arrived with 4.11,
and 7 were built by the community afterwards. Scenario legality is read out
of the front end's own kMaps and kFootballMaps, so the page cannot claim a
track is offered when the menu does not offer it.

tools/pages carries the generators for both reference pages, with a README
covering the two formats they read and the id-alignment the listing is
needed for. They were scratch scripts until now, which made a committed
page harder to regenerate than to rebuild by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:27:26 -05:00
CydandClaude Opus 5 6df309e9c4 Vehicles and hulls get the names the console gave them
The roster page called vehicles by their resource key and hulls by their
two-letter art code - PUCK, "Hull PA". TeslaConsole's RPConfig.xml is the
only place either is named in words, so it now supplies both: the card says
ARMADILLO with the key beneath it, and the hull says "Armadillo hull" rather
than "Hull PA".

The class comes from the picture the console showed for each machine, so
vehicles sharing a picture share a class, and each hull is named by majority
vote of the vehicles drawn with it - one mis-set picture cannot rename a
whole class. Which matters, because two are mis-set: the console shows a Bug
picture for Blacker Broccoli and Blacker Tarantula, while the game draws
them on the Mule and Bull hulls. The page says so on those two cards instead
of quietly picking a side. Every other vehicle agrees.

RPConfig.xml and RPStrings.xml are copied into tools/console-config: the
front end's catalogs already came from them by hand, the roster page reads
them now, and they were only living inside the airlock archive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:18:55 -05:00
CydandClaude Opus 5 17bbcb2049 Every VTV on the roster page, resolved by id
The page listed 34 of the 38 vehicles in the resource file. The four
missing were community variants, and I had written down that they shared
another vehicle's mapping streams. They do not - they have their own, of 17
to 33 records. The decoder just could not find them.

Streams are stored as resources named plainly L4 and Thrustmaster, so
nothing in a stream says whose it is. The old decoder guessed by taking the
nearest preceding vehicle name in the file, which works while vehicles are
laid out one after another and fails quietly when they are not: it lost vole
outright and mis-attributed four blkr variants.

The file answers exactly if asked properly. A vehicle's ControlsMappings
List holds the resource ids of its two streams. Ids are not quite positional
- this file leaves 53 and 56 unassigned - so the directory walk is aligned
against RPL4TOOL -l, skipping the ids the listing marks Not Used. That gives
1077 ids with zero size mismatches, and every stream lands on its owner.
Subsystem names now come from each vehicle's own Stream of N Subsystems,
checked against the count in its header instead of being pattern-matched out
of the bytes.

Nothing already verified moved: lepton, dark, blkspk and neut decode exactly
as they did when checked against the 4.10 retail file, blkspk still putting
its third booster on the thumb-high in preset 4 and dark still spending
preset 5's HORN slot on its second demo pack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:11:00 -05:00
CydandClaude Opus 5 b3ed7bc141 The drop-down boxes stop being Windows-coloured
CBS_OWNERDRAWFIXED only hands over the item area, so while the list rows
came out green on black, the closed box kept the system's frame and drop
arrow - a white/grey Windows control sitting in the middle of a black panel.

The closed box is painted here now: black field, dim green border, bright
green text, and a plain green triangle instead of a themed button. The
control keeps doing everything else, including dropping its list, so this is
a subclass over WM_PAINT rather than a reimplementation.

Still system-drawn: the scrollbar inside a dropped list, which only appears
on the two lists longer than twelve rows - vehicle and track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:55:10 -05:00
CydandClaude Opus 5 44f5a2c6fd The pilot name heads the loadout column
It was tucked under the vehicle and colour boxes, which put the one
field you type into below three you only click. It now sits at the top
of the second column: who you are, then what you are driving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:51:47 -05:00
CydandClaude Opus 5 3ed6498183 The setup menu picks from drop-downs
Every list group is a combo box now: track, time, weather, length, vehicle,
and colour/badge or team/position. Scenario stays as visible buttons because
it decides what the other lists contain, so it should not be hidden behind
one of them.

This ends a problem I had been solving the wrong way. The menu was flat
lists of everything, which was fine when the content was short enough to see
at once - the quality that made it feel like the pod panel. The promoted
resource file roughly doubled it, and I answered with two columns, then
better margins, then a general column flow, each time keeping an idiom whose
justification had already gone. Eight controls replace ninety-odd rows.

At 800x600 the columns go from 131px to 323px, so nothing ellipsizes any
more - the longest name wants 158. The whole menu now needs 310px of the 492
above the buttons there, and 529 of 900 at 1080p, so adding vehicles or maps
cannot crowd it again.

The boxes are owner-drawn - green on black, highlight inverted rather than
tinted - so they read as part of the panel instead of arriving in system
colours. They are rebuilt rather than moved when the scenario changes, since
it swaps two of them outright and reshuffles the track list.

Built and run at 640x480, 800x600, 1280x720 and 1920x1080.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:48:10 -05:00
CydandClaude Opus 5 da7f675cca The setup menu flows into as many columns as it needs
I fixed the wrong column twice. The overflow was never the vehicle list: it
was the settings column - scenario, map, time, weather, length - which sat
within 11px of the bottom at 800x600 before any of this, and went 115px past
it once seven maps were added. At 1080p it wanted 1174 of 1080.

Fixed columns cannot hold this menu any more, so the groups flow: they fill
a column, start the next, and the layout takes as many as the content needs,
sizing them to share the width. Two passes - one to count the columns, one
to place the items - so nothing has to know the count in advance. Adding a
map or a vehicle can no longer push anything off screen, which is the actual
property that was missing.

Every column starts two rows down so the pilot name box has the same home
whichever column ends up last, and the bottom is reserved for LAUNCH and the
lobby buttons. AddGroupItems is gone; the flow places items directly.

Verified by arithmetic at 640x480, 800x600, 1024x768, 1280x720 and 1920x1080
in both scenarios - nothing exceeds its width or its bottom - and by running
the front end at four of those plus a mission at 800x600.

800x600 is honestly dense: the content genuinely needs five columns there,
and the longest few names ellipsize. Lowering the row-height floor does not
buy a column back, so the rows stay at 18px and legible. Dropdowns would end
this class of problem outright and are worth considering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:29:03 -05:00
CydandClaude Opus 5 ff0e98a7a5 Four columns get margins that suit four columns
The wrapped layout inherited the three-column fractions, which are generous
for three and wrong for four. At 800x600 that put the last column hard
against the frame - 19px of slack across the whole right edge, with the
longest vehicle name needing all but 2px of its 160px column.

The wrapped case now spreads its four columns evenly on its own margins,
and the unwrapped case keeps the proportions it always had. At 800x600 the
columns go to 168px with 40px of slack; 1024x768 and 1280x720 land
comfortably too.

640x480 cannot be made to fit: four columns of long names want more width
than there is, so names there are drawn with an ellipsis rather than sliced
through a glyph. That applies everywhere, so any window too narrow for its
content degrades the same readable way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:03:14 -05:00
CydandClaude Opus 5 cf90698159 The vehicle list wraps instead of running off the bottom
Adding eleven vehicles to the setup menu made the column longer than the
window. At 1920x1080 it used to end at 994 against a 1080 client and now
wanted 1324, so the bottom of the list was simply off screen - and the rows
cannot shrink to absorb it, being already at the 18px floor that keeps them
legible.

The list now wraps across two columns when it does not fit, split evenly
rather than filled-then-spilled, and the loadout column - colours and
badges, or team and position - moves one place right along with the pilot
name box and the launch, host and join buttons. A roster short enough for a
single column lays out exactly as it did before, so this only changes the
screen when it has to.

Four columns still fit the width everywhere we ship: the right edge lands at
623 of 640, 1250 of 1280 and 1877 of 1920. The tallest column is 19 rows,
ending at 428, 502 and 754 against those clients.

AddGroupItems grows a 'first' argument so a group can start partway through
its own list. The items stay contiguous in fe->items, so the header still
draws once above the first of them, and each item keeps its true index -
selection and hit-testing already work off item->index rather than position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 08:25:41 -05:00
CydandClaude Opus 5 3ba79476fc The setup menu offers the vehicles and maps that came back
The front end's catalogs are hand-written from the console's RPConfig.xml,
and the promoted resource file brought content they did not know about.
Eleven vehicles added - dark, blktrn and neut, which the console names
Blacker Puck, Black Tarantula and Neutrino, plus the eight community Blacker
variants - and seven maps, keeping the console's own convention of bracketing
non-arcade tracks in dashes.

The football map list is deliberately untouched. The console config has its
per-scenario invalid lists commented out, so it says nothing about whether
the new tracks are football-legal, and guessing would put players on a map
with no scoring zones.

A note where the catalog is declared, not a check: validating the keys
against RPL4.RES at menu time crashes, because the front end runs before the
resource file is opened and GetResourceFile has nothing to search yet. That
drift is real - this menu offered blkspk for a while before any vehicle
resource backed it - but the place to catch it is offline against the built
file, not in the boot path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:12:30 -05:00
CydandClaude Opus 5 f7c7000694 The cut vehicles come back, and eight more besides
assets/RP411/RPL4.RES is now the 1.25MB resource from the airlock archive, a
2014 community build, replacing the 785KB file RP412 inherited. It verifies
as a strict superset of ours: nothing is lost but two unnamed Not Used
placeholders, the format version matches, all 26 base vehicles' L4 and
Thrustmaster mapping streams are byte-identical to the ones we shipped, vole
matches resource for resource, and it boots against our own GAUGE, VIDEO and
AUDIO with a log identical to the baseline.

Its L4GAUGE.CFG comes with it. That file is ours plus the new vehicles'
blocks and one fix: dragonInit gains twoBoosterInit, so the dragon's two
boosters finally have gauges - it always had them in its subsystem list and
the panel simply never drew them.

dark, blkspk and blktrn are back with the tables they shipped with in 4.10,
checked against the retail file: blkspk still puts its third booster on the
thumb-high in preset 4 alone, dark still spends preset 5's HORN slot on its
second demo pack. The archive also brings neut, a four-booster Lepton class
with an Eject subsystem, eight community Blacker variants and seven maps.

The black mystery turned out to be a renaming bug rather than a vehicle that
never existed. RP411's gauge config carried a blackInit block nothing could
select, because the lookup is <model>Init and no model is named black. The
airlock config calls the same block - byte-identical body - blktrnInit,
which is the model's real name. disk is now the only genuine orphan: a panel
layout with no vehicle behind it in any resource file, and no entry in the
console's own config either.

The roster page regenerates to 34 vehicles. Four of the eight community
variants have a ControlsMappings List but no streams of their own, pointing
at another vehicle's by id, so they carry no preset table and do not appear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:05:57 -05:00
CydandClaude Opus 5 8e2e00d8d3 The resource pipeline builds again
RPL4.RES is compiled from authored sources by RPL4TOOL -b, the original 1996
resource tool, which still builds in this tree. That pipeline did not work:
the tool aborted on the very first model, so the resource file could only be
consumed, never regenerated. Content could not be changed at all.

It died in PlugStream_FindEntryAndWriteObjectID resolving resource=
Translocation01 from AUDIO/PLYINT.SCP, a name defined in no file under
CONTENT/RP. The obvious suspect was the AWE32 soundbank path, since
AudioCard::LoadSBK is stubbed to return 1 by the Win32 port - but that is a
red herring. Supplying the banks changes nothing because nothing reads them
at build time. CreateStaticAudioStreamResource opens audio\static.scp, which
declares all 154 patch resources as plain text mapping each name to a bank
and patch number, and CONTENT/RP simply does not have that file. The sda4
developer drive does. Two more scripts included by 27 vehicles, VTVINT.SCP
and VTVEXT.SCP, were missing the same way. All three are kept in recovered/
because they are the keystone and are small.

No engine change was needed. The pipeline was missing content, not code.

build-res.ps1 assembles a build tree from the 4.10 content, those three
scripts, and the soundbanks and ~547 VIDEO files that RP412 ships complete
and the content tree does not. It never overwrites an authored file with a
shipped one, so archival content stays authoritative where it exists.

A model missing a skeleton is dropped SILENTLY - the tool logs and carries
on, producing a resource file with fewer models rather than failing. Check
the model count, which is why the script reports unresolved inputs.

-RestoreCutVehicles uncomments dark, blkspk and blktrn, three vehicles taken
out of the .bld after 4.10 shipped with their model ids left in place. All
three build clean and take the count from 42 to 45, exactly retail's, each
with its full subsystems, segments, damage zones and control mappings.

What this cannot do yet: eleven maps have no source. Five survive only on the
sda4 drive in a 1996 state older than retail, and otto, frstrm, burnt,
brewers, headoff and headmf are gone entirely - the .CAM cameras and .XST
existence boxes are here but the .MAP files are not. So a build from these
sources yields 45 models and zero maps against RP411's nine, and is not yet a
drop-in replacement for assets/RP411/RPL4.RES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:12:00 -05:00
CydandClaude Opus 5 cf59ac9f4b Every VTV and its presets, in a page that ships with the game
VTV-PRESETS.html joins the handbook in the dist: all 26 racable vehicles,
each with its hull plan view, what it carries, and the six control presets
on its stick. The data is decoded from RPL4.RES rather than transcribed -
each vehicle's own subsystem list names its systems, and
VTV::BasicSubsystemCount fixes list index i as subsystem 9+i, so every
subsystem id in all 26 mapping streams resolves with nothing left over.
The gauge config could not have been used for this: it is only artwork,
and it lies. The dragon has boosters and a chute with no art declared,
and the burro's chute sits in a different panel slot than sequence would
suggest. Both come out right this way.

The plan views are each vehicle's own damage-gauge silhouette, redrawn
from the three-colour original as an alpha mask so it takes the page's
colour in either theme. There is no per-vehicle art to use instead:
vehicles are grouped into hull families that share both the silhouette
and the mesh in VIDEO/, which is keyed by the same two-letter code. Each
card says which hull it is and who else races the same one.

The page does not mention the intercom PTT. Its hardware never went past
prototype cockpits, so naming a control nobody can press would only raise
questions; those cells are simply blank. CONTROL-PRESETS.md carries the
full account, because anyone re-decoding the resource will find message
ID 13 on the pinky and needs to know why the tables show it empty. Two
things in the shipped assets settle it: the tool panel's fourth quadrant
is bare where every other system has a legend, and the two finished
intercom station screens are referenced by nothing at all. The edge
strips are referenced - but gated on ModeIntercom, which nothing ever
sets, so they have never been on screen either. The block that would have
wired the buttons is inside #if 0 and still names L4ModeManager, a class
that no longer exists: cut before the RPL4ModeManager rename and never
revisited.

The doc also now explains the mode-mask gate itself, since that is what
makes the dead intercom legible: one 32-bit word, seeded 0x201, read by
both the controls dispatch and the gauge renderer's active/inactive
sorting. A drawable whose bit is never set is parked in inactiveList for
the life of the process and never complains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:10:53 -05:00
CydandClaude Opus 5 05f993b9aa Picking a preset no longer blinds the mode lamps
The six PRESET switches down the map's right flank stored their lamps in
modeLamp[], which holds four. Indices 4 and 5 ran off the end into
presetLamp[0..1], so the whole thing stayed self-consistent by memory
layout and nobody noticed - but it overwrote the four control-mode lamps
made moments earlier, and BASIC/STANDARD/VETERAN/MASTER on the upper-right
MFD were never lit again. presetLamp[], meanwhile, went unused.

The preset pass now fills the array it was always meant to, and the lamp
work moves out of the switch handler into a virtual NotifyOfPresetChange
that PresetEnable announces itself. That closes the second gap in passing:
keyboard 1-6 changed the mappings without touching the lamps, leaving the
flank showing a preset that was no longer in force. Both routes now go
through one place. The lamp arrays are also cleared in the constructor -
only the mapping loops ever filled them, and NOMODES skips those.

Verified by dumping the commanded RIO lamp states out of the running game
(PadRIO, TEST.EGG, at rest in Basic mode). Before and after are identical
except lamp 0x33, BASIC, which goes from 14 dim to 3c lit. The preset
lamps are unchanged: they worked by accident, and now work by
construction.

docs/CONTROL-PRESETS.md is the research behind it. The presets are not a
map feature at all - each is a complete factory layout for the four
mappable stick buttons, one mode-mask bit apiece, with all 26 vehicles
carrying their own six-preset table in RPL4.RES for both the pod RIO and
the Thrustmaster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:43:02 -05:00
CydandClaude Opus 5 f83f56e14d Stop sending expired testers to an empty page
Both the README and the dialog an expired build puts up pointed at the
Gitea releases page for the next build. That page is empty now - releases
come through another channel - so in a fortnight's time the one message a
tester is guaranteed to read would have sent them somewhere with nothing
on it.

No address in its place: whoever handed them the build is who to ask, and
a URL that goes stale again is worse than no URL. The Source: line stays,
because the repository it names is still there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:38:29 -05:00
CydandClaude Opus 5 f44cdbacca How a hit becomes lost armor and lost score
The damage model, end to end, read off the authentic surviving RP source
(VTV.cpp, WEAPSYS.cpp, RIVET.cpp, DEMOPACK.cpp, RPPLAYER.cpp) rather than
inferred. Companion to the BT doc of the same name; the shared engine
layers are the same and only summarised here.

The finding it is built around: RP's damage model is a physics and score
economy, not a subsystem-failure simulation. One armor pool at zone 0, no
criticals, no per-zone cascade. Collision armor is not authored at all -
it is calibrated from the vehicle's own mass and MaxImpactSpeed so that a
full-speed hit spends exactly the whole budget - and deathConstant
converts damage to score at that same exchange rate. Every point of
damage is simultaneously a transaction between two players, through a
two-second revenge window. Martian football, not a mech duel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:38:16 -05:00