Commit Graph
7 Commits
Author SHA1 Message Date
CydandClaude Opus 5 25e25260b1 Home and End are the bass knob
The volume keys wanted a partner, and the bass trim could not be one as it
stood: it scaled the sample data as it loaded, so by the time anyone pressed
a key the audio was already sitting in OpenAL buffers and nothing short of a
restart would move it.

So the trim is now a per-zone gain applied in the mix instead. Each buffer's
depth - how much of the low band it occupies - is still worked out once at
load from its playback rate, but the trim itself is read every frame, which
is what lets Home and End move it while sounds are playing. It is the better
form regardless: no rewriting of sample data, and no quantisation on top of
audio that has already been through one gain stage.

Home raises, End lowers, in steps of 0.05, and the setting is written to
bass.cfg beside the exe exactly as the volume writes volume.cfg. Together
with PageUp and PageDown that is the amplifier and the crossover the
cabinets had in hardware and a desktop does not.

Builds clean, runs, and neither knob fires unprompted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:08:55 -05:00
CydandClaude Opus 5 4e8392fcfb PageUp and PageDown are the volume knob
The cabinets had no volume control - they ran at unity and left level to an
external amplifier - so a player without that hardware had nowhere to turn
it down but environ.ini and a restart. PageUp and PageDown now step the
master volume by 0.05 while you play, from silent to double, and whatever
you leave it on is written to volume.cfg beside the exe and used from then
on. The environ.ini figure decides where a machine that has never been
touched starts out; the keys are the knob, and a knob stays where it was
left.

Page keys because they produce no typed character, so they cannot collide
with the character-keyed commands the engine already answers to, nothing
else in RP binds them, and they are on every keyboard including tenkeyless.

They are polled rather than read off the key-message path, which is worth
recording because the message path looked like the obvious home for them
and was tried first. RP's keyboard pump only takes WM_KEYUP, WM_SYSKEYUP
and WM_CHAR off the front of the queue, and the front end runs message
loops of its own, so key messages get raced for and lost: six deliberate,
well-spaced presses arrived as two. Fine for the abort chord, useless for
something you tap repeatedly to find a level. Reading key state directly
costs nothing and cannot be dropped. That losses figure is a pre-existing
property of the input path, not something this change introduced, and is
worth knowing before anything else gets bound there.

Builds clean, runs, and does not fire unprompted. The step function itself
is proven - it was driven end to end through the message path before the
switch, stepping the right way, clamping, and persisting. What I could not
test from here is the polling trigger, because Windows would not hand the
game foreground and injecting keys without it would have sprayed them
across whatever else was open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:00:36 -05:00
CydandClaude Opus 5 523f713a30 Volume and bass knobs, for players without an amplifier
The cabinets ran the game at unity and shaped volume and tone outside it,
in an external amplifier and a 3-way crossover. That is why there is no
master volume anywhere in the original code and none in AUDIO.INI - an
operator turned a knob on an amp. A desktop player has no amp and no
crossover, and the recovered soundbanks are a good deal livelier than what
4.12 shipped with, so the game has to offer the two controls the pod got
from hardware.

RP412AUDIOVOLUME, 0.0 to 4.0, is the amplifier: a listener gain, which the
port had never set at all. RP412AUDIOBASS, 0.0 to 1.0, is the crossover's
low band. Both default to leaving the mix exactly as the pod played it, so
neither changes anything for anyone who does not go looking.

The bass trim is not a filter, and the reason is worth writing down: the
OpenAL we ship is Creative's, not OpenAL Soft, and it implements only
AL_FILTER_LOWPASS. It rejects highpass and bandpass outright. A bandpass
would have been the tidy answer, carrying the authored brightness model on
GAINHF and the trim on GAINLF across the single direct filter a source
gets. It is not on offer.

So the trim scales sample data as it loads, which suits how this low end is
actually built: the weight lives in discrete deep layer zones whose per-zone
tuning bakes out to a very low playback rate - thirteen zones below 8kHz,
three to five octaves under their recorded pitch, against four fifths of the
set at 22kHz and up. Baked rate is a dependable proxy for band, so pulling
down the low-rate zones is a real low-band trim and not a blunt cut. It eases
in below 22kHz and reaches full depth at 5.5kHz.

Caught while building this, and the reason for the probe: EFX_Initialize
checks alGetError after configuring the scratch filter, so asking for a
filter type the driver refuses leaves an error pending and takes the entire
bridge down - reverb included. The bandpass attempt did precisely that and
would have silently killed the reverb and brightness work. Initialize now
survives losing the filter and says so.

Builds clean, runs with both knobs set and with neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:45:29 -05:00
CydandClaude Opus 5 6ce729bab5 Lit cockpit buttons keep up with the sim
BT411's f99003c, brought across. Its playtesters reported the cockpit
lighting going slow or stopping altogether while the 3D view stayed
smooth, and RP412 has the same structure exactly: the on-screen vRIO
buttons light themselves from PadRIO::GetLampState, but what FILLS that
store is lampManager->Update() in GaugeRenderer::ExecuteForeground - once
per full gauge cycle.

Which is the cycle the previous commit was about. Measured on a starved
frame budget it now completes 3.1 times a second, and completed 0.7
times a second before that; either way far too slow to carry a flashing
lamp. So sweep the lamps once per frame from the main render instead,
which runs regardless of how little frame is left over. It is cheap, and
AssertNewLampValue already drops anything unchanged, so this pushes no
extra traffic - it only stops changes arriving late.

Only when a PadRIO is active, i.e. cockpit-less play, and only while a
mission is actually running. With real serial hardware selected the pod
keeps its authentic bandwidth-paced cadence, untouched.
RP412LAMPSWEEP=0 restores the once-per-cycle behaviour.

BT411's other half, 02ce9f5, does not apply. That one is about Windows
throttling WM_TIMER and paint messages for background windows, which
made the glass panels' flash crawl whenever they did not have focus.
RP412 has no timer-driven repaint anywhere - the MFD windows are D3D
devices presented from SVGA16::Update, and the panel strips repaint from
there too - so there is no throttled message path to bypass. That path
was starved rather than throttled, and the previous commit is the fix.

Verified: no regression at either budget, 20.0 display sweeps/s at a
normal frame budget and 3.1/s starved, both unchanged by this commit;
mission runs clean. The lamp win itself is structural - the sweep is now
an unconditional per-frame call - and would want a busy multiplayer
mission to see directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:45:23 -05:00
CydandClaude Opus 5 f4fef29428 The map keeps drawing when the view gets busy
Two testers reported the map and the countdown clock freezing, one of
them only on larger, more complex maps, and one of them until a death.
Both details point at the same place.

The gauges and the cockpit displays are redrawn in whatever time is left
after the 3D view. The background loop is guaranteed a single pass per
frame and gets more only while time remains before the frame is due, and
one pass drew exactly one gauge. So a full sweep of ninety-odd gauges
needed ninety-odd passes - free when there is spare frame, but on a busy
map the 3D view eats all of it, the loop drops to its one guaranteed
pass, and a sweep takes ninety-odd FRAMES. Seconds. A death makes the
renderer skip every static object, the budget frees up, and the backlog
drains at once: the display appears to come back to life.

Worse, the copy phase that follows ended after a SINGLE display, so the
map - one of three - came round only every third sweep.

So: draw gauges to a 2ms slice rather than one per pass, which ties the
refresh rate to elapsed time instead of to how much spare frame there
happened to be; and copy every display before reporting the sweep done.

Measured on a deliberately starved frame budget, which reproduces the
reported symptom: 0.7 sweeps/s before, 3.1 after. At a normal budget
20/s, against 18-19 before - no cost to the healthy case. RP412GAUGESLICE
tunes the slice and 0 restores the old behaviour, which reproduces the
0.7 exactly. RP412GAUGEDIAG=1 logs the rate; watching the screen cannot
tell a display that has stopped refreshing from one whose picture simply
is not changing, which is what made this hard to see.

Also fixes the constructor calling Update() three lines before it
initialised mDisplayToUpdate, so the first pass indexed the D3D device
and surface arrays with whatever was on the stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:36:45 -05:00
CydandClaude Opus 5 82e733c1a6 Replicants reckon from when an update was sent
Simulation::ReadUpdateRecord threw away the sender's timestamp and
stamped lastUpdate with its own arrival time. The line carried the
original authors' own note: "HACK - should be based upon
message->timeStamp".

The dead reckoner extrapolates a replicant over
(lastPerformance - lastUpdate), so starting that clock at ARRIVAL rather
than at SEND leaves every remote vehicle exactly one network latency
behind where it should be. On the 1 ms LAN inside an arcade that is
nothing. Over Steam Datagram Relay it is 50-150 ms of positional lag on
every other player - a constant bias, not jitter, and the information
needed to remove it was already in the packet.

The timestamp cannot be used as it stands: both machines run
QueryPerformanceCounter since their own boot, so the two clocks share no
epoch. The offset is estimated per peer instead. Each record gives

    sample = ourNow - theirStamp = trueOffset + oneWayLatency

and latency is never negative, so the smallest sample seen is the
closest to the truth. A rolling minimum over 128 samples follows crystal
drift and re-adapts when a route gets slower, rather than being pinned
forever by one lucky packet; a shorter path is believed immediately.

Applied with two clamps: never ahead of our own clock, and never further
back than 500 ms. Past that the packet is stale or the estimate is
wrong, and throwing a vehicle half a second forward does more damage
than the lag being corrected.

Entity::UpdateMessageHandler is the only point on the receive path that
knows whose update this is - records carry a timestamp but not an owner -
so it publishes the sender around the loop, and only for entities
somebody else owns. Offsets are forgotten in CreateMission: the hosts in
the next race are not the hosts in the last one and a HostID gets reused.

RP412NETCLOCK=0 restores the arrival-time behaviour, documented in
environ.ini, so a test machine can compare the two without a rebuild.
The estimate is logged per host when it first settles and whenever it
moves more than 50 ms, which is what a three-machine session should be
read against.

WHAT IS AND IS NOT VERIFIED. A full single-player race runs unchanged -
the path is never entered without replicants, which is the regression
risk that reaches everybody. The behaviour this exists for needs real
latency between real machines and is therefore untested: a two-instance
loopback race would only have exercised the zero-latency case, where the
correction is a no-op by construction. Expect remote vehicles to sit
further forward than before, and watch for overshoot when somebody
changes direction sharply - that is the tradeoff this makes, and the
clamp above is what bounds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:50:28 -05:00
CydandClaude Opus 5 4f34684b16 environ.ini is written on first run, not shipped
Packing one into every zip meant a tester who unzipped a new build over
their folder got their configuration replaced. bindings.txt has never had
that problem, because the exe carries the template and writes the file
only when it is absent. environ.ini now works the same way, so a new
build can land on an existing folder and every setting survives.

The 245-line template moves out of pack-dist.ps1 and into RPL4ENVIRON.cpp
as the exe's own literal, which also means the exe alone can produce a
working install. It was lifted mechanically rather than retyped, and the
file it writes is line-for-line identical to the one we have been
shipping - only the line endings changed, from a mongrel 243 LF plus one
stray CRLF that PowerShell's Set-Content left on the end, to the uniform
LF the game already writes bindings.txt with.

It cannot simply become optional. Without environ.ini, L4GAUGE is unset -
which disables the gauge renderer and takes every MFD with it - and
L4MFDSPLIT is unset, which is the packed-window arcade layout rather than
the glass cockpit. The shipped values ARE the desktop game; the built-in
getenv fallbacks are the 1995 pod. So the game writes the file rather
than tolerating its absence.

The cost of a file that is never overwritten is that a tester carrying
one across many builds stops being offered new options. Nothing breaks -
an option added later defaults to "behave as before" - but it goes
unnoticed, and "the podium does not work" is a confusing bug report when
the real answer is that their environ.ini predates RP412PODIUM. So the
load names every template key the player's file has never mentioned, and
says they are at built-in defaults and that deleting the file brings the
documented one back. A stale seven-line file lists all 40.

The file is read, never rewritten. The mention test is deliberately
generous - a key counts as known if it appears in any form, commented or
not - because the failure it guards against is worse than a missed
notice: environ.ini is applied line by line, so a second copy of a key
appearing later in the file would silently override the player's own.

The version line also moves to the top of WinMain. It used to print after
the environment was loaded, so the first thing in rpl4.log was a message
about environ.ini rather than which build wrote it.

Verified: the written file matches the old shipped one line for line; an
edited file with a hand-added comment survives another run untouched; a
seven-line file from an older build boots and names all 40 options it has
never heard of; and a full mission on a self-written file brings up the
glass cockpit at 125% with the virtual RIO active and nothing alarming in
the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:46:18 -05:00