28 Commits
Author SHA1 Message Date
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
CydandClaude Opus 5 303758cb52 Testers' crash dumps stay out of the history
Crashdmp\ is where a dump sent in by a tester lands. Read it with cdb
against the matching Release\rpl4opt.pdb - the PE timestamp recorded in
the dump says which build it came from, and the symbols mean nothing
unless it matches.

Not tracked, because a minidump is not ours to keep: it carries process
memory and the sender's own file paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:37:58 -05:00
CydandClaude Opus 5 12f9ebefab A quiet sound at the wrong distance no longer kills the game
From Nathan's crash dump: an access violation reading 8093e920, fourteen
minutes into a session, on 4.12.115.

  rpl4opt!PatchLevelOfDetail::SetupPatch+0xbb
  rpl4opt!Static3DPatchSource::StartImplementation+0x50
  rpl4opt!AudioRenderer::ExecuteBackground+0x9e

The faulting instruction is g_buffers[index] with index = 0x20000000 -
536 million - and the array base in eax at 0093e920, which is exactly the
address it died on. So the index was garbage, and the dump says where the
garbage came from: the stack slot holding info.bufferIndex.

PRESET_getSampleInfo builds a SAMPLEINFO to return when it is asked for a
zone the preset does not have. It sets chan, file, implemented and loop -
and not bufferIndex. Every caller tests bufferIndex >= 0 before using it,
so "no such zone" was meant to be rejected there; instead the test read
whatever was on the stack, and passed whenever that happened to be
positive. AL_getBuffer then indexed the array with it, unchecked.

Why it asked for a zone that is not there: the loop runs to
sourceSet.count, which was fixed when the audio source was built, from
whichever level of detail was selected at the time. SetDistance re-picks
the level of detail by distance on the line immediately before SetupPatch
runs, and the zone counts across the recovered banks are nothing like
uniform - of 200 presets, 46 have no zones at all, and the rest run 1 to
4. So a sound that moved far enough to drop to a quieter patch could ask
that patch for a zone it never had. In the dump: count 3, died asking for
zone 2.

Fixed at all three levels, because any one of them alone would have held:
the default carries bufferIndex = -1 so the existing guard works,
AL_getBuffer returns AL_NONE rather than reading past its array, and
SetupPatch asks for no more zones than the patch it is actually using
has.

Verified: the dump's own numbers reproduce arithmetically, and two full
races run clean. The distance-dependent trigger itself was reasoned from
the dump rather than reproduced here - it needs a sound to cross a level
of detail boundary into a shorter patch - so the belt-and-braces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:08:20 -05:00
CydandClaude Opus 5 a417175da8 The handbook explains why the sound changed
The volume and bass keys were not on the keyboard diagram, and nothing told
a returning player why the game suddenly sounds different.

Adds a short section on what came back out of the original soundbanks -
pitch, the missing layers, distance, reverb, doppler - written for someone
who wants to know why their collisions have weight now, not for someone
reading the source. It closes on the knobs, because "it is too much" is a
fair reaction and the answer should be next to the explanation.

PgUp, PgDn, Home and End now light up on the keyboard diagram in their own
colour, with a legend entry, rather than sitting there as dead keys. And
volume.cfg and bass.cfg join the list of files in the folder that belong to
the player.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:54:32 -05:00
CydandClaude Opus 5 943a15cef4 A second race no longer takes the stack with it
Reported by a tester and reproduced here: finish a race, come back to the
lobby, start another, and the game dies a few seconds in.

It is a stack overflow, from CockpitShellProc calling itself. The cockpit
subclasses the game window to catch WM_SIZE and re-fit the canvas, and
kept SetWindowLongPtr's return as the proc to chain on to. But the game
window is not the cockpit's - it outlives it, and carries the console
screen from one race to the next - and nothing ever unsubclassed it. So
the second race subclassed an already-subclassed window, SetWindowLongPtr
handed back CockpitShellProc itself as the "original", and from the next
message onwards the proc chained to itself until the stack ran out.

Nothing in the log, because nothing in the game had gone wrong yet.

So the destructor puts the window's own proc back, and the install site
will not subclass the same window twice even if it could not.

While there: the destructor also left activeCockpit pointing at the
object it had just freed, so GetCockpit() handed CockpitShellProc a dead
cockpit to lay out. Harmless until someone resized or maximised the
window at the lobby between races, which is not a hard thing to do. Now
cleared.

This came in with the cockpit resize work in 6b43971, so every build
since has had it.

Verified under cdb: before, the crash is a c00000fd stack overflow with
CockpitShellProc / CallWindowProcA repeating the whole way down. After,
four consecutive races - launch, race, results, CONTINUE, lobby, launch
again - complete with no exception at all, and the process exits only
when asked to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:36:35 -05:00
CydandClaude Opus 5 cdccb16251 Keep AUDIO.RES; a Windows ? matches nothing too
The bank exclusion used AUDIO?.RES, which also swallowed AUDIO.RES - the
one-byte stub that has been in the audio folder since 1995. A '?' in a
Windows wildcard will match zero characters, not just one. Name the two
banks instead of pattern-matching them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:24:36 -05:00
CydandClaude Opus 5 a5faa6cf9b Stop shipping seven megabytes nobody reads
The original AWE32 soundbanks are the source the shipped sound effects are
generated from, and they belong in the repo for that, but the game has no
use for them at run time - the LoadSBK path died with the sound cards and
AUDIO.INI's [AudioResources] section is commented out alongside it. They
were going into every download regardless.

Also drops four wav files that nothing references: two leftovers named temp,
one of them empty, and two stale zone files orphaned when the preset table
was regenerated from the banks.

Together about 8MB off the package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:22:39 -05:00
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 e133d4c993 Sounds recycle their voices instead of churning through them
Recovering the soundbanks took voice demand per sound from about one zone to
about two and a half, and the audio path allocated an OpenAL source for every
sound event and destroyed it again on release. Sources are a hard
per-context resource - this driver grants 256 - so that churn doubled at
exactly the moment it got more expensive. Sources are now generated once and
recycled through a free list: measured, three sources generated across
twelve thousand acquisitions.

The BT tree reached the same conclusion the expensive way, from field logs
full of failed acquisitions: raising the source budget is not the fix,
because the ceiling also acts as a governor and more voices mixing is real
CPU during exactly the busiest moments. Recycling is the fix, and it costs
nothing.

Two older bugs were sitting underneath, both reproduced against the driver
rather than assumed:

Releasing a set leaked it. alDeleteSources is atomic - one bad name in the
array and nothing at all is deleted. ReleaseSourceSet handed it the whole
fixed-size array and then parked the slots at -1, so any partial set, and
any double release, leaked every source it held. Sources are now handed back
one at a time and slots park at 0, which is never a valid name.

A source set began life uninitialised. The constructor set only the count,
and the acquire path decided whether a slot was already filled by asking
OpenAL about uninitialised stack garbage. Garbage that happened to match a
live name meant two sounds silently sharing one source. Pooling would have
made that more likely, not less, since it keeps small names in circulation.

Recycled sources are scrubbed before parking - stopped, buffer detached,
looping, gain, pitch, relative flag, position and velocity reset, and the
EFX filter and reverb send dropped. Without that last part a dry cockpit
sound inherits the wet send of whatever 3D source held the name before it.
Verified: a deliberately dirtied source comes back clean.

Builds clean. Runs with memory and handle count flat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:33:45 -05:00
CydandClaude Opus 5 d361a0b8be The sound effects play at the pitch they were written at
Red Planet's original AWE32 soundbanks are back in the tree, and the game's
sound effects are now generated from them instead of from an incomplete
one-off extraction.

AUDIO1.RES and AUDIO2.RES come from the 1996 release in the TeslaRel410
archive, hash-identical. AUDIO.INI has named them all along - they were
simply never carried into the port. tools/rp_sf2extract.py reads them and
regenerates both the WAV set and RP_L4/WTPresets.cpp, so the assets are
reproducible from the banks rather than hand-maintained.

Two things were wrong with the old set:

Pitch. Every shipped WAV was flat 44100 Hz with the banks' tuning discarded,
so 202 of the 219 zones played at the wrong speed - the worst by nine
semitones. The EMU8000's per-zone root key and tuning are now baked into
each file's declared sample rate, which is exact and needs no engine change.
Layers that were meant to be deep now are: a collision sub-thud that lasted
18 milliseconds at the wrong rate is a 0.66 second one at 1228 Hz.

Missing layers. 93 presets were short of zones and 176 were missing outright,
219 of 395. Nothing was lost recovering them - the 46 preset slots that
disappeared were all empty placeholders. The old files were also over-read,
running past the end of their sample into whatever PCM came next;
WellheadDrill02a was six seconds where the bank says eight hundred
milliseconds. Every one of the 395 files now matches its bank record exactly.

Also baked in: per-zone layer attenuation, and the static resonant low-pass
the EMU8000 applied in hardware.

Measured while doing it, and worth knowing: RP's banks contain no key-splits
at all - every multi-zone preset is a pure layer stack - and no preset has
more than four zones, which is what the engine's own "AWE appears to only
play 1st 4 voices" warning has been asserting since 1995.

Still to do: loop regions and the release fades, which 349 zones ask for and
which need new SAMPLEINFO fields. And voice demand per sound has gone from
about one zone to about two and a half, so the per-event alGenSources and
alDeleteSources churn roughly doubles - the BT tree measured pooling as the
fix for that, and a CPU win besides.

Builds clean. The extreme baked rates, 1228 Hz up to 88200, were checked
through the real path - libsndfile, alBufferData, alSourcePlay - and all
load. Not yet listened to on the pod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:16:13 -05:00
CydandClaude Opus 5 ce1b0ab9c3 Sounds fade, dull and doppler with distance again
The OpenAL port kept the whole authored audio model and then threw most of
its output away. Every frame the engine computed a distance-attenuation
curve, a high-frequency rolloff, doppler cents, a reverb level and a
front/rear placement, and every one of those consumers had been commented
out when the two AWE32 cards were replaced. What reached the speakers was
OpenAL's own defaults instead: a straight-line fade to silence, no
filtering, doppler at the wrong constants with an inverted velocity, no
reverb, and every cockpit sound dead centre.

Restored, per AUDIO.INI, which is byte-identical to the file that shipped
in August 1995:

  - the authored knee/rolloff distance curve, replacing AL_LINEAR_DISTANCE.
    This also un-blinds the transient cull, the voice-steal weighting and
    the mix ducking, which all key off it and were treating far sources as
    full presence
  - the CC7 squared volume law; writing the scale linearly ran everything
    about 6 dB hot at mid-scale
  - brightness and distance muffling, and the wet-exterior/dry-cockpit
    reverb split, both through a new OpenAL EFX bridge
  - doppler on the moving-source path only, as the original had it
  - front/rear placement from the authored position enum

The larger find is that AL_PITCH was never called anywhere in the tree, so
the entire pitch chain was inert - not only doppler but pitch_mix_offset,
which our own sequences author 97 times. Doppler alone would have changed
nothing audible.

Note pitch is applied for parity with the BT engine but is identity here:
our content predates NoteAudioControlID, so every source runs at note 60.

Builds clean on VS2022 Release|Win32. Smoke-tested against vRIO on COM1 -
reaches gameplay and holds a steady frame loop. ALC_EXT_EFX is present on
the build machine with all nine entry points, so the filter and reverb work
is live rather than inert. Not yet listened to on the pod, which is the
real test: the volume law changes the level of everything.

docs/SOUND.md documents the original two-card quadraphonic design, where
the surviving original assets are, and what remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:00:37 -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 eb17220dd5 Test builds go stale after a fortnight
A tester still racing a two-week-old binary reports things that were
fixed a week ago, and the afternoon spent chasing them is gone. An
expired build now says so and stops: a dialog naming its version and
expiry date, pointing at the releases page, and an exit before anything
else runs. The log carries the same line, so a report from an expired
build identifies itself.

$expireDays at the top of stamp-version.ps1 is the shelf life, sitting
next to the product line it belongs with. It counts from the day a build
was MADE rather than the day the code was written - rebuilding an old
commit to chase something should hand back a usable binary, not one born
stale.

SET IT TO 0 FOR A REAL RELEASE. A shipped build that expires is a
catastrophe, and that one line decides it. It is called out in the
script, in the generated header and in BUILD.md, because it is the kind
of thing that gets noticed exactly once, too late.

The date is what makes rpl4build.h differ from one day to the next, so
the first build of each day recompiles RPL4.CPP and the rest do not.

This is a nudge, not a lock. The date comes from the machine's own clock
and anyone determined can wind it back; the point is to stop an honest
tester wasting a day, not to stop anybody at all. RP412NOEXPIRY=1 waives
it for us and logs that it did, so a waived build is never mistaken for
a current one. It is deliberately absent from environ.ini - a bypass
every tester can see is a bypass every tester will use, and then it never
goes stale for the one person it was meant to stop.

Verified all four ways by backdating the shelf life rather than touching
the clock, which is what a negative $expireDays is for: a fresh build
runs untouched; an expired one raises the dialog, exits 1, and logs
"Build expired on 4 August 2026 - refusing to run"; the same expired
build with RP412NOEXPIRY=1 runs and logs the waiver; and a build with two
days left runs and logs two days left.

Two things that only showed up by running it. Negative days first meant
"never" rather than "already expired", so the refusal path went untested
on the first pass - only 0 means never now. And the days-left count was
anchored at midday, reporting one day fewer than the build had; it is
anchored at the end of the expiry day, which is the rule the check
actually enforces.

The packaged README tells testers the build expires, where to get the
next one, and that unzipping it over the folder keeps their four files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:35:22 -05:00
CydandClaude Opus 5 8ba2d4fc86 Zipping a release no longer costs you your own settings
The file-preserving repack landed with the restore before the zip and
skipped entirely under -Zip, to keep somebody's callsign and key
bindings out of a release. It worked, but at the price of -Zip quietly
wiping the settings out of dist\ - captured, then discarded.

Both properties are available at once by moving the restore after the
archive is taken: the zip is built from a folder with none of the
player's files in it, and they go back into dist\ immediately
afterwards. A fresh unzip still looks like a first run, and cutting a
release costs the person cutting it nothing.

Verified: edited all four files, packed with -Zip, and confirmed the
archive contains none of them - 1003 entries, nothing loose at the root -
while all four are still in dist\ with their edits intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:02:13 -05:00
CydandClaude Opus 5 a0a0ad51d1 Repacking dist keeps the player's files
The game stopped shipping environ.ini so a new build could land on an
existing folder without replacing anyone's settings. pack-dist.ps1 then
went on rebuilding dist\ from nothing every time, which threw away
environ.ini, bindings.txt, pilot.cfg and mfd_layout.cfg on every repack -
the one place the promise did not hold, and the folder we do most of our
own testing in.

They are now carried across the rebuild. -Fresh wipes them too, for
checking what a genuine first run does.

Restored last, after the tree is rebuilt, so nothing the pack writes can
land on top of them. Not restored at all under -Zip: the archive is taken
from dist\, so a preserved file would otherwise travel to whoever
downloads the release, and a release should always be built from a folder
with none of them in it.

Also fixes three paragraphs of the packaged README that have been saying
the opposite of the truth since the change: that environ.ini ships, that
it is the only one of the four that does, and that bindings.txt is the
only one never overwritten. Those edits were made at the time with
PowerShell .Replace() calls that silently matched nothing - which is
exactly why the same corrections here are made with an editor that fails
loudly instead.

Verified by editing all four files, repacking, and reading the first line
of each back out of the rebuilt folder; then again with -Fresh to confirm
all four are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:56:01 -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 68f5780efa The cockpit clock counts the console's clock
A race ends when the console says so, but the countdown on the map
display was computed from the engine clock and its own idea of when the
race started - QueryPerformanceCounter from Application::gameStarted,
against the console's GetTickCount from gRunStartTick. Two clocks, two
epochs, two threads. They agreed to within a frame in the ordinary case,
which is why nobody noticed.

They do not agree at all when RP412MISSIONSECONDS is set: the override
shortens the CONSOLE's length and leaves the egg's alone, so a 25-second
test race displayed a clock counting down from 5:00 and was stopped with
4:35 still showing.

gMissionClockHook (APPMGR.h, alongside the gPerFrameHook it mirrors) lets
the console answer for the countdown when it is marshalling. NULL, or a
console that has no answer yet, falls back to exactly the old
computation - which is what the arcade -net pods, lobby members and
mission review all take, none of them running a console locally. A
member's clock is anchored by the console's RunMission arriving over the
wire anyway, so it starts within one latency of correct and only drifts
at the rate the two crystals differ.

Two things come out of it beyond the clock itself. The camera directors
switch behaviour at "30 seconds left" (DIRECTOR.cpp, RPDIRECT.cpp) and
were reading the same free-running number, so the dramatic end-of-race
camera and the actual buzzer were on different clocks too; they now
share one. And the countdown holds at 00:00 instead of going negative -
the console polls at 250 ms, so zero always arrives slightly before the
stop is dispatched.

The hook is guarded on gWatchedApp == application. Nothing ever
uninstalls it, so a player who hosts a race and then joins somebody
else's lobby still has it wired up, and in that race the console is a
bystander holding the previous mission's gLengthMs and gRunStartTick.

Verified by running a 25-second race with the menu still set to 5:00 and
photographing the map display: 00:17, 00:01, then 00:00 held while
"time expired - stopping mission" went to the log. Captures use
PrintWindow rather than CopyFromScreen - the first attempt grabbed the
desktop sitting in front of the Map window, which is somebody's screen
contents written to disk, and those files were deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:39:19 -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
CydandClaude Opus 5 72bb3b394f The controls map becomes the handbook
It stopped being a controls map somewhere around the display arrangement
and the joystick wizard, and a page called CONTROLS.html is the wrong
place to look for what a file in the game folder does. So:
docs/rp412-controls.html is now docs/rp412-handbook.html and ships as
HANDBOOK.html, titled to match.

The new section answers the question the page could not: what is in the
game folder and which of it is yours. Four files are - environ.ini,
bindings.txt, pilot.cfg, mfd_layout.cfg - and only the first ships, so a
fresh unzip has none of the others and deleting one simply starts that
part over. A second table covers the shipped engine data, which nobody
should edit but everybody eventually wonders about: which INI the gauge
canvas comes from, why there are audio mixer tables for hardware that has
not existed since 1995, and that JOYSTICK.INI is the legacy path rather
than anything the new joystick support reads.

The callout carries the two that actually catch people, both of which
have caught us during this work: environ.ini is applied OVER the
environment, so a variable set in a shell loses to an uncommented line in
the file; and bindings.txt is never overwritten once it exists, which is
what protects a player's edits and also why an update's new defaults do
not appear until it is deleted.

CONTROLS.txt keeps its name. It is the controls half in plain text for
Notepad, which is still exactly what it is, and the README now says so
rather than describing the two as the same thing.

Verified by rendering the packed HANDBOOK.html headless: both tables and
the callout sit in the page's own components, the footer names the new
file, and the version stamp still flows through - the shipped copy reads
4.12.96 from the build it was packed with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:25:34 -05:00
CydandClaude Opus 5 a1d2de591c The patch number is the commit count
A hand-maintained version says what somebody remembered to type. Pinning
it to the repository means a binary always names the commit it came from,
so a log from a test machine settles which changes are in it.

stamp-version.ps1 runs as RP_L4's pre-build step and writes the generated
RP_L4\rpl4build.h:

  #define RP412_VERSION       "4.12.96"
  #define RP412_VERSION_LONG  "4.12.96 (a1b2c3d)"

The hash beside the number names the commit exactly; a trailing '+' means
the tree had uncommitted changes to TRACKED files when it was built, which
is the state a puzzling bug report usually comes from. Untracked files do
not count - one scratch document in the tree would otherwise mark every
build dirty and the marker would stop meaning anything.

Generated rather than committed, and gitignored, because a hardcoded
number cannot work: the commit that records "4.12.96" is itself commit 96,
so the file is stale the moment it lands. The header is rewritten only
when the stamp changes, so ordinary rebuilds do not drag RPL4.CPP through
a recompile.

pack-dist.ps1 reads that header instead of asking git again - a commit
between building and packing would otherwise have the zip claiming a
version the binary inside it does not report - and warns when the build
it is packing came from a modified tree. The README banner, the zip name
and the shipped CONTROLS.html all take the same number.

Numbering stays ordered: 95 commits so far, so 4.12.95 follows 4.12.7 and
every future build sorts after it. Only the "4.12" line is set by hand,
at the top of the script.

Two things the wiring turned up:

  Windows PowerShell turns a native command's stderr into ErrorRecords,
  so with $ErrorActionPreference = 'Stop' git's routine "LF will be
  replaced by CRLF" warning threw straight past the dirty check and
  stamped a modified tree as clean. Every git call now goes through cmd,
  which keeps stderr out of PowerShell's error stream entirely.

  The script ended on "git diff --quiet", which exits 1 to mean "there
  are changes" - as a pre-build step that failed the build on exactly
  the tree a developer builds in. It exits 0 explicitly now.

Verified: deleting the header and building recreates it; a second build
reports "(unchanged)" and leaves the timestamp alone; a build on a
modified tree succeeds and stamps 4.12.95 (c1729e4+); and the packed game
logs "Red Planet 4.12.95 (c1729e4+)" on its first line while README.txt
and CONTROLS.html in the same package both read 4.12.95.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:38:38 -05:00
CydandClaude Opus 5 c1729e40c7 The callsign and loadout outlive the session
The loadout has always survived a race - gPersistSelection is why the
setup screen reopens the way you left it - but only for as long as the
process lived. Closing the game was a reset, and the callsign is the one
thing on that screen a player types rather than picks, so it was the one
they had to type again every launch.

pilot.cfg beside bindings.txt now holds both, KEY=VALUE like environ.ini,
one line per group.

BT411 solved this first, in fe_last.ini, and its own comment says why
RP412 never grew the file: BT411 relaunches the process between missions
and would otherwise forget the loadout mid-evening, while RP412 stays in
one process. That made the gap invisible from inside a session and total
across two. Same idea, two differences worth naming:

  BT411 saves only on a launch - it returns before SavePersisted when
  the player quits. That loses a callsign typed by somebody who then
  changed their mind, which is exactly the moment this feature exists
  for, so this writes on the way out however the menu is left:
  launching, stepping into a lobby, or EXIT GAME.

  BT411 takes the stored name as-is. A callsign here is quoted into
  frontend.egg, joined into a comma-separated list for the results
  screen, and published as Steam lobby member data, so a comma alone
  would split one pilot into two on the score sheet. SanitizeCallsign
  drops what could end a token early and is applied to what is typed as
  well as to what is read, so the file cannot hold what the game will
  not accept.

Every index is range-checked on the way in, against the group's real
size rather than a constant - the track list is the one that moves,
since football and the death race carry different maps, so it answers
for whichever scenario is selected. The track is re-checked after the
whole file is read as well, because the file is parsed in the order it
happens to be written and the scenario may arrive second.

Written unconditionally rather than only on a change: it is a few
hundred bytes, and writing every time means a value hand-edited out of
range comes back corrected instead of being quietly re-rejected on every
launch forever.

Verified by round trip. A callsign typed and then abandoned via EXIT
GAME is in the file and back in the box next launch. A file carrying
   Ba"d,Na#me   loads as BadName; an empty one falls back to Pilot. A
full loadout round-trips value for value; vehicle=999 and color=-3 come
back 0 with the rest untouched; and track=9 under football falls back to
0 both when the scenario is read first and when it is read second, which
is the case the second check exists for.

One correction to my own test rig on the way: cross-process
SetWindowText on an EDIT updates the cached caption, which an external
GetWindowText then reads back happily, while leaving the control's own
buffer alone - so the harness looked right and the game correctly saw
the old name. WM_SETTEXT is marshalled properly and shows the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:56:15 -05:00
CydandClaude Opus 5 f20547cb25 Controls page covers the joystick setup
CONTROLS.md gained this in the port; the page that ships beside it did
not, and the page is the one people actually look at.

A "Bring your own stick" section before Rebinding: what DirectInput is
and why it needs telling what its axes are for, joyconfig.bat as the
answer, and the four beats of running it. The callout carries the reason
the wizard reads direction rather than asking you to know it, since that
is the part that looks like a quirk until it is explained. Then what it
writes beside the grammar that produced it, and a table for the two
rules the pod's shape asks for - the signed Pedals axis working the
pedal pair, and a real lever owning the throttle channel.

The reference tables were left incomplete by the port and are now
whole: Pedals joins the axis list, the joy rows join the grammar block,
and DirectInput's own axis names get a row of their own.

Written in the page's existing components - glance, callout, two-col,
tbl-scroll - rather than new ones. The two <kbd> elements I reached for
first are not styled anywhere on this page and would have rendered as
browser defaults, so they are <code> like every other inline literal
here.

No version change: 4.12.7 is republished with the page in it.

Verified by rendering the packed CONTROLS.html headless at 1280 wide and
reading the section back - heading, lede, the four-panel strip, callout,
both code blocks, both tables, and the reference rows all sit in the
page's own idiom, and the tag balance is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:27:19 -05:00
448 changed files with 5961 additions and 811 deletions
+16
View File
@@ -45,6 +45,15 @@ rpl4.log
# on somebody's desk. # on somebody's desk.
mfd_layout.cfg mfd_layout.cfg
# The pilot's callsign, remembered between sessions. Whoever is sitting
# at this machine, which is not the repo's business.
pilot.cfg
# Generated by stamp-version.ps1 as RP_L4's pre-build step. The patch
# number in it IS this repository's commit count, so a committed copy
# would be stale the moment it was committed.
/RP_L4/rpl4build.h
# Build-output static libs that land in lib/ (the two committed dependency # Build-output static libs that land in lib/ (the two committed dependency
# libs, OpenAL32.lib and libsndfile-1.lib, stay tracked). # libs, OpenAL32.lib and libsndfile-1.lib, stay tracked).
/lib/Munga_L4.lib /lib/Munga_L4.lib
@@ -75,3 +84,10 @@ assets/**/last.spl
# packaged releases (attached to Gitea releases, not tracked) # packaged releases (attached to Gitea releases, not tracked)
RedPlanet-*.zip RedPlanet-*.zip
# Crash dumps sent in by testers. Read them with cdb against the matching
# Release\rpl4opt.pdb - the PE timestamp in the dump says which build, and
# the symbols only mean anything if it matches. They are not ours to keep
# in the history: a minidump carries process memory and the sender's own
# file paths.
/Crashdmp/
+50 -3
View File
@@ -50,10 +50,57 @@ The solution is [WinTesla.sln](WinTesla.sln) with four v143 projects:
Build order is resolved by `ProjectReference` (RP_L4 and RPL4TOOL both reference Build order is resolved by `ProjectReference` (RP_L4 and RPL4TOOL both reference
Munga_L4). Munga_L4).
**Versioning:** the patch number *is* the repository's commit count, so a
build always names the commit it came from and there is no question about
which changes a given binary contains.
[stamp-version.ps1](stamp-version.ps1) runs as RP_L4's pre-build step and
writes the generated, uncommitted `RP_L4\rpl4build.h`:
```
#define RP412_VERSION "4.12.96"
#define RP412_VERSION_LONG "4.12.96 (a1b2c3d)"
```
The game logs the long form on its first line. A trailing `+` on the hash
means the tree had uncommitted changes to tracked files when it was built —
useful when a test machine reports something a clean build cannot reproduce.
Only the `4.12` product line is set by hand, at the top of the script.
**Test builds expire.** `$expireDays` at the top of the same script is the
shelf life in days (currently **14**, counted from the day it was *built*,
not the day the code was written — rebuilding an old commit gives a usable
binary rather than one born stale). An expired build says so in a dialog,
names its version and expiry date, points at the releases page, and exits
without running. It stops a tester spending an afternoon on something that
was fixed a week ago.
> ⚠️ **Set `$expireDays = 0` for a real release.** A shipped build that
> expires is a catastrophe, and that one line is what decides it.
`RP412NOEXPIRY=1` waives the check when an old build has to be run on
purpose, and says so in the log so a waived build is never mistaken for a
current one. It is deliberately **not** listed in `environ.ini` — a bypass
every tester can see is a bypass every tester will use. Negative
`$expireDays` backdates the expiry, which is how the refusal gets tested
without touching the machine's clock.
The header is deliberately not committed: the commit that recorded a
hardcoded number would itself change the count, so the file would be stale
the moment it landed. It is rewritten only when the stamp actually changes,
so ordinary rebuilds do not recompile `RPL4.CPP` for nothing. Building
outside a git checkout stamps `4.12.x (no repository)` rather than inventing
a number that would sort against real ones.
**Packaging:** [pack-dist.ps1](pack-dist.ps1) assembles a runnable game into **Packaging:** [pack-dist.ps1](pack-dist.ps1) assembles a runnable game into
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, desktop `dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, launch scripts,
`environ.ini`, `start-windowed.bat`, README). Pass `-Zip` to also produce HANDBOOK.html, README). It deliberately does **not** write `environ.ini`
`dist\RedPlanet412-prototype.zip` for handing to someone else. the exe carries that template and writes it on first run
([RP_L4/RPL4ENVIRON.cpp](RP_L4/RPL4ENVIRON.cpp)), so a tester can drop a new
build over an old folder without losing their settings. Pass `-Zip` to also produce
`RedPlanet-<version>.zip` for handing to someone else. It reads the version
from `rpl4build.h` rather than asking git again, so the package and the
binary inside it cannot disagree, and it warns if the build it is packing
came from a modified tree.
## 3. VS2022 migration notes (what changed and why) ## 3. VS2022 migration notes (what changed and why)
+21
View File
@@ -648,10 +648,25 @@ Time endIntercom = Now();
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// //
if (GetApplicationState() == RunningMission) if (GetApplicationState() == RunningMission)
{
//
// Ask the console first: it owns the clock that actually ends the
// race, so this is the countdown the buzzer will agree with. Its
// own reckoning is the fallback for everything with no console of
// its own - see gMissionClockHook in APPMGR.h.
//
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{ {
secondsRemainingInGame = secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted); currentMission->GetGameLength() - (Now() - gameStarted);
} }
}
routePacketFinished = False; routePacketFinished = False;
CLEAR_FOREGROUND_PROCESSING(); CLEAR_FOREGROUND_PROCESSING();
@@ -1125,6 +1140,12 @@ void
Check(this); Check(this);
Check(egg_notation_file); Check(egg_notation_file);
//
// Forget every peer's clock offset: the hosts in the next race are not
// the hosts in the last one, and a HostID gets reused.
//
NetClock_Reset();
// //
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Create mission from egg notation file // Create mission from egg notation file
+3
View File
@@ -15,6 +15,9 @@ Logical gConsoleMarshalsLaunch = False;
// losing the console mid-mission ends it (lobby-member races) // losing the console mid-mission ends it (lobby-member races)
Logical gConsoleLossEndsMission = False; Logical gConsoleLossEndsMission = False;
// the console's countdown, when a console is marshalling (see APPMGR.h)
Logical (*gMissionClockHook)(Scalar *seconds_remaining) = NULL;
ApplicationManager* ApplicationManager::CurrentAppManager = NULL; ApplicationManager* ApplicationManager::CurrentAppManager = NULL;
ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this) ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this)
+22
View File
@@ -20,6 +20,28 @@ extern Logical gConsoleMarshalsLaunch;
// console to return, exactly as always. // console to return, exactly as always.
extern Logical gConsoleLossEndsMission; extern Logical gConsoleLossEndsMission;
//
// The console's own countdown, when there is a console to ask.
//
// A mission ends when the console says so, but secondsRemainingInGame was
// computed here from the engine clock and its own idea of when the race
// started - a different clock, from a different epoch, than the one that
// actually fires the buzzer. The two agree to within a frame or so, which
// is why nobody noticed, but they are not the same number: the cockpit
// clock could read 0:00 with the race still running, and the camera
// directors' "last 30 seconds" behaviour switched on the engine's reading
// rather than on the real remaining time.
//
// Set by the console when it is marshalling; NULL restores the engine's
// own reckoning, which is what the arcade -net pods, lobby members and
// mission review all use (none of them run a console locally, and their
// clock is anchored by the console's RunMission arriving anyway).
//
// Returns False when it has no answer yet - the window between the
// application reaching RunningMission and the console noticing.
//
extern Logical (*gMissionClockHook)(Scalar *seconds_remaining);
class ApplicationManager : public Node class ApplicationManager : public Node
{ {
public: public:
+10 -2
View File
@@ -94,8 +94,16 @@ void
} }
headEntitySocket.Add(entity); headEntitySocket.Add(entity);
alDistanceModel(AL_LINEAR_DISTANCE); // FIDELITY (docs/SOUND.md F3/F10): the engine computes the AUTHORED distance
alDopplerFactor(0.3f); // attenuation curve (AUDIO.INI amplitude_rolloff knee/exponent ->
// AudioLocation::distanceVolumeScale) and the AUTHORED doppler-cents model
// (doppler_range=600 / speed_of_sound=250) on every spatial update. Disable
// OpenAL's own models so they cannot double-apply or fight them:
// AL_LINEAR_DISTANCE faded distant audio to zero on a straight line where the
// authored curve still sits near 44% at the clip edge, and AL doppler ran at
// the wrong constants with a sign-inverted velocity feed.
alDistanceModel(AL_NONE);
alDopplerFactor(0.0f);
#if 0 #if 0
// //
+25
View File
@@ -377,8 +377,28 @@ void
//------------------------------------------------------------------------ //------------------------------------------------------------------------
// Step through each block until there are no more remaining, and send the // Step through each block until there are no more remaining, and send the
// update out the the simulation indicated by the subsystemID // update out the the simulation indicated by the subsystemID
//
// This is the only point on the receive path that knows WHOSE update
// this is - the records themselves carry a timestamp but not an owner -
// so the sender is published here for the net clock to align against.
// Every record in the message, and the damage zones nested inside them,
// came from the same machine in the same frame.
//------------------------------------------------------------------------ //------------------------------------------------------------------------
// //
//
// Only for an entity somebody else owns. Our own clock needs no
// aligning, and an update we somehow handed ourselves would otherwise
// drag lastUpdate back by a frame for no reason.
//
Check(application);
Check(application->GetHostManager());
Logical remote_owner =
GetOwnerID() != application->GetHostManager()->GetLocalHostID();
if (remote_owner)
{
NetClock_BeginUpdate(GetOwnerID());
}
while (stream.GetBytesRemaining()) while (stream.GetBytesRemaining())
{ {
Simulation::UpdateRecord *update = Simulation::UpdateRecord *update =
@@ -389,6 +409,11 @@ void
simulation->ReadUpdateRecord(update); simulation->ReadUpdateRecord(update);
stream.AdvancePointer(update->recordLength); stream.AdvancePointer(update->recordLength);
} }
if (remote_owner)
{
NetClock_EndUpdate();
}
Check_Fpu(); Check_Fpu();
} }
+45
View File
@@ -21,6 +21,31 @@
BitTrace Gauge_Renderer("Gauge Renderer"); BitTrace Gauge_Renderer("Gauge Renderer");
#endif #endif
//
// How long a single background pass may spend drawing gauges, in
// milliseconds. RP412GAUGESLICE tunes it; 0 restores the original
// behaviour of exactly one gauge per pass.
//
static long
GaugeSliceMs()
{
static long
slice = -1L;
if (slice < 0L)
{
const char
*setting = getenv("RP412GAUGESLICE");
slice = (setting != NULL) ? atol(setting) : 2L;
if (slice < 0L)
{
slice = 0L;
}
}
return slice;
}
//####################################################################### //#######################################################################
// Miscellaneous utilities // Miscellaneous utilities
//####################################################################### //#######################################################################
@@ -3682,8 +3707,28 @@ Logical
break; break;
case background: case background:
{
//-----------------------------------------------------------
// Draw gauges until the slice is spent, rather than exactly
// one per pass.
//
// The background loop is only guaranteed a single pass per
// frame; it gets more only while time remains before the
// frame is due. On a busy map the 3D foreground eats the
// whole budget, so a cycle of ninety-odd gauges takes
// ninety-odd frames to come round and the displays sit
// frozen for seconds. Working to a slice makes the refresh
// rate depend on elapsed time instead of on how much spare
// frame there happened to be.
//-----------------------------------------------------------
Time slice_end = Now();
slice_end += GaugeSliceMs();
do
{ {
result = ProcessOneActiveGauge(); result = ProcessOneActiveGauge();
}
while (result && taskMode == background && Now() < slice_end);
break; break;
} }
+181 -1
View File
@@ -264,6 +264,109 @@ Simulation::SharedData
// Model support // Model support
// //
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
//##########################################################################
// Net clock - see SIMULATE.h for why the sender's timestamp is estimated
// rather than used as it stands.
//##########################################################################
namespace
{
enum
{
netClockMaxPeers = 16,
// Samples per rolling minimum. A peer sends one record per
// simulation per frame, so at eight vehicles and 60 fps this is
// well under a second - fast enough to follow a route change,
// long enough that the minimum means something.
netClockWindow = 128,
// The furthest back we will believe a timestamp. Beyond this the
// packet is stale or the estimate is wrong, and extrapolating a
// vehicle half a second forward does more harm than the lag we
// are correcting.
netClockMaxLagTicks = 500
};
struct PeerClock
{
HostID host;
Logical inUse;
Logical settled;
long offsetTicks; // our clock - their clock
long windowMinTicks;
int windowCount;
};
PeerClock gPeerClocks[netClockMaxPeers];
HostID gUpdateSender = 0;
Logical gUpdateSenderValid = False;
Logical NetClockEnabled()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412NETCLOCK");
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
if (!enabled)
{
DEBUG_STREAM << "NetClock: disabled by RP412NETCLOCK=0 - "
<< "replicants dead-reckon from arrival time\n" << std::flush;
}
}
return enabled ? True : False;
}
PeerClock *FindPeer(HostID host)
{
PeerClock *free_slot = NULL;
for (int i = 0; i < netClockMaxPeers; ++i)
{
if (gPeerClocks[i].inUse)
{
if (gPeerClocks[i].host == host)
{
return &gPeerClocks[i];
}
}
else if (free_slot == NULL)
{
free_slot = &gPeerClocks[i];
}
}
if (free_slot != NULL)
{
free_slot->inUse = True;
free_slot->host = host;
free_slot->settled = False;
free_slot->offsetTicks = 0;
free_slot->windowMinTicks = 0;
free_slot->windowCount = 0;
}
return free_slot;
}
}
void NetClock_BeginUpdate(HostID sender)
{
gUpdateSender = sender;
gUpdateSenderValid = True;
}
void NetClock_EndUpdate()
{
gUpdateSenderValid = False;
}
void NetClock_Reset()
{
memset(gPeerClocks, 0, sizeof(gPeerClocks));
gUpdateSenderValid = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// //
void void
@@ -272,7 +375,84 @@ void
Check(this); Check(this);
Check_Pointer(message); Check_Pointer(message);
lastUpdate = Now(); // HACK - should be based upon message->timeStamp //
//------------------------------------------------------------------
// When this update arrived is not when it was taken. Put lastUpdate
// at the sender's sampling moment, expressed in our clock, so the
// dead reckoner extrapolates over the network latency instead of
// starting from scratch once it has already elapsed.
//------------------------------------------------------------------
//
long now_ticks = Now().ticks;
long local_ticks = now_ticks;
PeerClock *peer = gUpdateSenderValid && NetClockEnabled()
? FindPeer(gUpdateSender) : NULL;
if (peer != NULL)
{
//
// sample = trueOffset + oneWayLatency, so the running minimum
// converges on the offset from above.
//
long sample = now_ticks - message->timeStamp.ticks;
if (!peer->settled)
{
peer->settled = True;
peer->offsetTicks = sample;
peer->windowMinTicks = sample;
peer->windowCount = 0;
DEBUG_STREAM << "NetClock: host " << peer->host
<< " first sample, offset " << sample << " ms\n" << std::flush;
}
else
{
if (sample < peer->windowMinTicks)
{
peer->windowMinTicks = sample;
}
if (sample < peer->offsetTicks)
{
peer->offsetTicks = sample; // a shorter path: believe it now
}
if (++peer->windowCount >= netClockWindow)
{
//
// Close the window: adopt its minimum even if it is
// LARGER than the running estimate, which is how the
// figure follows clock drift and a route that got
// slower rather than staying pinned to one old packet.
//
long moved = peer->windowMinTicks - peer->offsetTicks;
if (moved > 50 || moved < -50)
{
DEBUG_STREAM << "NetClock: host " << peer->host
<< " offset " << peer->offsetTicks << " -> "
<< peer->windowMinTicks << " ms\n" << std::flush;
}
peer->offsetTicks = peer->windowMinTicks;
peer->windowMinTicks = sample;
peer->windowCount = 0;
}
}
local_ticks = message->timeStamp.ticks + peer->offsetTicks;
//
// Never ahead of our own clock, and never further back than we
// are willing to extrapolate.
//
if (local_ticks > now_ticks)
{
local_ticks = now_ticks;
}
else if (now_ticks - local_ticks > netClockMaxLagTicks)
{
local_ticks = now_ticks - netClockMaxLagTicks;
}
}
lastUpdate.ticks = local_ticks;
SetSimulationState(message->simulationState); SetSimulationState(message->simulationState);
Check_Fpu(); Check_Fpu();
} }
+35
View File
@@ -4,6 +4,41 @@
#include "receiver.h" #include "receiver.h"
#include "time.h" #include "time.h"
#include "resource.h" #include "resource.h"
#include "hostid.h"
//##########################################################################
//########################### Net clock ##############################
//##########################################################################
//
// Aligning a peer's clock with ours, so a replicant is dead-reckoned from
// when its update was SENT rather than when it happened to arrive.
//
// Every update record carries the sender's own timestamp. The receiver
// used to throw it away and stamp lastUpdate with its own Now() - the
// original code says so: "HACK - should be based upon message->timeStamp".
// The dead reckoner then extrapolates 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 arcade LAN that was invisible. Over Steam Datagram Relay it
// is a constant 50-150 ms of positional lag - a bias, not jitter.
//
// The timestamp cannot be used raw: two machines' clocks share no epoch,
// both being QueryPerformanceCounter since their own boot. So we estimate
// the offset per peer. Each arriving record gives
//
// sample = ourNow - theirStamp = trueOffset + oneWayLatency
//
// and since latency is never negative, the SMALLEST sample seen is the
// closest to the true offset. Taking a minimum over a short rolling
// window tracks crystal drift and re-adapts when the route changes,
// instead of being pinned forever by one lucky packet.
//
// RP412NETCLOCK=0 turns the whole thing off and restores the arrival-time
// behaviour, so a test machine can A/B it without a rebuild.
//
void NetClock_BeginUpdate(HostID sender); // around one message's records
void NetClock_EndUpdate();
void NetClock_Reset(); // forget every peer (new mission)
class Simulation__SharedData; class Simulation__SharedData;
class Simulation__IndexData; class Simulation__IndexData;
+177
View File
@@ -0,0 +1,177 @@
//###########################################################################
//
// L4AUDEFX.cpp -- OpenAL EFX bridge (docs/SOUND.md, findings F9 and F11).
// See L4AUDEFX.h for the fidelity rationale.
//
//###########################################################################
#include "mungal4.h"
#pragma hdrstop
#include "l4audefx.h"
#include "openal/alc.h"
#include "openal/efx.h"
#ifndef AL_EFFECT_EAXREVERB
#define AL_EFFECT_EAXREVERB 0x8000 // newer efx.h constant; OpenAL Soft supports it
#endif
namespace
{
bool s_available = false;
ALuint s_reverbSlot = 0;
ALuint s_reverbEffect = 0;
ALuint s_scratchFilter = 0;
LPALGENEFFECTS p_alGenEffects = 0;
LPALEFFECTI p_alEffecti = 0;
LPALEFFECTF p_alEffectf = 0;
LPALGENAUXILIARYEFFECTSLOTS p_alGenAuxiliaryEffectSlots = 0;
LPALAUXILIARYEFFECTSLOTI p_alAuxiliaryEffectSloti = 0;
LPALAUXILIARYEFFECTSLOTF p_alAuxiliaryEffectSlotf = 0;
LPALGENFILTERS p_alGenFilters = 0;
LPALFILTERI p_alFilteri = 0;
LPALFILTERF p_alFilterf = 0;
}
bool EFX_Available()
{
return s_available;
}
bool EFX_Initialize(float global_reverb_scale)
{
ALCcontext *context = alcGetCurrentContext();
if (context == 0)
{
return false;
}
ALCdevice *device = alcGetContextsDevice(context);
if (device == 0 || !alcIsExtensionPresent(device, "ALC_EXT_EFX"))
{
Tell("L4AUDEFX: ALC_EXT_EFX not present - filters and reverb inert\n");
return false;
}
p_alGenEffects = (LPALGENEFFECTS)alGetProcAddress("alGenEffects");
p_alEffecti = (LPALEFFECTI)alGetProcAddress("alEffecti");
p_alEffectf = (LPALEFFECTF)alGetProcAddress("alEffectf");
p_alGenAuxiliaryEffectSlots = (LPALGENAUXILIARYEFFECTSLOTS)alGetProcAddress("alGenAuxiliaryEffectSlots");
p_alAuxiliaryEffectSloti = (LPALAUXILIARYEFFECTSLOTI)alGetProcAddress("alAuxiliaryEffectSloti");
p_alAuxiliaryEffectSlotf = (LPALAUXILIARYEFFECTSLOTF)alGetProcAddress("alAuxiliaryEffectSlotf");
p_alGenFilters = (LPALGENFILTERS)alGetProcAddress("alGenFilters");
p_alFilteri = (LPALFILTERI)alGetProcAddress("alFilteri");
p_alFilterf = (LPALFILTERF)alGetProcAddress("alFilterf");
if (!p_alGenEffects || !p_alEffecti || !p_alEffectf
|| !p_alGenAuxiliaryEffectSlots || !p_alAuxiliaryEffectSloti || !p_alAuxiliaryEffectSlotf
|| !p_alGenFilters || !p_alFilteri || !p_alFilterf)
{
Tell("L4AUDEFX: EFX entry points missing - filters and reverb inert\n");
return false;
}
alGetError();
p_alGenAuxiliaryEffectSlots(1, &s_reverbSlot);
p_alGenEffects(1, &s_reverbEffect);
if (alGetError() != AL_NO_ERROR)
{
return false;
}
//
// EAXReverb where available (OpenAL Soft: yes), plain reverb otherwise.
//
p_alEffecti(s_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_EAXREVERB);
if (alGetError() != AL_NO_ERROR)
{
p_alEffecti(s_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_REVERB);
}
p_alAuxiliaryEffectSloti(s_reverbSlot, AL_EFFECTSLOT_EFFECT, (ALint)s_reverbEffect);
//
// The authentic wet level: the original sent CC91 = global_reverb_scale on
// every 3D channel, so one global slot gain reproduces the same uniform
// send. RP authors 0.35 (AUDIO.INI); BT used 0.3.
//
p_alAuxiliaryEffectSlotf(s_reverbSlot, AL_EFFECTSLOT_GAIN,
(global_reverb_scale < 0.0f) ? 0.0f :
(global_reverb_scale > 1.0f) ? 1.0f : global_reverb_scale);
//
// LOWPASS only, deliberately. A bandpass would have been convenient -- one
// direct filter carrying both the authored brightness model and a bass trim
// -- but the OpenAL this game ships (Creative's, via oalinst.exe; renderer
// reports "Generic Software") implements ONLY AL_FILTER_LOWPASS. It rejects
// both HIGHPASS and BANDPASS, verified on the build machine. Asking for one
// leaves an error pending, which the check below would read as total EFX
// failure and silently take the reverb down with it.
//
p_alGenFilters(1, &s_scratchFilter);
p_alFilteri(s_scratchFilter, AL_FILTER_TYPE, AL_FILTER_LOWPASS);
if (alGetError() != AL_NO_ERROR)
{
//
// No usable direct filter. The reverb slot above is independent of it,
// so keep the bridge alive and just make the filter path a no-op rather
// than losing F11 as well.
//
s_scratchFilter = 0;
Tell("L4AUDEFX: no lowpass filter available - brightness path inert\n");
}
s_available = (alGetError() == AL_NO_ERROR);
Tell("L4AUDEFX: " << (s_available ? "ready" : "failed")
<< " (reverb slot gain " << global_reverb_scale << ")\n");
return s_available;
}
void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf)
{
if (!s_available || s_scratchFilter == 0)
{
return;
}
if (gainhf < 0.001f) gainhf = 0.001f;
if (gainhf > 1.0f) gainhf = 1.0f;
//
// Nothing to do at unity -- detach rather than attach a filter that would
// only cost mixing work to achieve nothing.
//
if (gainhf >= 0.999f)
{
alSourcei(source, AL_DIRECT_FILTER, AL_FILTER_NULL);
alGetError();
return;
}
//
// Filter parameters are COPIED at attach time, so one scratch filter object
// serves every source -- no per-source filter allocation is needed.
//
p_alFilterf(s_scratchFilter, AL_LOWPASS_GAIN, 1.0f);
p_alFilterf(s_scratchFilter, AL_LOWPASS_GAINHF, gainhf);
alSourcei(source, AL_DIRECT_FILTER, (ALint)s_scratchFilter);
alGetError();
}
void EFX_AttachReverbSend(ALuint source)
{
if (!s_available)
{
return;
}
alSource3i(source, AL_AUXILIARY_SEND_FILTER, (ALint)s_reverbSlot, 0, AL_FILTER_NULL);
}
void EFX_ClearSourceEffects(ALuint source)
{
if (!s_available)
{
return;
}
alSourcei(source, AL_DIRECT_FILTER, AL_FILTER_NULL);
alSource3i(source, AL_AUXILIARY_SEND_FILTER, AL_EFFECTSLOT_NULL, 0, AL_FILTER_NULL);
alGetError(); // swallow any property complaint
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
//###########################################################################
//
// L4AUDEFX.h -- OpenAL EFX bridge for the authored filter/reverb chains
// (docs/SOUND.md, findings F9 and F11).
//
// The original drove the AWE32's initial-filter-cutoff NRPN (21) every frame
// -- brightness x the distance high-frequency rolloff -- and sent CC91 reverb
// on the 3D channels (global_reverb_scale=0.35 in RP's AUDIO.INI) while
// keeping the cockpit DirectPatch channels dry. The OpenAL port computed
// both and applied neither: GetHighFreqCutoffScale() had no callers at all
// and every CC91 send site sat inside a comment block, so RP played
// spectrally full-bright at every distance and bone-dry everywhere.
//
// This bridge reproduces both through OpenAL Soft's EFX extension: one
// EAXReverb auxiliary slot plus a scratch AL_FILTER_LOWPASS whose parameters
// are copied at attach time. Without ALC_EXT_EFX it stays inert and every
// entry point below is a no-op, so the game still runs on a bare OpenAL.
//
//###########################################################################
#include "openal/al.h"
//
// Load the EFX entry points, create the reverb slot (gain = the authored
// global_reverb_scale) and the scratch lowpass. Call once, with the AL
// context current. Returns false (and stays inert) without ALC_EXT_EFX.
//
bool EFX_Initialize(float global_reverb_scale);
bool EFX_Available();
//
// Per-frame direct-path filter: gainhf is the linear high-frequency gain at the
// EFX 5 kHz reference, carrying the authored brightness x distance model.
// Callers map the AWE cutoff through EFX_CutoffScaleToGainHF below.
//
// At unity the filter is detached rather than attached at no-op settings.
//
// NOTE: this is a LOWPASS and can only ever be one. The OpenAL this game ships
// (Creative's) implements no other filter type -- see L4AUDEFX.cpp -- so the
// bass trim could not ride here as a bandpass GAINLF and lives in the resource
// loader instead (RPApplyBassTrim, L4AUDRES.cpp).
//
void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf);
//
// AWE NRPN 21 curve -> EFX gainhf. cutoff_scale is [0,1] of the 100-8000 Hz
// span; approximated as the attenuation of a 2-pole lowpass at the 5 kHz
// reference. Curve shape is approximate, endpoints exact.
//
inline float EFX_CutoffScaleToGainHF(float cutoff_scale)
{
if (cutoff_scale < 0.0f) cutoff_scale = 0.0f;
if (cutoff_scale > 1.0f) cutoff_scale = 1.0f;
float cutoff_hz = 100.0f + cutoff_scale * 7900.0f;
float g = (cutoff_hz / 5000.0f) * (cutoff_hz / 5000.0f);
return (g > 1.0f) ? 1.0f : ((g < 0.001f) ? 0.001f : g);
}
//
// Wet-exterior routing: attach the source's auxiliary send to the reverb slot
// (Dynamic3D / Static3D). Direct cockpit sources stay dry.
//
void EFX_AttachReverbSend(ALuint source);
//
// Drop both the direct-path filter and the reverb send. Required when a source
// is recycled through the pool: without it a dry cockpit sound can inherit the
// wet send of the 3D source that used the name before it, and a full-bright
// source can inherit a distant source's lowpass.
//
void EFX_ClearSourceEffects(ALuint source);
+266 -29
View File
@@ -2,6 +2,7 @@
#pragma hdrstop #pragma hdrstop
#include "l4audio.h" #include "l4audio.h"
#include "l4audefx.h"
#include "l4audlvl.h" #include "l4audlvl.h"
#include "l4app.h" #include "l4app.h"
#include "l4audrnd.h" #include "l4audrnd.h"
@@ -9,6 +10,49 @@
#include "..\munga\player.h" #include "..\munga\player.h"
#include "..\rp\vtv.h" #include "..\rp\vtv.h"
//
// FIDELITY (docs/SOUND.md): the AWE32 played each patch at the requested MIDI
// note relative to the sample root (60). RP's authored 4.10 content predates
// NoteAudioControlID -- its AudioControlID enum stops at AttackTimeAudioControlID
// -- so every source runs at DEFAULT_NOTE and this factor is 1.0 today. It is
// applied anyway so the pitch path is complete if authored notes ever appear,
// and to keep the shared MUNGA engine in step with the BT tree.
//
static inline float RPNotePitchFactor(int note_value)
{
return (float)pow(2.0, ((double)note_value - 60.0) / 12.0);
}
//
// FIDELITY (docs/SOUND.md F12): the authored DirectPatchSource `position=`
// enum picked a SOUND CARD (front pair for Front/FrontLeft/FrontRight, rear
// pair for Rear/RearLeft/RearRight) and a MIDI pan (CC10 centre/left/right).
// The port read audioPosition from the stream and then discarded it -- every
// cockpit sound played dead centre because SetupPatch pins each source
// AL_SOURCE_RELATIVE at the origin.
//
// Sources are listener-relative and no AL_ORIENTATION is ever set, so OpenAL's
// default listener frame applies: facing -Z with +Y up. Front is therefore
// -Z, rear +Z, left -X, right +X; the corner values combine both at equal
// weight. RP's own content only ever authors Front (28 sites) and Rear (13),
// but the corners are mapped for completeness since the enum allows them.
//
static void RPGetDirectPatchPosition(DirectPatchPosition p, float *x, float *z)
{
const float diag = 0.7071068f; // unit vector split across both axes
switch (p)
{
case FrontDirectPatchPosition: *x = 0.0f; *z = -1.0f; break;
case RearDirectPatchPosition: *x = 0.0f; *z = 1.0f; break;
case FrontLeftDirectPatchPosition: *x = -diag; *z = -diag; break;
case FrontRightDirectPatchPosition: *x = diag; *z = -diag; break;
case RearLeftDirectPatchPosition: *x = -diag; *z = diag; break;
case RearRightDirectPatchPosition: *x = diag; *z = diag; break;
default: *x = 0.0f; *z = 0.0f; break;
}
}
//############################################################################# //#############################################################################
//####################### L4AudioSpatialization ######################### //####################### L4AudioSpatialization #########################
//############################################################################# //#############################################################################
@@ -658,6 +702,19 @@ L4AudioSource::L4AudioSource(
AudioSource(stream, entity) AudioSource(stream, entity)
{ {
channelSet.count = GetAudioVoiceCount(); channelSet.count = GetAudioVoiceCount();
//
// sources[] was left uninitialized here, and RequestAudioChannels decides
// whether a slot already holds a source by asking alIsSource about it.
// Garbage that happened to match a live name meant silently sharing another
// source -- a real hazard now that the pool recycles small integer names.
// 0 is never a valid AL name.
//
for (int i = 0; i < (int)(sizeof(channelSet.sources) / sizeof(channelSet.sources[0])); i++)
{
channelSet.sources[i] = 0;
}
L4AudioSourceX(); L4AudioSourceX();
} }
@@ -923,6 +980,22 @@ void
patch_resource->SetDistance(GetDistanceToSource()); patch_resource->SetDistance(GetDistanceToSource());
patch_resource->SetupPatch(channelSet); patch_resource->SetupPatch(channelSet);
//
// FIDELITY (docs/SOUND.md F12): place the source per the authored position
// enum. SetupPatch has just pinned it AL_SOURCE_RELATIVE at the origin, so
// this must run after it. With AL_NONE as the distance model the unit
// radius costs no attenuation -- it only supplies direction.
//
{
float pos_x, pos_z;
RPGetDirectPatchPosition(audioPosition, &pos_x, &pos_z);
for (int i = 0; i < channelSet.count; i++)
{
alSource3f(channelSet.sources[i], AL_POSITION, pos_x, 0.0f, pos_z);
}
}
// //
// Set the channel to default control values // Set the channel to default control values
// //
@@ -1039,6 +1112,8 @@ void
// Apply filter scale // Apply filter scale
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// //
float direct_gainhf = 1.0f;
if (UseSourceBrightnessScale()) if (UseSourceBrightnessScale())
{ {
const MIDINRPNValue filter_resolution = 2;// HACK - should come from audio.ini const MIDINRPNValue filter_resolution = 2;// HACK - should come from audio.ini
@@ -1058,6 +1133,32 @@ void
{ {
lastMIDIFilterCutoff = midi_filter_cutoff; lastMIDIFilterCutoff = midi_filter_cutoff;
} }
//
// FIDELITY (docs/SOUND.md F9): this block previously computed the AWE
// initial-filter-cutoff (NRPN 21) and then only updated its own
// bookkeeping member -- the cutoff was never applied to anything, so
// authored brightness (ctl 5) was inert. Route it through EFX instead.
// Direct sources take brightness alone; the distance rolloff belongs to
// the 3D paths.
//
direct_gainhf = EFX_CutoffScaleToGainHF(
(float)midi_filter_cutoff / (float)MIDI_MAX_CONTROL_VALUE
);
}
//
// Applied OUTSIDE the brightness gate: a source that does not use brightness
// still has to be told, because the same call carries the player's bass trim.
// At unity on both axes it detaches the filter, so this costs nothing in the
// default configuration.
//
if (EFX_Available())
{
for (int i = 0; i < channelSet.count; i++)
{
EFX_SetSourceLowpassGainHF(channelSet.sources[i], direct_gainhf);
}
} }
// //
@@ -1069,17 +1170,24 @@ void
const MIDIValue volume_resolution = 2; // HACK - should come from audio.ini const MIDIValue volume_resolution = 2; // HACK - should come from audio.ini
volume_scale = CalculateSourceVolumeScale(); volume_scale = CalculateSourceVolumeScale();
L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation());
Check(application); //
L4AudioRenderer *audio_renderer = // FIDELITY (docs/SOUND.md F4): the original ended its volume path in MIDI
Cast_Object(L4AudioRenderer*, application->GetAudioRenderer()); // CC7, whose GM/SoundFont curve is concave -- amplitude ~ (v/127)^2. Writing
Check(audio_renderer); // volume_scale linearly to AL_GAIN played every intermediate level about
AudioHead *audio_head = audio_renderer->GetAudioHead(); // +6 dB hot at mid-scale and compressed the authored dynamic range.
Check(audio_head); //
// AL_MAX_DISTANCE is no longer written here: the distance model is AL_NONE
// (see MUNGA/AUDIO.cpp) so it has no effect, and DirectPatch is the
// non-positional cockpit path which never took distance attenuation anyway.
//
const float direct_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue());
PatchResource *direct_patch = Cast_Object(PatchResource*, GetAudioResource());
for (int i=0; i < channelSet.count; i++) for (int i=0; i < channelSet.count; i++)
{ {
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head)); alSourcef(channelSet.sources[i], AL_GAIN,
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale); volume_scale * volume_scale * direct_patch->GetZoneBassGain(i));
alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * direct_note_pitch);
} }
} }
@@ -1206,6 +1314,17 @@ void
patch_resource->SetDistance(GetDistanceToSource()); patch_resource->SetDistance(GetDistanceToSource());
patch_resource->SetupPatch(channelSet); patch_resource->SetupPatch(channelSet);
//
// FIDELITY (docs/SOUND.md F11): wet exterior. The original sent CC91 =
// global_reverb_scale on all four channels of a 3D source and CC91 = 0 on
// the cockpit DirectPatch channels -- a deliberate outside/inside contrast
// that the port lost when every send site was commented out.
//
for (int i = 0; i < channelSet.count; i++)
{
EFX_AttachReverbSend(channelSet.sources[i]);
}
/*patch_resource->SetDistance(GetDistanceToSource()); /*patch_resource->SetDistance(GetDistanceToSource());
for (i = 0; i < AudioChannelSetSize; i++) for (i = 0; i < AudioChannelSetSize; i++)
{ {
@@ -1405,15 +1524,71 @@ void
pitch_offset = CalculateSourcePitchOffset(); pitch_offset = CalculateSourcePitchOffset();
//
// FIDELITY (docs/SOUND.md F10): add the AUTHORED doppler. AUDIO.INI's
// doppler_range=600 / speed_of_sound=250 are computed into
// AudioLocation::dopplerCents on every spatial update, and the original
// applied it on this dynamic path only -- static and direct sources stayed
// doppler-free. GetDopplerCents() previously had no callers at all.
//
pitch_offset += GetAudioLocation()->GetDopplerCents();
double relativePitch = pow(2.0,pitch_offset/1200.0); double relativePitch = pow(2.0,pitch_offset/1200.0);
Clamp(relativePitch,0.5,2.0); Clamp(relativePitch,0.5,2.0);
//
// FIDELITY (docs/SOUND.md): relativePitch was computed here and never
// applied -- there was no AL_PITCH call anywhere in the tree, so the whole
// authored pitch chain (pitch_mix_offset / PitchAudioControlID, authored 97
// times across RP's sequences) was inert along with doppler.
//
// AL_VELOCITY is still written for bookkeeping but is now inert: doppler
// factor is 0 (see MUNGA/AUDIO.cpp) because this feed is sign-inverted
// relative to the AL_POSITION frame and never subtracted head velocity.
// AL_MAX_DISTANCE is dropped -- the distance model is AL_NONE and the
// authored curve is applied in CalculateSourceVolumeScale instead.
//
//
// FIDELITY (docs/SOUND.md F9): the AUTHORED high-frequency rolloff. The
// original drove the AWE filter cutoff on this path from
// highFreqCutoffScale x brightnessScale, ungated, on all four quadrant
// channels -- every moving 3D sound got duller with distance. AUDIO.INI
// still computes highFreqCutoffScale each frame (rolloff 2.0, knee 60,
// scale 0.005) and GetHighFreqCutoffScale() previously had zero callers.
//
float dynamic_gainhf = 1.0f;
if (EFX_Available())
{
PatchResource *filter_patch =
Cast_Object(PatchResource*, GetAudioResource());
Check(filter_patch);
Scalar filter_scale =
GetAudioLocation()->GetHighFreqCutoffScale() *
CalculateSourceBrightnessScale();
Scalar max_cutoff = (Scalar)filter_patch->GetMaxMIDIFilterCutoff();
Scalar midi_cutoff = filter_scale * max_cutoff;
dynamic_gainhf = EFX_CutoffScaleToGainHF(
(float)(midi_cutoff / (Scalar)MIDI_MAX_CONTROL_VALUE)
);
}
const float dynamic_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue());
PatchResource *dynamic_patch = Cast_Object(PatchResource*, GetAudioResource());
for (int i=0; i < channelSet.count; i++) for (int i=0; i < channelSet.count; i++)
{ {
alSource3f(channelSet.sources[i],AL_POSITION,pos.x,pos.y,pos.z); alSource3f(channelSet.sources[i],AL_POSITION,pos.x,pos.y,pos.z);
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale); alSourcef(channelSet.sources[i], AL_GAIN,
volume_scale * volume_scale * dynamic_patch->GetZoneBassGain(i));
alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * dynamic_note_pitch);
alSource3f(channelSet.sources[i],AL_VELOCITY,-relative_velocity.x,-relative_velocity.y,-relative_velocity.z); alSource3f(channelSet.sources[i],AL_VELOCITY,-relative_velocity.x,-relative_velocity.y,-relative_velocity.z);
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head));
if (EFX_Available())
{
EFX_SetSourceLowpassGainHF(channelSet.sources[i], dynamic_gainhf);
}
} }
} }
@@ -1432,22 +1607,25 @@ AudioControlValue
// //
Scalar Scalar
volume_scale = L4AudioSource::CalculateSourceVolumeScale(); volume_scale = L4AudioSource::CalculateSourceVolumeScale();
return volume_scale;
// //
// Update the spatial model that will result in the value // FIDELITY (docs/SOUND.md F3): apply the AUTHORED distance attenuation.
// for distance related volume attenuation // AUDIO.INI's knee/rolloff curve (amplitude_rolloff=2.0, knee=60,
// distance_scale=0.003, clipping_radius=550) is computed into
// distanceVolumeScale on every spatial update; this multiply was commented
// out behind an early return and AL_LINEAR_DISTANCE substituted, which faded
// distant audio on a straight line to zero instead of the authored
// 1/(1+(k(d-knee))^2). Restoring it also un-blinds the volume-based
// transient cull, the AudioWeighting voice-steal, and the CalculateMix
// ducking chain, all of which key off this value and were treating far
// sources as full-presence.
// //
/*Check(application); // The spatial model is already refreshed each Execute, so the
Check(application->GetAudioRenderer()); // UpdateSpatialModel call the original comment carried is not needed here.
UpdateSpatialModel(application->GetAudioRenderer()->GetAudioHead());
//
// Apply distance attenuation to the volume scale
// //
Check(GetAudioLocation()); Check(GetAudioLocation());
volume_scale *= GetAudioLocation()->GetDistanceVolumeScale(); volume_scale *= GetAudioLocation()->GetDistanceVolumeScale();
return volume_scale;*/ return volume_scale;
} }
//############################################################################# //#############################################################################
@@ -1468,6 +1646,26 @@ Static3DPatchSource::Static3DPatchSource(
MemoryStream_Read(stream, &useInternalSpatialization); MemoryStream_Read(stream, &useInternalSpatialization);
} }
//
//#############################################################################
//#############################################################################
//
AudioControlValue
Static3DPatchSource::CalculateSourceVolumeScale()
{
Check(this);
//
// FIDELITY (docs/SOUND.md F3): same authored distance attenuation as the
// dynamic path. The spatial model computes distanceVolumeScale on every
// execute; without this multiply statics were left to AL_LINEAR_DISTANCE.
//
Scalar volume_scale = L4AudioSource::CalculateSourceVolumeScale();
Check(GetAudioLocation());
volume_scale *= GetAudioLocation()->GetDistanceVolumeScale();
return volume_scale;
}
Logical Static3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head) Logical Static3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head)
{ {
if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState) if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState)
@@ -1694,6 +1892,16 @@ void
Check(patch_resource); Check(patch_resource);
patch_resource->SetDistance(GetDistanceToSource()); patch_resource->SetDistance(GetDistanceToSource());
patch_resource->SetupPatch(channelSet); patch_resource->SetupPatch(channelSet);
//
// FIDELITY (docs/SOUND.md F11): statics are exterior sources too, so they
// take the same wet send as the dynamic path.
//
for (int i = 0; i < channelSet.count; i++)
{
EFX_AttachReverbSend(channelSet.sources[i]);
}
/*for (i = 0; i < AudioChannelSetSize; i++) /*for (i = 0; i < AudioChannelSetSize; i++)
{ {
if ((channel = channelSet.GetNth(i)) != NULL) if ((channel = channelSet.GetNth(i)) != NULL)
@@ -1909,12 +2117,6 @@ void
Scalar volume_scale = CalculateSourceVolumeScale(); Scalar volume_scale = CalculateSourceVolumeScale();
L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation()); L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation());
Check(application);
L4AudioRenderer *audio_renderer =
Cast_Object(L4AudioRenderer*, application->GetAudioRenderer());
Check(audio_renderer);
AudioHead *audio_head = audio_renderer->GetAudioHead();
Check(audio_head);
Scalar pitch_offset; Scalar pitch_offset;
@@ -1933,12 +2135,47 @@ void
relative_position = audio_location->GetVectorToSource(); relative_position = audio_location->GetVectorToSource();
} }
//
// FIDELITY (docs/SOUND.md F4 + pitch): squared CC7 volume law, and the
// authored pitch chain applied -- see the DirectPatch/Dynamic3D paths. The
// original left static sources doppler-free, so no doppler term here.
// AL_MAX_DISTANCE dropped with the AL_NONE distance model; the authored
// curve is applied in CalculateSourceVolumeScale.
//
//Static models have their position freely available as relative positions and stand still //Static models have their position freely available as relative positions and stand still
//
// FIDELITY (docs/SOUND.md F9): statics took brightness alone in the
// original -- no distance term on this path.
//
float static_gainhf = 1.0f;
if (EFX_Available() && UseSourceBrightnessScale())
{
PatchResource *filter_patch =
Cast_Object(PatchResource*, GetAudioResource());
Check(filter_patch);
Scalar midi_cutoff =
CalculateSourceBrightnessScale() *
(Scalar)filter_patch->GetMaxMIDIFilterCutoff();
static_gainhf = EFX_CutoffScaleToGainHF(
(float)(midi_cutoff / (Scalar)MIDI_MAX_CONTROL_VALUE)
);
}
const float static_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue());
for (int i=0; i < channelSet.count; i++) for (int i=0; i < channelSet.count; i++)
{ {
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale); alSourcef(channelSet.sources[i], AL_GAIN,
volume_scale * volume_scale * patch_resource->GetZoneBassGain(i));
alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * static_note_pitch);
alSource3f(channelSet.sources[i],AL_POSITION,relative_position.x,relative_position.y,relative_position.z); alSource3f(channelSet.sources[i],AL_POSITION,relative_position.x,relative_position.y,relative_position.z);
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head));
if (EFX_Available())
{
EFX_SetSourceLowpassGainHF(channelSet.sources[i], static_gainhf);
}
} }
// //
+7
View File
@@ -541,6 +541,13 @@ public:
virtual Logical IsAudioSourceClipped(AudioHead *audio_head); virtual Logical IsAudioSourceClipped(AudioHead *audio_head);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Mix levels
//
public:
AudioControlValue
CalculateSourceVolumeScale();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// SetPosition // SetPosition
// //
+34 -1
View File
@@ -128,8 +128,25 @@ void
// #endif // #endif
SAMPLEINFO info; SAMPLEINFO info;
//
// Ask this patch for no more zones than it has.
//
// sourceSet.count was fixed when the audio source was built, from
// whichever level of detail was selected at the time. SetDistance
// re-picks the level of detail by distance immediately before this
// runs (see Static3DPatchSource::StartImplementation), and a
// further-away patch can have fewer zones than the one the source was
// sized for - so the count outruns this patch's zone list, and the
// zones past the end come back as "no such zone".
//
int zone_count = PRESET_getNumSamples(bankID,patchID);
if (zone_count > sourceSet.count)
{
zone_count = sourceSet.count;
}
//Attach buffers //Attach buffers
for (int i=0; i < sourceSet.count; i++) for (int i=0; i < zone_count; i++)
{ {
info = PRESET_getSampleInfo(bankID,patchID,i); info = PRESET_getSampleInfo(bankID,patchID,i);
if (info.bufferIndex >= 0) if (info.bufferIndex >= 0)
@@ -310,3 +327,19 @@ MIDINRPNValue
Check(patch_level_of_detail); Check(patch_level_of_detail);
return patch_level_of_detail->GetMaxMIDIFilterCutoff(); return patch_level_of_detail->GetMaxMIDIFilterCutoff();
} }
//
//#############################################################################
//#############################################################################
//
float
PatchResource::GetZoneBassGain(int zone_index)
{
Check(this);
PatchLevelOfDetail *patch_level_of_detail =
Cast_Object(PatchLevelOfDetail*, GetAudioLevelOfDetail());
Check(patch_level_of_detail);
return patch_level_of_detail->GetZoneBassGain(zone_index);
}
+20
View File
@@ -37,6 +37,12 @@ struct PRESETINFO
extern PRESETINFO allPresets[2][100]; extern PRESETINFO allPresets[2][100];
//
// Defined in L4AUDRES.cpp; declared here rather than including that header so
// the level-of-detail and resource headers stay independent of each other.
//
float RPBufferBassGain(int buffer_index);
bool PRESET_isImplemented(int bank, int preset); bool PRESET_isImplemented(int bank, int preset);
int PRESET_getNumSamples(int bank, int preset); int PRESET_getNumSamples(int bank, int preset);
SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd); SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd);
@@ -69,6 +75,14 @@ public:
GetVoiceCount() GetVoiceCount()
{return PRESET_getNumSamples(bankID,patchID);} {return PRESET_getNumSamples(bankID,patchID);}
//
// Gain this zone takes from the Home/End bass trim, 1.0 when untouched.
//
float
GetZoneBassGain(int zone_index)
{return RPBufferBassGain(
PRESET_getSampleInfo(bankID,patchID,zone_index).bufferIndex);}
// //
//----------------------------------------------------------------------- //-----------------------------------------------------------------------
// BuildFromPage // BuildFromPage
@@ -163,6 +177,12 @@ public:
void void
SetupPatch(SourceSet sourceSet); SetupPatch(SourceSet sourceSet);
//
// Gain this zone takes from the Home/End bass trim, 1.0 when untouched.
//
float
GetZoneBassGain(int zone_index);
MIDINRPNValue MIDINRPNValue
GetMaxMIDIFilterCutoff(); GetMaxMIDIFilterCutoff();
}; };
+155
View File
@@ -18,6 +18,130 @@
ALuint *g_buffers; ALuint *g_buffers;
int g_numBuffers; int g_numBuffers;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Bass trim ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// RP412AUDIOBASS, 0.0..1.0, default 1.0 (the mix exactly as authored), stepped
// live by the Home/End keys.
//
// The arcade pod ran the game at unity and did its volume and tone shaping in
// hardware -- an external amplifier and a 3-way crossover. A desktop player has
// neither, so the low band needs a control in software. This is the crossover's
// low trim; the master volume (L4AUDRND.cpp) is the amplifier's.
//
// It cannot be an EFX filter: the OpenAL this game ships implements only
// AL_FILTER_LOWPASS, so there is no low shelf or bandpass to lean on, and the
// one direct filter a source gets is already carrying the authored brightness
// model. So the trim is a GAIN, applied per zone in the mix.
//
// That works because of HOW the low end is built. RP's soundbanks carry their
// weight in discrete deep layer zones whose per-zone tuning bakes out to a very
// low playback rate -- 13 zones sit below 8 kHz, between 3.4 and 5.2 octaves
// below their recorded pitch, against 81% of the set at 22 kHz and up. A zone's
// baked rate is therefore a reliable proxy for which band it occupies, so
// attenuating the low-rate zones is a genuine low-band trim rather than a blunt
// overall cut.
//
// Ramp: untouched at or above 22050 Hz, full trim at or below 5512 Hz, log
// interpolated between, so nothing steps abruptly at a threshold. Each buffer's
// DEPTH is fixed at load; the trim itself is read at mix time, which is what
// lets the keys move it while sounds are playing.
//
static const ALsizei kBassTrimFullRate = 5512; // at/below: full trim
static const ALsizei kBassTrimNoneRate = 22050; // at/above: untouched
static const char kBassTrimFile[] = "bass.cfg";
static const float kBassTrimStep = 0.05f;
static float *g_bufferBassDepth = NULL; // one per loaded buffer
static float g_bassTrim = 1.0f;
//
// How much of the trim a buffer at this rate takes: 0 = untouched, 1 = fully.
//
static float
RPBassDepthForRate(ALsizei rate)
{
if (rate >= kBassTrimNoneRate) return 0.0f;
if (rate <= kBassTrimFullRate) return 1.0f;
const float span = (float)log((double)kBassTrimNoneRate / (double)kBassTrimFullRate);
return (float)log((double)kBassTrimNoneRate / (double)rate) / span;
}
void
RPBassTrimInitialize()
{
g_bassTrim = 1.0f;
if (const char *setting = getenv("RP412AUDIOBASS"))
{
float value = (float)atof(setting);
if (value >= 0.0f && value <= 1.0f)
{
g_bassTrim = value;
}
}
//
// Whatever the player last set with the keys wins, exactly as the master
// volume behaves -- environ.ini only decides where an untouched machine
// starts out.
//
if (FILE *cfg = fopen(kBassTrimFile, "rt"))
{
float value = -1.0f;
if (fscanf(cfg, "%f", &value) == 1 && value >= 0.0f && value <= 1.0f)
{
g_bassTrim = value;
}
fclose(cfg);
}
Tell("Audio bass trim " << (int)(g_bassTrim * 100.0f + 0.5f) << "%\n");
}
void
RPBassTrimStep(int direction)
{
g_bassTrim += (direction > 0) ? kBassTrimStep : -kBassTrimStep;
if (g_bassTrim < 0.0f) g_bassTrim = 0.0f;
if (g_bassTrim > 1.0f) g_bassTrim = 1.0f;
g_bassTrim = (float)((int)(g_bassTrim / kBassTrimStep + 0.5f)) * kBassTrimStep;
if (FILE *cfg = fopen(kBassTrimFile, "wt"))
{
fprintf(cfg, "%.2f\n", g_bassTrim);
fclose(cfg);
}
Tell("Audio bass trim " << (int)(g_bassTrim * 100.0f + 0.5f) << "%\n");
}
float
RPBassTrim()
{
return g_bassTrim;
}
//
// The gain a zone takes at the current trim. 1.0 whenever the player has not
// touched it, so the default costs one multiply by one.
//
float
RPBufferBassGain(int buffer_index)
{
if (g_bassTrim >= 0.999f || g_bufferBassDepth == NULL
|| buffer_index < 0 || buffer_index >= g_numBuffers)
{
return 1.0f;
}
return 1.0f - (1.0f - g_bassTrim) * g_bufferBassDepth[buffer_index];
}
//############################################################################# //#############################################################################
//####################### AudioObjectStream ############################# //####################### AudioObjectStream #############################
//############################################################################# //#############################################################################
@@ -566,6 +690,18 @@ void
g_buffers = NULL; g_buffers = NULL;
g_numBuffers = 0; g_numBuffers = 0;
} }
else
{
//
// Parallel to g_buffers: how much of the bass trim each zone takes.
//
RPBassTrimInitialize();
g_bufferBassDepth = new float[g_numBuffers];
for (int b = 0; b < g_numBuffers; b++)
{
g_bufferBassDepth[b] = 0.0f;
}
}
} }
int bufferInd = 0; int bufferInd = 0;
@@ -647,6 +783,15 @@ void
sf_read_raw(file,data,size); sf_read_raw(file,data,size);
sf_close(file); sf_close(file);
//
// Record which band this zone sits in, for the Home/End bass
// trim. Fixed per buffer; the trim itself is read at mix time.
//
if (g_bufferBassDepth != NULL)
{
g_bufferBassDepth[bufferInd] = RPBassDepthForRate(alSampleRate);
}
//Feed the buffer //Feed the buffer
alBufferData(g_buffers[bufferInd],format,data,size,alSampleRate); alBufferData(g_buffers[bufferInd],format,data,size,alSampleRate);
PRESET_setBufferIndex(i,j,k,bufferInd); PRESET_setBufferIndex(i,j,k,bufferInd);
@@ -726,6 +871,16 @@ void
ALuint AL_getBuffer(int index) ALuint AL_getBuffer(int index)
{ {
//
// 0 is AL_NONE - "no buffer" - which alSourcei accepts and which detaches
// the source rather than crashing. An index that is out of range means a
// zone that does not exist, and the only thing an unchecked lookup here
// can do about it is read whatever lies past the array.
//
if (g_buffers == NULL || index < 0 || index >= g_numBuffers)
{
return 0;
}
return g_buffers[index]; return g_buffers[index];
} }
+10
View File
@@ -9,6 +9,16 @@ ALuint AL_getBuffer(int index);
extern ALuint *g_buffers; extern ALuint *g_buffers;
extern int g_numBuffers; extern int g_numBuffers;
//
// RP412AUDIOBASS low-band trim, stepped live by the Home/End keys. Applied as
// a per-zone gain in the mix; see the comment block in L4AUDRES.cpp for why it
// lives here and not in EFX.
//
void RPBassTrimInitialize();
void RPBassTrimStep(int direction);
float RPBassTrim();
float RPBufferBassGain(int buffer_index);
//class AudioHardware; //class AudioHardware;
+308 -25
View File
@@ -2,9 +2,20 @@
#pragma hdrstop #pragma hdrstop
#include "l4audrnd.h" #include "l4audrnd.h"
#include "l4audefx.h"
#include "..\munga\notation.h" #include "..\munga\notation.h"
#include "openal/alc.h" #include "openal/alc.h"
#include <stdio.h>
//
// Master volume limits, shared by the startup load and the PgUp/PgDn step.
// The file sits beside the exe with the other runtime state.
//
static const char kAudioVolumeFile[] = "volume.cfg";
static const float kAudioVolumeStep = 0.05f;
static const float kAudioVolumeMax = 2.0f;
// //
//############################################################################# //#############################################################################
// L4AudioRenderer // L4AudioRenderer
@@ -379,6 +390,66 @@ void
{ {
ALCcontext *context = alcCreateContext(device,NULL); ALCcontext *context = alcCreateContext(device,NULL);
alcMakeContextCurrent(context); alcMakeContextCurrent(context);
//
// FIDELITY (docs/SOUND.md F9/F11): bring up the EFX bridge that carries
// the authored brightness/distance lowpass and the wet-exterior reverb
// send. Needs the context current, and the reverb gain has already been
// read from AUDIO.INI into the head above. Inert without ALC_EXT_EFX.
//
EFX_Initialize(audio_head->GetGlobalReverbScale());
//
// Master volume. There was no listener gain at all before -- the mix
// always ran at unity -- so restoring the authored dynamics gave players
// no way to pull the whole thing down. This lives in environ.ini rather
// than AUDIO.INI deliberately: AUDIO.INI is byte-identical to the file
// that shipped in 1995 and is worth keeping that way.
//
// Default is 1.0, i.e. exactly the previous behaviour -- the knob only
// does something when someone asks for it.
//
{
float master_volume = 1.0f;
if (const char *setting = getenv("RP412AUDIOVOLUME"))
{
float value = (float)atof(setting);
if (value >= 0.0f && value <= kAudioVolumeMax)
{
master_volume = value;
}
}
//
// Whatever the player last set with the volume keys wins over the
// environ.ini figure: the keys are the amplifier knob, and a knob
// stays where it was left. environ.ini sets where it starts on a
// machine that has never been touched.
//
if (FILE *cfg = fopen(kAudioVolumeFile, "rt"))
{
float value = -1.0f;
if (fscanf(cfg, "%f", &value) == 1
&& value >= 0.0f && value <= kAudioVolumeMax)
{
master_volume = value;
}
fclose(cfg);
}
gRPMasterVolume = master_volume;
alListenerf(AL_GAIN, master_volume);
Tell("Audio master volume " << (int)(master_volume * 100.0f + 0.5f) << "%\n");
}
//
// The bass trim is not set here: it is a per-zone gain owned by the
// resource manager (L4AUDRES.cpp), which needs the buffers to exist
// first. PreloadResources initialises it below.
//
} }
// //
@@ -1257,6 +1328,190 @@ Logical
return resources_available; return resources_available;
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Master volume ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The pod ran at unity and left volume to an external amplifier, so the game
// never had a level control. Standing in for that amplifier means the player
// needs to reach it while playing, not only through environ.ini -- hence the
// PgUp/PgDn binding in L4Application::KeyCommandMessageHandler.
//
// Page keys specifically: they produce no typed character, so they cannot
// collide with any of the engine's character-keyed commands the way '+'/'-'
// would, they are bound to nothing in any RP layout, and they exist on
// tenkeyless keyboards.
//
float gRPMasterVolume = 1.0f;
void
RPAudioMasterVolumeStep(int direction)
{
gRPMasterVolume += (direction > 0) ? kAudioVolumeStep : -kAudioVolumeStep;
if (gRPMasterVolume < 0.0f) gRPMasterVolume = 0.0f;
if (gRPMasterVolume > kAudioVolumeMax) gRPMasterVolume = kAudioVolumeMax;
//
// Snap to the step grid so repeated presses cannot drift on float error and
// land somewhere that never reads back as a round number.
//
gRPMasterVolume =
(float)((int)(gRPMasterVolume / kAudioVolumeStep + 0.5f)) * kAudioVolumeStep;
alListenerf(AL_GAIN, gRPMasterVolume);
//
// Persist immediately. A pod operator setting the level expects it to still
// be there after the cabinet is power-cycled, and there is no settings UI to
// hang it off.
//
if (FILE *cfg = fopen(kAudioVolumeFile, "wt"))
{
fprintf(cfg, "%.2f\n", gRPMasterVolume);
fclose(cfg);
}
Tell("Audio master volume " << (int)(gRPMasterVolume * 100.0f + 0.5f) << "%\n");
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ OpenAL source pool ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Sources are expensive to create and destroy and are a HARD per-context
// resource (this driver grants 256 mono). Generating one per sound event and
// deleting it on release burns through that ceiling during busy play even
// though steady-state demand is modest, which shows up as sounds silently
// failing to start. Generate once, recycle forever.
//
// The cap sits below the driver grant with a reserve, so growth stops on our
// terms rather than on an alGenSources failure. Growth also stops by itself if
// a driver offers fewer sources than the cap -- a failed generate simply ends
// growth and the pool recycles what it already has.
//
static const int kAudioPoolMax = 512; // free-list array size
static const int kAudioPoolCap = 240; // grow no further than this
static ALuint gAudioPoolFree[kAudioPoolMax];
static int gAudioPoolFreeCount = 0; // entries parked in gAudioPoolFree
static int gAudioPoolTotal = 0; // sources ever generated (<= cap)
static long gAudioPoolReuses = 0; // diagnostics
int RPAudioPoolSize() { return gAudioPoolTotal; }
int RPAudioPoolFree() { return gAudioPoolFreeCount; }
long RPAudioPoolReuses() { return gAudioPoolReuses; }
//
// Reset a source to a neutral state so nothing carries across owners.
//
static void
RPAudioScrubSource(ALuint src)
{
ALint state = AL_STOPPED;
alGetSourcei(src, AL_SOURCE_STATE, &state);
if (state == AL_PLAYING || state == AL_PAUSED)
{
alSourceStop(src);
}
alSourcei(src, AL_BUFFER, 0); // detach (nothing is queued here)
alSourcei(src, AL_LOOPING, AL_FALSE); // or the next owner inherits a loop
alSourcef(src, AL_GAIN, 1.0f);
alSourcef(src, AL_PITCH, 1.0f);
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f);
alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
//
// Drop the EFX state too. Without this a recycled name can carry a 3D
// source's reverb send into a dry cockpit sound, or a distant source's
// lowpass into a close one.
//
EFX_ClearSourceEffects(src);
alGetError(); // swallow any property complaint
}
//
// Hand out a source: recycle first, generate only while under the cap.
// False means genuinely out, and the caller retries after the steal loop runs.
//
Logical
RPAudioPoolAcquire(ALuint *out)
{
Check_Pointer(out);
while (gAudioPoolFreeCount > 0)
{
ALuint src = gAudioPoolFree[--gAudioPoolFreeCount];
if (alIsSource(src)) // a context reset invalidates names
{
++gAudioPoolReuses;
*out = src;
return True;
}
--gAudioPoolTotal; // stale name: forget it
}
if (gAudioPoolTotal >= kAudioPoolCap)
{
return False;
}
ALuint src = 0;
alGetError();
alGenSources(1, &src);
if (alGetError() != AL_NO_ERROR || !alIsSource(src))
{
return False; // driver said no before our cap
}
++gAudioPoolTotal;
#if DEBUG_LEVEL>0
{
//
// One line per high-water band, so a log shows how close real play gets
// to the ceiling without spamming.
//
static int s_notified = 0;
if (gAudioPoolTotal >= s_notified + 25)
{
s_notified = gAudioPoolTotal;
Tell("Audio source pool high-water: " << gAudioPoolTotal
<< " of " << kAudioPoolCap << "\n");
}
}
#endif
*out = src;
return True;
}
//
// Take a source back. Scrubbed and parked, never deleted.
//
void
RPAudioPoolRelease(ALuint src)
{
if (!alIsSource(src))
{
return;
}
RPAudioScrubSource(src);
if (gAudioPoolFreeCount < kAudioPoolMax)
{
gAudioPoolFree[gAudioPoolFreeCount++] = src;
return;
}
alDeleteSources(1, &src); // unreachable: cap < array size
--gAudioPoolTotal;
}
// //
//############################################################################# //#############################################################################
// RequestAudioChannels // RequestAudioChannels
@@ -1271,32 +1526,56 @@ Logical
Check(this); Check(this);
Check(source_request); Check(source_request);
//Do we have enough? //
// SOURCE POOLING (docs/SOUND.md). This used to alGenSources per sound
// event, with ReleaseSourceSet alDeleteSources'ing on release -- so play
// activity CHURNED through OpenAL's per-context source limit (the driver
// grants 256 mono here). Recovering the soundbanks took the voice count
// per sound from about 1.1 zones to about 2.6, roughly doubling that churn.
//
// The BT tree measured this exact problem: raising the budget was NOT the
// fix, recycling was, and it was a net CPU win besides. Sources are now
// generated once and handed back to a free list, so steady-state play costs
// no allocation at all.
//
int requested = source_request->count; int requested = source_request->count;
bool failed = true; if (requested > (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0])))
{
alGetError(); requested = (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0]));
source_request->count = requested;
}
for (int i = 0; i < requested; i++) for (int i = 0; i < requested; i++)
{ {
if (!alIsSource(source_request->sources[i])) if (source_request->sources[i] != 0 && alIsSource(source_request->sources[i]))
{ {
alGenSources(1, source_request->sources + i); continue; // slot already holds a live source
}
} }
ALenum error = alGetError(); ALuint src = 0;
if (error == AL_NO_ERROR)
{
failed = false;
}
if (failed) if (!RPAudioPoolAcquire(&src))
{ {
//
// Out of sources. Hand back everything acquired on THIS attempt so a
// failed request cannot strand voices -- the renderer's steal loop
// will free some and retry.
//
for (int j = 0; j < i; j++)
{
if (source_request->sources[j] != 0)
{
RPAudioPoolRelease(source_request->sources[j]);
source_request->sources[j] = 0;
}
}
return False; return False;
} }
source_request->sources[i] = src;
}
return True; return True;
// //
@@ -1375,23 +1654,27 @@ Logical
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet) void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
{ {
//
// SOURCE POOLING (docs/SOUND.md): park each source on the free list rather
// than destroying it. RPAudioPoolRelease stops it, detaches its buffer and
// scrubs the state -- including the EFX filter and reverb send -- so the
// next owner starts clean.
//
// The bulk alDeleteSources(count, sources) this replaces was also a leak
// waiting to happen: per the AL spec it is ATOMIC, so ONE invalid name in
// the array (an empty slot of a partial set, or the old -1 sentinel on a
// double release) meant NOTHING was deleted and the whole set leaked.
// Slots are parked at 0, which is never a valid AL name -- unlike -1, which
// alIsSource would be asked about as 0xFFFFFFFF.
//
for (int i = 0; i < sourceSet.count; i++) for (int i = 0; i < sourceSet.count; i++)
{ {
ALenum state; if (sourceSet.sources[i] != 0)
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
if (state == AL_PLAYING)
{ {
alSourceStop(sourceSet.sources[i]); RPAudioPoolRelease(sourceSet.sources[i]);
sourceSet.sources[i] = 0;
} }
} }
alDeleteSources(sourceSet.count, sourceSet.sources);
for (int i = 0; i < sourceSet.count; i++)
{
sourceSet.sources[i] = -1;
}
} }
//~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~
+8
View File
@@ -6,6 +6,14 @@
#include "l4audres.h" #include "l4audres.h"
#include "openal/al.h" #include "openal/al.h"
//
// Master volume, standing in for the amplifier the cabinets had. Stepped by
// PgUp/PgDn (L4APP.cpp) and persisted to volume.cfg; see L4AUDRND.cpp.
//
extern float gRPMasterVolume;
void RPAudioMasterVolumeStep(int direction);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+74
View File
@@ -6,6 +6,8 @@
#include "l4ctrl.h" #include "l4ctrl.h"
#include "l4keybd.h" #include "l4keybd.h"
#include "l4app.h" #include "l4app.h"
#include "l4audrnd.h" // RPAudioMasterVolumeStep, for the PgUp/PgDn keys
#include "l4audres.h" // RPBassTrimStep, for the Home/End keys
#include "l4dinput.h" #include "l4dinput.h"
#include "..\munga\appmgr.h" #include "..\munga\appmgr.h"
#include "dxutils.h" #include "dxutils.h"
@@ -1513,6 +1515,78 @@ void
// Update the PC keyboard mapping group // Update the PC keyboard mapping group
//------------------------------------------------------------------------- //-------------------------------------------------------------------------
// //
//
//-------------------------------------------------------------------------
// Master volume, PgUp louder / PgDn quieter.
//
// The cabinets ran the game at unity and left level to an external
// amplifier and crossover; without that hardware the player has to be able
// to reach the volume while playing.
//
// POLLED, not taken off the key message below, and that is deliberate. The
// pump below only ever consumes WM_KEYUP / WM_SYSKEYUP / WM_CHAR from the
// front of the queue, and the front-end runs message loops of its own, so
// key messages are raced for and routinely lost -- measured here at roughly
// two of every six presses arriving. That is survivable for a one-shot like
// the abort chord; it is not survivable for a control you tap repeatedly to
// find a level. Reading the key state directly costs nothing and cannot be
// dropped.
//
// Page keys because they produce no typed character, so they cannot collide
// with the character-keyed commands the pump feeds, nothing else in RP binds
// them, and they exist on tenkeyless keyboards.
//
// The foreground check keeps an alt-tabbed game from eating the volume keys
// of whatever the player switched to.
//-------------------------------------------------------------------------
//
// Home/End do the same for the bass trim - the crossover's low band to
// PgUp/PgDn's amplifier.
//
{
static int volume_up_held = 0;
static int volume_down_held = 0;
static int bass_up_held = 0;
static int bass_down_held = 0;
int focused = 0;
if (HWND foreground = GetForegroundWindow())
{
DWORD foreground_process = 0;
GetWindowThreadProcessId(foreground, &foreground_process);
focused = (foreground_process == GetCurrentProcessId());
}
const int up = focused && (GetAsyncKeyState(VK_PRIOR) & 0x8000) != 0;
const int down = focused && (GetAsyncKeyState(VK_NEXT) & 0x8000) != 0;
const int bass_up = focused && (GetAsyncKeyState(VK_HOME) & 0x8000) != 0;
const int bass_down = focused && (GetAsyncKeyState(VK_END) & 0x8000) != 0;
if (up && !volume_up_held)
{
RPAudioMasterVolumeStep(+1);
}
if (down && !volume_down_held)
{
RPAudioMasterVolumeStep(-1);
}
if (bass_up && !bass_up_held)
{
RPBassTrimStep(+1);
}
if (bass_down && !bass_down_held)
{
RPBassTrimStep(-1);
}
volume_up_held = up;
volume_down_held = down;
bass_up_held = bass_up;
bass_down_held = bass_down;
}
if (flags.keyboardExists) if (flags.keyboardExists)
{ {
//RB 1/20/07 //RB 1/20/07
+142 -5
View File
@@ -3828,6 +3828,13 @@ static LRESULT CALLBACK
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
static WNDPROC gCockpitBaseProc = NULL; static WNDPROC gCockpitBaseProc = NULL;
//
// Which window we subclassed, so the destructor can put its own proc
// back. The shell is the GAME window: it outlives the cockpit and
// carries the console screen from one race to the next.
//
static HWND gCockpitShellWindow = NULL;
static LRESULT CALLBACK static LRESULT CALLBACK
CockpitShellProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) CockpitShellProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{ {
@@ -4358,6 +4365,15 @@ SVGA16::SVGA16(
// Split-view mode: decide before BuildWindows so the packed gauge // Split-view mode: decide before BuildWindows so the packed gauge
// windows can stay hidden. // windows can stay hidden.
//------------------------------------------------------------------ //------------------------------------------------------------------
//
// Before anything else: the constructor calls Update() below, and
// both of these are read in there. mDisplayToUpdate was only being
// set at the END of the constructor, so that first pass indexed the
// display arrays with whatever was on the stack.
//
mDisplayToUpdate = 0;
mDisplaysCopiedThisPass = 0;
splitViews = False; splitViews = False;
cockpitViewscreen = NULL; cockpitViewscreen = NULL;
Logical explodedViews = False; Logical explodedViews = False;
@@ -4679,9 +4695,25 @@ SVGA16::SVGA16(
GetClientRect(cockpit, &inner); GetClientRect(cockpit, &inner);
LayoutCockpit(inner.right, inner.bottom); LayoutCockpit(inner.right, inner.bottom);
// catch maximise / restore / drag-resize and re-fit //
// Catch maximise / restore / drag-resize and re-fit - ONCE.
//
// The destructor puts the original proc back, so ordinarily
// this window is unsubclassed by the time a second race
// builds a new cockpit. The guard is for the case where it
// was not: subclassing an already-subclassed window makes
// SetWindowLongPtr hand back CockpitShellProc itself as the
// "original", and the proc below then chains to itself on
// every single message until the stack runs out. That is a
// stack overflow a few frames into the second race, with no
// hint of a cause in the log.
//
if (gCockpitShellWindow != cockpit)
{
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA( gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc); cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
gCockpitShellWindow = cockpit;
}
// //
// Sticky placement for the shell. Position AND size: nothing // Sticky placement for the shell. Position AND size: nothing
@@ -4917,6 +4949,30 @@ SVGA16::~SVGA16()
cockpitViewscreen = NULL; cockpitViewscreen = NULL;
} }
//
// Give the game window its own proc back, and stop answering for a
// cockpit that is about to stop existing.
//
// The window survives us - it is the one that shows the console
// screen between races - so both of these outlived their subject.
// The subclass was the worse of the two: the next race re-subclassed
// the same window and CockpitShellProc ended up chained to itself.
// activeCockpit was the quieter one, left pointing at this object
// after it was freed, ready for the next WM_SIZE to lay out a
// cockpit that had already gone.
//
if (gCockpitShellWindow != NULL)
{
SetWindowLongPtrA(
gCockpitShellWindow, GWLP_WNDPROC, (LONG_PTR) gCockpitBaseProc);
gCockpitShellWindow = NULL;
gCockpitBaseProc = NULL;
}
if (activeCockpit == this)
{
activeCockpit = NULL;
}
Check_Fpu(); Check_Fpu();
} }
@@ -4997,12 +5053,16 @@ Logical SVGA16::Update(Logical forceAll)
GaugeRenderer *renderer = application->GetGaugeRenderer(); GaugeRenderer *renderer = application->GetGaugeRenderer();
if (!valid || renderer == NULL) if (!valid || renderer == NULL)
{ {
mDisplaysCopiedThisPass = 0;
CLEAR_SCREEN_COPY(); CLEAR_SCREEN_COPY();
return False; // Do no more! return False; // Do no more!
} }
if (++mDisplayToUpdate >= NUMGAUGEWINDOWS) // one display per call; the rotation steps at the end of the function
if (mDisplayToUpdate >= NUMGAUGEWINDOWS)
{
mDisplayToUpdate = 0; mDisplayToUpdate = 0;
}
//Top MFD's //Top MFD's
L4GraphicsPort *UL = static_cast<L4GraphicsPort*>(renderer->GetGraphicsPort("auxUL2")); L4GraphicsPort *UL = static_cast<L4GraphicsPort*>(renderer->GetGraphicsPort("auxUL2"));
@@ -5045,7 +5105,10 @@ Logical SVGA16::Update(Logical forceAll)
lrMask |= (lrMask << 16); lrMask |= (lrMask << 16);
} else } else
{ {
//No MFDs to draw, break out early //No MFDs to draw, break out early. The sweep counter resets
//too: leaving it part-used would keep the renderer in its
//copy phase, and it never draws another gauge while there.
mDisplaysCopiedThisPass = 0;
return False; return False;
} }
} else } else
@@ -5057,7 +5120,8 @@ Logical SVGA16::Update(Logical forceAll)
secPalette = &((SVGA16 *) secPort->graphicsDisplay)->palette[secPort->paletteID]; secPalette = &((SVGA16 *) secPort->graphicsDisplay)->palette[secPort->paletteID];
} else } else
{ {
//No secondary, skip //No secondary, skip - and end the sweep, as above.
mDisplaysCopiedThisPass = 0;
return False; return False;
} }
} }
@@ -5227,7 +5291,80 @@ Logical SVGA16::Update(Logical forceAll)
// if (end.ticks - start.ticks > 100) // if (end.ticks - start.ticks > 100)
// end = start; // end = start;
return False; // True == 'more to do' //
//------------------------------------------------------------------
// Step the rotation, and say "more to do" until every display has
// had its turn.
//
// This used to return False unconditionally, which told the gauge
// renderer its copy phase was over after a SINGLE display. A full
// gauge sweep - one gauge per background pass, so as many passes as
// there are active gauges - therefore refreshed one display, and the
// map, one of three, came round only every third sweep.
//
// That is invisible with frame time to spare, because the background
// loop keeps running until the frame budget is used up and gets
// through several sweeps. On a big map the 3D foreground eats the
// whole budget, the loop drops to the one pass per frame it is
// guaranteed, and the map goes seconds between refreshes - which is
// what the field reports describe, on exactly those maps. 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, which is the tell that led here.
//------------------------------------------------------------------
//
mDisplayToUpdate++;
if (mDisplayToUpdate >= NUMGAUGEWINDOWS)
{
mDisplayToUpdate = 0;
}
if (++mDisplaysCopiedThisPass < NUMGAUGEWINDOWS)
{
return True; // call again - there are displays waiting
}
mDisplaysCopiedThisPass = 0;
//
// RP412GAUGEDIAG=1 reports how often the displays are actually being
// refreshed. Pixel-watching from outside cannot tell a display that
// is not refreshing from one whose picture simply is not changing,
// and that ambiguity is exactly what makes "my map froze" hard to
// pin down. This counts the real thing.
//
{
static int diagnostics = -1;
if (diagnostics < 0)
{
const char *setting = getenv("RP412GAUGEDIAG");
diagnostics = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diagnostics)
{
static unsigned long window_start = 0;
static int sweeps = 0;
unsigned long now = GetTickCount();
++sweeps;
if (window_start == 0)
{
window_start = now;
}
else if (now - window_start >= 2000)
{
// tenths, by hand: whole sweeps per second rounds the
// interesting cases - a starved pipeline managing two
// thirds of a sweep a second reads as a flat "0/s".
int tenths = sweeps * 10000 / (int)(now - window_start);
DEBUG_STREAM << "GaugeDiag: " << sweeps << " display sweep(s) in "
<< (now - window_start) << " ms ("
<< (tenths / 10) << '.' << (tenths % 10) << "/s, "
<< NUMGAUGEWINDOWS << " displays each)\n" << std::flush;
window_start = now;
sweeps = 0;
}
}
}
return False; // the sweep is complete
} }
+11
View File
@@ -293,6 +293,17 @@ private:
int mDisplayToUpdate; int mDisplayToUpdate;
//------------------------------------------------------------------
// How many displays this copy pass has refreshed.
//
// The gauge renderer's copy phase ends the moment Update() reports it
// has finished, and Update() reported that after ONE display - so a
// whole gauge sweep refreshed a single display, and the map, one of
// three, came round only every third sweep. Counting them out means
// one sweep refreshes all of them.
//------------------------------------------------------------------
int mDisplaysCopiedThisPass;
//------------------------------------------------------------------ //------------------------------------------------------------------
// Split-view mode (L4MFDSPLIT=1): the five channel-packed MFDs and // Split-view mode (L4MFDSPLIT=1): the five channel-packed MFDs and
// the rotated map render as their own desktop windows; the packed // the rotated map render as their own desktop windows; the packed
+77
View File
@@ -17,6 +17,10 @@
#include "..\munga\nttmgr.h" #include "..\munga\nttmgr.h"
#include "..\munga\app.h" #include "..\munga\app.h"
#include "l4particles.h" #include "l4particles.h"
#include "l4padrio.h" // PadRIO::IsActive, for the per-frame lamp sweep
#include "..\munga\gaugrend.h"
#include "..\munga\lamp.h"
#include "..\munga\mode.h"
#include "DXUtils.h" #include "DXUtils.h"
using namespace std; using namespace std;
@@ -5983,11 +5987,84 @@ void
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Execute Method, performs the rendering of one frame // Execute Method, performs the rendering of one frame
// //
//
//===========================================================================
// RPSweepCockpitLamps
//
// Push the cockpit lamp STATE once per frame, instead of once per gauge
// cycle.
//
// The on-screen vRIO buttons light themselves from PadRIO::GetLampState,
// and they redraw with their MFD strip. What FILLS that store is
// LampManager::Update -> AssertNewLampValue -> SetLamp, and that rides
// the gauge renderer's FOREGROUND turn - which comes round only once per
// full gauge cycle. On a busy map the cycle takes the best part of a
// second, so the lit buttons froze and any flash stalled while the 3D
// view, a separate per-frame render, stayed perfectly smooth. BT411 saw
// the same thing on its glass surround and fixed it the same way.
//
// It is cheap: a sweep over the lamps, no raster, and AssertNewLampValue
// already drops anything that has not changed - so this pushes no extra
// traffic, it only stops changes arriving late.
//
// Only when a PadRIO is active, i.e. cockpit-less play. With real serial
// hardware selected the pod keeps its authentic bandwidth-paced cadence,
// untouched. RP412LAMPSWEEP=0 restores the once-per-cycle behaviour.
//===========================================================================
//
static void
RPSweepCockpitLamps()
{
static int
enabled = -1;
if (enabled < 0)
{
const char
*setting = getenv("RP412LAMPSWEEP");
enabled = (setting != NULL && setting[0] == '0') ? 0 : 1;
}
if (!enabled || !PadRIO::IsActive() || application == NULL)
{
return;
}
//
// Only while a mission is actually running. This is called from the
// top of the frame, ahead of the state switch below, so it would
// otherwise fire while the mission is still being built and the
// gauges do not exist yet.
//
if (application->GetApplicationState() != Application::RunningMission)
{
return;
}
GaugeRenderer
*renderer = application->GetGaugeRenderer();
ModeManager
*modes = application->GetModeManager();
if (renderer != NULL && modes != NULL)
{
LampManager
*lamps = renderer->GetLampManager();
if (lamps != NULL)
{
lamps->Update(modes->GetModeMask());
}
}
}
void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator) void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator)
{ {
Component *component; Component *component;
HRESULT hr; HRESULT hr;
RPSweepCockpitLamps(); // keep the lit buttons tracking the sim (see above)
// timing variables // timing variables
__int64 ticks = HiResNowTicks(); __int64 ticks = HiResNowTicks();
#ifdef LOGFRAMERATE #ifdef LOGFRAMERATE
+2
View File
@@ -227,6 +227,7 @@
<ClCompile Include="..\MUNGA\WRHOUS.cpp" /> <ClCompile Include="..\MUNGA\WRHOUS.cpp" />
<ClCompile Include=".\DXUtils.cpp" /> <ClCompile Include=".\DXUtils.cpp" />
<ClCompile Include=".\L4APP.cpp" /> <ClCompile Include=".\L4APP.cpp" />
<ClCompile Include=".\L4AUDEFX.cpp" />
<ClCompile Include=".\L4AUDHDW.cpp" /> <ClCompile Include=".\L4AUDHDW.cpp" />
<ClCompile Include=".\L4AUDIO.cpp" /> <ClCompile Include=".\L4AUDIO.cpp" />
<ClCompile Include=".\L4AUDLVL.cpp" /> <ClCompile Include=".\L4AUDLVL.cpp" />
@@ -432,6 +433,7 @@
<ClInclude Include="..\MUNGA\WRHOUS.h" /> <ClInclude Include="..\MUNGA\WRHOUS.h" />
<ClInclude Include=".\DXUtils.h" /> <ClInclude Include=".\DXUtils.h" />
<ClInclude Include=".\L4APP.H" /> <ClInclude Include=".\L4APP.H" />
<ClInclude Include=".\L4AUDEFX.h" />
<ClInclude Include=".\L4AUDHDW.h" /> <ClInclude Include=".\L4AUDHDW.h" />
<ClInclude Include=".\L4AUDIO.h" /> <ClInclude Include=".\L4AUDIO.h" />
<ClInclude Include=".\L4AUDLVL.h" /> <ClInclude Include=".\L4AUDLVL.h" />
+6
View File
@@ -486,6 +486,9 @@
<ClCompile Include=".\L4AUDHDW.cpp"> <ClCompile Include=".\L4AUDHDW.cpp">
<Filter>Source Files\MUNGA_L4</Filter> <Filter>Source Files\MUNGA_L4</Filter>
</ClCompile> </ClCompile>
<ClCompile Include=".\L4AUDEFX.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
<ClCompile Include=".\L4AUDIO.cpp"> <ClCompile Include=".\L4AUDIO.cpp">
<Filter>Source Files\MUNGA_L4</Filter> <Filter>Source Files\MUNGA_L4</Filter>
</ClCompile> </ClCompile>
@@ -1058,6 +1061,9 @@
<ClInclude Include=".\L4APP.H"> <ClInclude Include=".\L4APP.H">
<Filter>Header Files\MUNGA_L4</Filter> <Filter>Header Files\MUNGA_L4</Filter>
</ClInclude> </ClInclude>
<ClInclude Include=".\L4AUDEFX.h">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
<ClInclude Include=".\L4AUDHDW.h"> <ClInclude Include=".\L4AUDHDW.h">
<Filter>Header Files\MUNGA_L4</Filter> <Filter>Header Files\MUNGA_L4</Filter>
</ClInclude> </ClInclude>
+7
View File
@@ -32,6 +32,13 @@ SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd)
default.file = ""; default.file = "";
default.implemented = false; default.implemented = false;
default.loop = SampleLoop::LoopAtWill; default.loop = SampleLoop::LoopAtWill;
//
// -1 = no buffer. Every caller tests bufferIndex >= 0 before using it as
// an index, and this one field was being left as whatever was on the
// stack - so "this zone does not exist" read as a real buffer whenever
// the garbage happened to be positive, and indexed g_buffers with it.
//
default.bufferIndex = -1;
if (sampleInd < 0 || sampleInd >= allPresets[bank-1][preset].sampleNum) if (sampleInd < 0 || sampleInd >= allPresets[bank-1][preset].sampleNum)
{ {
+7 -3
View File
@@ -96,9 +96,13 @@ team/position columns and its own track list). Steam multiplayer: see
[docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) (until RP412 [docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) (until RP412
has its own AppID it runs under Spacewar, 480). has its own AppID it runs under Spacewar, 480).
The two config files beside the exe are self-documenting: **environ.ini** The config files beside the exe are self-documenting and none of them
(every engine option, commented) and **bindings.txt** (every key, pad ship: the game writes each one the first time it needs it and then leaves
button, and axis; written with the full default layout on first run). it alone, so a new build dropped over an existing folder keeps every
setting. **environ.ini** is every engine option, commented; **bindings.txt**
every key, pad button and axis; **pilot.cfg** your callsign and loadout;
**mfd_layout.cfg** where you dragged the windows. Delete any of them to
start that part over with the current defaults.
Default controls: numpad flies (8/2/4/6 stick, 7/9 pedals, 0 trigger), Default controls: numpad flies (8/2/4/6 stick, 7/9 pedals, 0 trigger),
Shift/Ctrl throttle, Alt reverse, arrows look, Space fires, letter rows Shift/Ctrl throttle, Alt reverse, arrows look, Space fires, letter rows
are the MFD button banks as printed on the panel. **Alt+Q** aborts a are the MFD button banks as printed on the panel. **Alt+Q** aborts a
+106 -21
View File
@@ -22,6 +22,7 @@
#include "rpl4pb.h" #include "rpl4pb.h"
#include "rpl4fe.h" #include "rpl4fe.h"
#include "rpl4environ.h"
#include "rpl4console.h" #include "rpl4console.h"
#include "rpl4lobby.h" #include "rpl4lobby.h"
#include "..\munga_l4\l4steamtransport.h" #include "..\munga_l4\l4steamtransport.h"
@@ -29,6 +30,7 @@
#include "..\munga_l4\l4mfdview.h" // RPWindowLayout_* #include "..\munga_l4\l4mfdview.h" // RPWindowLayout_*
#include "..\munga_l4\l4joy.h" // RPJoyConfigWizard #include "..\munga_l4\l4joy.h" // RPJoyConfigWizard
#include "rpl4ver.h" #include "rpl4ver.h"
#include "rpl4build.h" // generated: RP412_VERSION / RP412_VERSION_LONG
#include "..\munga\resver.h" #include "..\munga\resver.h"
#include "..\munga\resource.h" #include "..\munga\resource.h"
// added for game status drawing support // added for game status drawing support
@@ -40,6 +42,7 @@
#include <strsafe.h> #include <strsafe.h>
#include <direct.h> #include <direct.h>
#include <shellapi.h> #include <shellapi.h>
#include <time.h> // the test-build expiry check
#define SPOOL_SIZE 0x600000 #define SPOOL_SIZE 0x600000
@@ -149,6 +152,15 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
SetUnhandledExceptionFilter(RPL4CrashDumpFilter); SetUnhandledExceptionFilter(RPL4CrashDumpFilter);
//
// Which build this is, before anything else can fail. The patch number
// is this repository's commit count and the hash beside it names the
// commit, so a log from a test machine says exactly where it came from.
// A trailing '+' means the tree had uncommitted changes when it was
// built. See stamp-version.ps1.
//
DEBUG_STREAM << "Red Planet " << RP412_VERSION_LONG << std::endl << std::flush;
// load up our environment variables // load up our environment variables
//controls //controls
if(getenv("L4CONTROLS") == NULL) if(getenv("L4CONTROLS") == NULL)
@@ -163,32 +175,105 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
putenv("TARGETFPS=60"); putenv("TARGETFPS=60");
if(getenv("MAXPARTICLES") == NULL) if(getenv("MAXPARTICLES") == NULL)
putenv("MAXPARTICLES=8192"); putenv("MAXPARTICLES=8192");
FILE *file; //
char line[1024]; // environ.ini: written on first run and read here. The exe owns the
if (fopen_s(&file, "environ.ini", "r") == 0) // template rather than the packaging script laying one down on every
// unzip, so a tester can drop a new build over an old folder and keep
// their settings. See rpl4environ.h.
//
RPL4Environ_Load();
//
//-------------------------------------------------------------------------
// Test builds have a shelf life.
//
// A tester still racing a fortnight-old binary reports things that were
// fixed a week ago, and the afternoon spent chasing them is gone. So the
// build says plainly that it is out of date and stops, rather than
// running on and being quietly wrong about what it is.
//
// This is a nudge, not a lock: the date comes from the machine's own
// clock, so anyone determined can wind it back, and RP412NOEXPIRY=1 is
// there for us when an old build has to be run on purpose. It is
// deliberately not listed in environ.ini - a bypass every tester can see
// is a bypass every tester will use, and then the build never goes stale
// for the one person it was meant to stop.
//
// $expireDays in stamp-version.ps1 is what sets this, and 0 turns it off
// for a real release.
//-------------------------------------------------------------------------
//
#if RP412_EXPIRES
{ {
while (!feof(file)) const char *no_expiry = getenv("RP412NOEXPIRY");
Logical overridden = (no_expiry != NULL && atoi(no_expiry) != 0);
__time64_t raw_now = _time64(NULL);
struct tm today;
if (!overridden && _localtime64_s(&today, &raw_now) == 0)
{ {
if (fgets(line, sizeof(line), file)) int now_stamp =
(today.tm_year + 1900) * 10000 + (today.tm_mon + 1) * 100 + today.tm_mday;
int expiry_stamp =
RP412_EXPIRY_YEAR * 10000 + RP412_EXPIRY_MONTH * 100 + RP412_EXPIRY_DAY;
if (now_stamp > expiry_stamp)
{ {
for (int i = strlen(line); i >= 0; i--) DEBUG_STREAM << "Build expired on " << RP412_EXPIRY_TEXT
if (line[i] == '\n' || line[i] == '\r') << " - refusing to run\n" << std::flush;
line[i] = 0;
// the file is self-documenting: skip comments, blanks, char notice[512];
// and anything that is not KEY=VALUE sprintf(notice,
char *setting = line; "This Red Planet test build has expired.\n\n"
while (*setting == ' ' || *setting == '\t') " Build %s\n"
++setting; " Expired %s\n\n"
if (*setting == '\0' || *setting == '#' || *setting == ';' || "Test builds are good for a fortnight so that nobody spends an "
strchr(setting, '=') == NULL) "afternoon chasing something that was fixed a week ago.\n\n"
continue; "Ask for the current one.",
putenv(setting); RP412_VERSION_LONG, RP412_EXPIRY_TEXT);
} MessageBoxA(NULL, notice, "Red Planet - test build expired",
} MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
fclose(file); return 1;
} }
DEBUG_STREAM << "Red Planet 4.12.7" << std::endl << std::flush; //
// The last few days get a line in the log, so somebody reading a
// report can see the build was nearly out rather than wondering.
//
struct tm expiry_day;
memset(&expiry_day, 0, sizeof(expiry_day));
expiry_day.tm_year = RP412_EXPIRY_YEAR - 1900;
expiry_day.tm_mon = RP412_EXPIRY_MONTH - 1;
expiry_day.tm_mday = RP412_EXPIRY_DAY;
//
// The END of the expiry day, because that is the rule the check
// above enforces - the build is good for all of that date and
// refuses the morning after. Anchoring at midday instead would
// report one day fewer than the build actually has left.
//
expiry_day.tm_hour = 23;
expiry_day.tm_min = 59;
expiry_day.tm_sec = 59;
expiry_day.tm_isdst = -1;
__time64_t expiry_time = _mktime64(&expiry_day);
if (expiry_time != (__time64_t) -1)
{
int days_left = (int)((expiry_time - raw_now) / (24 * 60 * 60));
if (days_left <= 3)
{
DEBUG_STREAM << "Build expires " << RP412_EXPIRY_TEXT << " ("
<< days_left << " day(s) left)\n" << std::flush;
}
}
}
else if (overridden)
{
DEBUG_STREAM << "Build expiry (" << RP412_EXPIRY_TEXT
<< ") waived by RP412NOEXPIRY\n" << std::flush;
}
}
#endif
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush; DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM #ifdef RP412_STEAM
+53
View File
@@ -352,6 +352,55 @@ namespace
gPhase = PhaseStopped; gPhase = PhaseStopped;
} }
//---------------------------------------------------------------
// The countdown the engine shows, taken from the clock that will
// actually end the race (gMissionClockHook - see APPMGR.h).
//
// Called on the game thread, reading two volatile LONGs the console
// thread writes with InterlockedExchange. Aligned 32-bit reads, and
// a torn value could only mistime the cockpit clock by one tick of
// a countdown nobody reads to the millisecond - not worth a lock on
// the frame path.
//---------------------------------------------------------------
Logical MissionClock(Scalar *seconds_remaining)
{
//
// Only answer for the race this console is actually marshalling.
// Nothing ever uninstalls the hook, so a player who hosts a race
// and then joins somebody else's lobby still has it wired up -
// and in that race the console is a bystander whose gLengthMs and
// gRunStartTick belong to the previous mission entirely.
//
if (gWatchedApp == NULL || gWatchedApp != application)
{
return False;
}
if (!gMissionRunning)
{
return False; // not started, or already stopped
}
LONG length_ms = gLengthMs;
if (length_ms <= 0)
{
return False; // endless: nothing to count down
}
// DWORD subtraction, so a GetTickCount wrap costs nothing
LONG elapsed_ms = (LONG)(GetTickCount() - (DWORD) gRunStartTick);
LONG left_ms = length_ms - elapsed_ms;
if (left_ms < 0)
{
//
// The console polls at 250 ms, so the clock reaches zero
// slightly before the stop is dispatched. Hold at zero
// rather than showing negative time in the cockpit.
//
left_ms = 0;
}
*seconds_remaining = (Scalar) left_ms / 1000.0f;
return True;
}
//--------------------------------------------------------------- //---------------------------------------------------------------
// The game-thread tick: state reporting + engine-safe execution // The game-thread tick: state reporting + engine-safe execution
//--------------------------------------------------------------- //---------------------------------------------------------------
@@ -522,6 +571,10 @@ namespace
// game-thread execution point // game-thread execution point
gPerFrameHook = &ConsoleTick; gPerFrameHook = &ConsoleTick;
// the cockpit clock now counts down the same clock that will stop
// the race, rather than the engine's own reckoning of it
gMissionClockHook = &MissionClock;
// results intake from the RP layer // results intake from the RP layer
gConsoleScoreSink = &CollectFinalScore; gConsoleScoreSink = &CollectFinalScore;
+569
View File
@@ -0,0 +1,569 @@
#include "rpl4.h"
#pragma hdrstop
#include "rpl4environ.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//########################################################################
// environ.ini - see rpl4environ.h for why the exe owns this rather than
// the packaging script.
//########################################################################
namespace
{
const char kEnvironFileName[] = "environ.ini";
//-------------------------------------------------------------------
// The shipped configuration, verbatim. Lifted out of pack-dist.ps1
// so there is one source of truth and the exe alone can produce a
// working install.
//-------------------------------------------------------------------
const char kEnvironTemplate[] =
"# ============================================================================\n"
"# environ.ini - Red Planet 4.12 configuration\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments; anything without an = is ignored. Delete a line (or\n"
"# comment it out) to fall back to the built-in default.\n"
"#\n"
"# Input bindings live in bindings.txt beside the exe (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"#\n"
"# Your callsign and loadout are remembered in pilot.cfg beside the exe.\n"
"# Set them on the setup screen once and they come back every session,\n"
"# however you left - launching, joining a lobby, or quitting. Delete\n"
"# that file to start over.\n"
"\n"
"# ---- Core (the shipped configuration) --------------------------------------\n"
"\n"
"# Control stack: tokens separated by ; or , processed left to right.\n"
"# PAD the virtual RIO (XInput controller + keyboard,\n"
"# rebindable via bindings.txt)\n"
"# RIO real serial cockpit hardware on COM1\n"
"# RIO:COMn same, on another port (RIO:COM3, ...)\n"
"# KEYBOARD the engine keyboard handler\n"
"# MOUSE, JOYSTICK, FLIGHTSTICKPRO, THRUSTMASTER, DIJOYSTICK\n"
"# legacy pointer/joystick drivers (untested here)\n"
"# Unset falls back to KEYBOARD alone.\n"
"L4CONTROLS=PAD;KEYBOARD\n"
"\n"
"# Renderer bring-up argument. Only its presence is checked (the DPL\n"
"# resolution parsing it once fed is gone) and the game refuses to start\n"
"# without it - any non-empty value works. Leave as shipped.\n"
"DPLARG=1\n"
"\n"
"# DPL (renderer/scene) configuration file, searched beside the exe.\n"
"# Any notation file name; RPDPL.INI is the one that ships.\n"
"L4DPLCFG=RPDPL.INI\n"
"\n"
"# Gauge (MFD/instrument) canvas. Must name a page of GAUGE\\L4GAUGE.INI:\n"
"# 640x480x8 | 640x480x16 | 800x600x16\n"
"# Unset disables the gauge renderer (and with it all MFDs).\n"
"L4GAUGE=640x480x16\n"
"\n"
"# Plasma display.\n"
"# SCREEN render the pod's plasma glass in-window (currently\n"
"# parked off-layout)\n"
"# COM1, COM2... drive real plasma glass on that serial port\n"
"# (9600 baud, N81)\n"
"# Unset = no plasma display.\n"
"L4PLASMA=SCREEN\n"
"\n"
"# 0 = classic separate gauge windows; 1 = the single-window glass\n"
"# cockpit (all seven displays composed on a locked 1920x1080 canvas\n"
"# around the viewscreen); 2 = exploded diagnostic view (each display\n"
"# in its own native-resolution desktop window - MFDs 640x480, map\n"
"# 480x640 - decoded exactly as the pod's VDB split them, no downscale).\n"
"L4MFDSPLIT=1\n"
"\n"
"# The game window - and in the exploded view (L4MFDSPLIT=2) each display\n"
"# window - is placed fresh every launch, so moving one somewhere useful\n"
"# never survived the menu-race-menu loop. This remembers where you put\n"
"# them, in mfd_layout.cfg beside this file:\n"
"# off / 0 / unset computed placement only, no file (default)\n"
"# load put the windows back where they were saved\n"
"# save the same, and re-save on every finished drag\n"
"# The game window gets its size back too, so you can size the cockpit to\n"
"# suit your monitor once and keep it. The display windows get position\n"
"# only: their size follows their content and their button banks, so an\n"
"# old one is never restored over them. Arrange everything once with\n"
"# save, then leave it on load.\n"
"#\n"
"# The plasma display window takes part too, under \"Plasma Display\".\n"
"#\n"
"# Each line in mfd_layout.cfg reads <title>=<x>,<y>,<w>,<h>, and you can\n"
"# append ,noframe to take that window's title bar and border off - a\n"
"# cockpit that fills the monitor edge to edge without -fit taking the\n"
"# whole screen. Put the window where you want it first: a bare window\n"
"# has nothing to drag by. Delete the flag to get the frame back.\n"
"#RP412MFDLAYOUT=off\n"
"\n"
"# Size of the six secondary displays in the glass cockpit, as a\n"
"# percentage of their pod size. The pod bolted them down at one size;\n"
"# on a big panel there is room to trade viewscreen for instrument, so\n"
"# turn these up if you want to actually read the other displays while\n"
"# you fly. 100 = as the pod had them. Range 25-200 (out-of-range and\n"
"# unreadable values fall back to the group setting, then to 100).\n"
"#\n"
"# The scaling is applied in canvas units, before the cockpit is fitted\n"
"# to your window, so a given number looks the same on every monitor.\n"
"# The layout stays legal whatever you ask for - the panes are clamped\n"
"# against their actual neighbours, shrinking uniformly so a display\n"
"# never comes out stretched. They do overlap the viewscreen, exactly\n"
"# as the pod's bezels did, but never each other.\n"
"#\n"
"# L4MFDSCALE sets all five green MFDs at once.\n"
"L4MFDSCALE=100\n"
"\n"
"# ...and any single display can override it. Uncomment one to size it\n"
"# on its own - useful if you only care about, say, the damage readout.\n"
"# UL upper left UC upper center UR upper right\n"
"# LL lower left LR lower right\n"
"#L4MFDSCALE_UL=100\n"
"#L4MFDSCALE_UC=100\n"
"#L4MFDSCALE_UR=100\n"
"#L4MFDSCALE_LL=100\n"
"#L4MFDSCALE_LR=100\n"
"\n"
"# The portrait radar/map, sized on its own (it already sits at 1.35x\n"
"# the MFDs by default). It shares the canvas with whichever MFD is\n"
"# above it, so at extreme settings one of the two gives way.\n"
"L4RADARSCALE=100\n"
"\n"
"# Where the radar sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER / LEFTCENTRE)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER / RIGHTCENTRE)\n"
"# Anywhere but CENTER stops it blocking the middle of the road, which\n"
"# is worth having on a wide screen.\n"
"#\n"
"# In a bottom corner it is one of three panes along the bottom, and the\n"
"# lower MFD whose corner it takes slides inboard beside it. Halfway up\n"
"# a side it leaves the bottom row entirely and sits between that side's\n"
"# two MFDs - roomy on a tall radar, but if the MFDs on that side are\n"
"# also scaled up, the radar is the one that gives way (it has to clear\n"
"# both of them, and it grows from the middle in both directions).\n"
"L4RADARPOS=CENTER\n"
"\n"
"# The Winners Circle: at the end of a race the finishers are stood on\n"
"# the award platform in finishing order, with each pilot's callsign on\n"
"# the plate beside their spot, and held there for a few seconds before\n"
"# the results screen. 1 = show it, 0 = straight to the results.\n"
"RP412PODIUM=1\n"
"\n"
"# The shot is framed for you, but these move the camera if you want it\n"
"# somewhere else. Distances are in game units, measured from the middle\n"
"# of the group of finishers.\n"
"# STANDOFF how far out in front of the stand the camera sits\n"
"# HEIGHT how far above the group\n"
"# AIM height of the point it looks at, relative to the group -\n"
"# negative tilts down, positive tilts up\n"
"# ASPECT the stand was composed for a 4:3 pod monitor, so the shot\n"
"# is cropped to that shape with black either side. 0 runs it\n"
"# full width instead.\n"
"# FADEIN seconds to come up out of the black after the race fades\n"
"# CAM 0 watches from your own cockpit rather than off the stand\n"
"#RP412PODIUMSTANDOFF=36\n"
"#RP412PODIUMHEIGHT=12\n"
"#RP412PODIUMAIM=2\n"
"#RP412PODIUMASPECT=1.333\n"
"#RP412PODIUMFADEIN=0.45\n"
"#RP412PODIUMCAM=1\n"
"\n"
"# Override the game length the menu picked, in seconds. The shortest the\n"
"# menu offers is 3:00, which is a long wait when what you are testing is\n"
"# what happens at the buzzer. Unset = use the menu's choice.\n"
"#RP412MISSIONSECONDS=20\n"
"\n"
"# Simulation/render frame rate, integer frames/second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"TARGETFPS=60\n"
"\n"
"# How long one background pass may spend drawing cockpit gauges, in\n"
"# milliseconds. The gauges and the MFD/map displays are redrawn in the\n"
"# time left over after the 3D view; on a big, busy map there is none\n"
"# left, and at the original one-gauge-per-pass the map and the countdown\n"
"# clock could sit frozen for seconds at a time - until something (a\n"
"# death, say) lightened the 3D view enough for the backlog to drain.\n"
"# Working to a slice ties the refresh rate to elapsed time instead. Set\n"
"# 0 for the old behaviour; raise it to favour the displays over frame\n"
"# rate.\n"
"RP412GAUGESLICE=2\n"
"\n"
"# 1 = log how many times a second every cockpit display is actually\n"
"# refreshed, to rpl4.log. Watching the screen cannot tell a display that\n"
"# has stopped refreshing from one whose picture simply is not changing.\n"
"#RP412GAUGEDIAG=1\n"
"\n"
"# 0 = light the on-screen cockpit buttons on the same slow cadence the\n"
"# arcade pod's serial hardware used. The lamp state is filled once per\n"
"# gauge cycle, so under the load described above the lit buttons froze\n"
"# and flashing ones stalled while the 3D view stayed perfectly smooth.\n"
"# On by default: the buttons are refreshed every frame instead. Ignored\n"
"# when real RIO hardware is selected - the pod keeps its own cadence.\n"
"#RP412LAMPSWEEP=0\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running and steam_appid.txt beside the exe; without them the game\n"
"# logs the reason and falls back to plain TCP. 0 = TCP only.\n"
"RP412STEAM=1\n"
"\n"
"# Line up each remote player's clock with ours, so their vehicle is\n"
"# extrapolated from when its update was SENT rather than when it\n"
"# arrived. Without it every remote pod sits one network latency behind\n"
"# where it should be - invisible on the 1ms arcade LAN the engine was\n"
"# written for, a constant 50-150ms of lag over the internet. 0 restores\n"
"# the old arrival-time behaviour if you want to compare.\n"
"#RP412NETCLOCK=0\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"
"# lamp buttons glow with the panel, flash modes and all.\n"
"# Unset or nonzero = on (the default); 0 = off.\n"
"#RP412KEYLIGHT=0\n"
"\n"
"# The cabinets ran the game at unity and did all their volume and tone\n"
"# shaping outside it, in an amplifier and a 3-way crossover. You almost\n"
"# certainly have neither, so these two stand in for them. Both default\n"
"# to leaving the mix exactly as the pod played it.\n"
"\n"
"# Master volume, 0.0 to 2.0, the amplifier's knob. 1.0 is unity. The\n"
"# sound effects now carry the pitch, layering and dynamics the original\n"
"# AWE32 soundbanks ask for, which is a good deal livelier than earlier\n"
"# 4.12 builds - lower this if the whole thing sits too hot.\n"
"#\n"
"# PageUp and PageDown change it while you play, in steps of 0.05, and\n"
"# whatever you leave it on is written to volume.cfg beside the exe and\n"
"# used from then on - so this line only decides where a machine that has\n"
"# never been touched starts out. Delete volume.cfg to come back here.\n"
"#RP412AUDIOVOLUME=0.8\n"
"\n"
"# Bass trim, 0.0 to 1.0, the crossover's low band. 1.0 is the low end\n"
"# exactly as authored. The soundbanks put real weight under collisions,\n"
"# engines and explosions - deep layers earlier builds played at the\n"
"# wrong rate, so they barely sounded at all. Lower this to pull that\n"
"# back; it eases in below 22kHz of playback rate and reaches full cut\n"
"# on the deepest layers, leaving the mid and top alone.\n"
"#\n"
"# Home and End change it while you play, in steps of 0.05, and what you\n"
"# leave it on is written to bass.cfg beside the exe and used from then\n"
"# on - so this line only decides where an untouched machine starts.\n"
"# Delete bass.cfg to come back here.\n"
"#RP412AUDIOBASS=0.7\n"
"\n"
"# Invert the stick on top of whatever bindings.txt produces:\n"
"# X = invert X only, Y = invert Y only, XY = both (case-insensitive).\n"
"#L4PADFLIP=XY\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9:\n"
"# 0 = off, else 2..16 as the GPU supports (1 selects the driver's\n"
"# \"nonmaskable\" mode; unsupported counts fail device creation).\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# On-screen plasma glass (L4PLASMA=SCREEN only). SCALE = integer pixel\n"
"# size 1..16, default 4 (out-of-range values are ignored). POS = window\n"
"# top-left as X,Y screen coordinates; unset = auto, parked below the\n"
"# main window.\n"
"#L4PLASMASCALE=4\n"
"#L4PLASMAPOS=0,0\n"
"\n"
"# Fixed random seed (repeatable runs): any unsigned integer.\n"
"# Unset seeds from the clock.\n"
"#RANDOM=12345\n"
"\n"
"# ---- LAN play without Steam -------------------------------------------------\n"
"# Host a race over plain TCP: list the member pods' console channels\n"
"# (members run: rpl4opt.exe -windowed -res 1920 1080 -net 1501).\n"
"# RP412HOSTPODS comma-separated IP[:port] list, one entry per member\n"
"# pod; port defaults to 1501 per entry\n"
"# RP412HOSTPORT this machine's console port, integer > 0\n"
"# (default 1501)\n"
"# RP412HOSTADDR this machine's LAN IP as members can reach it\n"
"# (default 127.0.0.1)\n"
"#RP412HOSTPODS=192.168.1.20:1501,192.168.1.21:1501\n"
"#RP412HOSTPORT=1501\n"
"#RP412HOSTADDR=192.168.1.10\n"
"\n"
"# ---- Developer / testing ----------------------------------------------------\n"
"\n"
"# Nonzero arms the debug keys: Alt+W wireframe, Alt+V predator vision,\n"
"# Alt+F frame dump, Alt+/ perf stats, Alt+E event-queue dump.\n"
"# 0 or unset = off. (Alt+Q, the mission abort, is always live.)\n"
"#RP412DEVKEYS=1\n"
"\n"
"# Console race-length override, integer seconds (short test races).\n"
"# Values <= 0 are ignored.\n"
"#L4CONSOLELEN=30\n"
"\n"
"# Nonzero = Steam transport loopback self-test at boot (logs PASS/FAIL).\n"
"#RP412STEAMSELFTEST=1\n"
"\n"
"# ---- Arcade heritage (multi-monitor pods; not used on the desktop) ----------\n"
"# PRIMGAUGE / SECGAUGE / MFDGAUGE / MFDGAUGE2 pin a display to a monitor\n"
"# by adapter index (0, 1, 2...). SPANDISABLE: 0 = let the MFDs span one\n"
"# wide surface, nonzero = separate windows (setting MFDGAUGE2 alone also\n"
"# forces spanning off). L4EYES = \"x y z xrot yrot zrot [type]\" floats\n"
"# for a detached camera; a type starting with r offsets it relative to\n"
"# the pod. L4INTERCOM enables the crew intercom - only its presence\n"
"# matters (traditionally COM2). NOMODES skips the mode/lamp programming;\n"
"# presence alone triggers it, even NOMODES=0. LOGSIZE > 0 sizes the\n"
"# trace log in dev builds compiled with tracing.\n"
"#PRIMGAUGE=1\n"
"#SECGAUGE=2\n"
"#MFDGAUGE=3\n"
"#MFDGAUGE2=4\n"
"#SPANDISABLE=1\n"
"#L4EYES=1\n"
"#L4INTERCOM=COM2\n"
"#NOMODES=1\n"
"#LOGSIZE=1000000\n"
;
//-------------------------------------------------------------------
// Does the player's file mention this key at all - set, or commented
// out, or with whitespace in front of it?
//
// Deliberately generous: a key that is mentioned in ANY form is left
// alone. The alternative failure is worse than a missed notice, since
// environ.ini is applied line by line and a second copy of a key
// further down the file would silently override the player's own.
//-------------------------------------------------------------------
Logical FileMentionsKey(const char *text, const char *key, int key_length)
{
const char *cursor = text;
while ((cursor = strstr(cursor, key)) != NULL)
{
//
// Must be a whole key: preceded by start-of-line, whitespace
// or a comment mark, and followed by '='.
//
const char *after = cursor + key_length;
Logical starts_token =
(cursor == text) ||
(cursor[-1] == '\n') || (cursor[-1] == '\r') ||
(cursor[-1] == ' ') || (cursor[-1] == '\t') ||
(cursor[-1] == '#') || (cursor[-1] == ';');
if (starts_token)
{
const char *scan = after;
while (*scan == ' ' || *scan == '\t')
{
++scan;
}
if (*scan == '=')
{
return True;
}
}
cursor = after;
}
return False;
}
//-------------------------------------------------------------------
// Name every template key the player's file has never heard of. Not
// a fix - their file stays theirs - but it puts the reason for a
// missing feature in the log we already ask testers for.
//-------------------------------------------------------------------
void ReportUnmentionedKeys(const char *file_text)
{
char missing[1024]; // what gets printed
char seen[1024]; // the same keys as "KEY=", so the mention
// test above can dedupe against them
missing[0] = '\0';
seen[0] = '\0';
int count = 0; // how many are missing
int listed = 0; // how many fitted in the line
const char *cursor = kEnvironTemplate;
while (*cursor != '\0')
{
const char *line = cursor;
const char *end = strchr(line, '\n');
int length = (end != NULL) ? (int)(end - line) : (int) strlen(line);
cursor = (end != NULL) ? (end + 1) : (line + length);
//
// A template key line is "KEY=..." or "#KEY=..." - the
// commented ones are options that ship switched off, and a
// player who has never seen them wants to know they exist.
//
const char *scan = line;
int remaining = length;
if (remaining > 0 && *scan == '#')
{
++scan;
--remaining;
}
if (remaining <= 0 || !(isalpha((unsigned char) *scan) || *scan == '_'))
{
continue;
}
int key_length = 0;
while (key_length < remaining &&
(isalnum((unsigned char) scan[key_length]) || scan[key_length] == '_'))
{
++key_length;
}
if (key_length >= remaining || scan[key_length] != '=' || key_length > 60)
{
continue;
}
char key[64];
memcpy(key, scan, key_length);
key[key_length] = '\0';
if (FileMentionsKey(file_text, key, key_length))
{
continue;
}
//
// Templates list some keys twice (documented once, shown
// again in an example); do not name one twice.
//
if (FileMentionsKey(seen, key, key_length))
{
continue;
}
++count;
if (strlen(seen) + key_length + 3 < sizeof(seen))
{
strcat(seen, key);
strcat(seen, "=\n");
}
if (strlen(missing) + key_length + 3 < sizeof(missing))
{
if (missing[0] != '\0')
{
strcat(missing, ", ");
}
strcat(missing, key);
++listed;
}
}
if (count > 0)
{
//
// Say when the list is short of the count rather than letting
// a full buffer quietly shorten the answer.
//
DEBUG_STREAM << "Environ: " << kEnvironFileName << " does not mention "
<< count << " option(s) this build knows: " << missing;
if (listed < count)
{
DEBUG_STREAM << ", and " << (count - listed) << " more";
}
DEBUG_STREAM << "\nEnviron: they are at their built-in defaults - delete "
<< kEnvironFileName << " to get the documented file back\n"
<< std::flush;
}
}
}
void
RPL4Environ_Load()
{
//
// First run: lay down the documented default. From here on the file
// belongs to whoever is sitting at this machine.
//
FILE *file = fopen(kEnvironFileName, "rb");
if (file == NULL)
{
FILE *out = fopen(kEnvironFileName, "wb");
if (out != NULL)
{
fwrite(kEnvironTemplate, 1, strlen(kEnvironTemplate), out);
fclose(out);
DEBUG_STREAM << "Environ: wrote default " << kEnvironFileName
<< "\n" << std::flush;
}
else
{
DEBUG_STREAM << "Environ: could not write " << kEnvironFileName
<< " - running on built-in defaults\n" << std::flush;
}
file = fopen(kEnvironFileName, "rb");
}
if (file == NULL)
{
return;
}
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0)
{
fclose(file);
return;
}
char *text = new char[size + 1];
size_t read = fread(text, 1, size, file);
text[read] = '\0';
fclose(file);
//
// One KEY=VALUE per line. Comments, blanks and anything without an
// '=' are skipped; everything else goes into the environment, which
// is why a line here beats a variable set in the shell.
//
int applied = 0;
char line[1024];
const char *cursor = text;
while (*cursor != '\0')
{
int length = 0;
while (cursor[length] != '\0' && cursor[length] != '\n' &&
length < (int) sizeof(line) - 1)
{
line[length] = cursor[length];
++length;
}
line[length] = '\0';
cursor += length;
while (*cursor == '\n' || *cursor == '\r')
{
++cursor;
}
for (int i = length - 1; i >= 0; --i)
{
if (line[i] == '\r' || line[i] == '\n')
{
line[i] = '\0';
}
}
char *setting = line;
while (*setting == ' ' || *setting == '\t')
{
++setting;
}
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
{
continue;
}
putenv(setting);
++applied;
}
DEBUG_STREAM << "Environ: " << applied << " setting(s) from "
<< kEnvironFileName << "\n" << std::flush;
ReportUnmentionedKeys(text);
delete[] text;
}
+44
View File
@@ -0,0 +1,44 @@
//===========================================================================//
// File: rpl4environ.h //
// Project: MUNGA Brick: Red Planet LBE Application //
// Contents: environ.ini - written on first run, then the player's //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
#include "..\munga\style.h"
//########################################################################
//
// environ.ini is the game's configuration: one KEY=VALUE per line, read
// once at startup and pushed into the environment, so every option the
// engine reads through getenv can be set from a file a player can open.
//
// The exe owns the template and writes it when the file is absent, the
// same way bindings.txt works, rather than the packaging script laying
// one down on every unzip. That is what lets a tester drop a new build
// over an old folder and keep their settings: the file is theirs from
// the moment it exists, and nothing overwrites it.
//
// It cannot simply be optional. Without it 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.
//
// The cost of a file that is never overwritten is that a tester carrying
// one across many builds stops being offered new options. Options added
// later default to "behave as before", so nothing breaks - but it does
// go unnoticed, so the load names any template key the player's file
// does not mention. That line in rpl4.log is what turns "the podium does
// not work" into "your environ.ini predates RP412PODIUM".
//
//########################################################################
// Write environ.ini if it is not there, then read it into the
// environment. Call once, before anything reads a setting.
void
RPL4Environ_Load();
+272 -7
View File
@@ -231,6 +231,241 @@ namespace
Logical gHavePersist = False; Logical gHavePersist = False;
char gLastPilotName[24] = "Pilot"; char gLastPilotName[24] = "Pilot";
//---------------------------------------------------------------
// What the player set last time, in pilot.cfg beside bindings.txt:
// the callsign they typed and the loadout they picked.
//
// The loadout has always survived a race - gPersistSelection below
// is why the menu reopens the way you left it - but only for as
// long as the process lives. BT411 keeps the same things in
// fe_last.ini and had to, since it relaunches between missions;
// RP412 stayed in one process and so never needed a file. Closing
// the game was still a reset, which is what this fixes.
//
// KEY=VALUE like environ.ini, one line per group.
//---------------------------------------------------------------
const char kPilotFileName[] = "pilot.cfg";
//
// Stable file keys, independent of the on-screen headings - those
// carry spaces ("TIME OF DAY") and are free to be reworded.
//
struct GroupKey
{
int group;
const char *key;
};
const GroupKey kGroupKeys[] =
{
{ GroupScenario, "scenario" }, { GroupMap, "track" },
{ GroupVehicle, "vehicle" }, { GroupColor, "color" },
{ GroupBadge, "badge" }, { GroupTeam, "team" },
{ GroupPosition, "position" }, { GroupTime, "time" },
{ GroupWeather, "weather" }, { GroupLength, "length" },
};
//
// How many rows a group offers, so a stale or hand-edited index
// cannot select past the end of a list. The track list is the one
// that moves - football and the death race carry different maps -
// so it answers for whichever scenario is selected.
//
int GroupSize(int group, const int *selection)
{
switch (group)
{
case GroupScenario: return FE_COUNT(kScenarios);
case GroupMap:
{
int count = 0;
ActiveMaps(selection, &count);
return count;
}
case GroupVehicle: return FE_COUNT(kVehicles);
case GroupColor: return FE_COUNT(kColors);
case GroupBadge: return FE_COUNT(kBadges);
case GroupTeam: return FE_COUNT(kTeams);
case GroupPosition: return FE_COUNT(kPositions);
case GroupTime: return FE_COUNT(kTimes);
case GroupWeather: return FE_COUNT(kWeather);
case GroupLength: return FE_COUNT(kLengths);
}
return 0;
}
//
// A callsign is quoted into frontend.egg, joined into a
// comma-separated list for the results screen, and published as
// Steam lobby member data. Anything that could end a token early
// therefore has to go - a comma alone would split one pilot into
// two on the score sheet. Applied to what is typed as well as to
// what is read back, so the file cannot hold what the game will
// not accept.
//
void SanitizeCallsign(char *name, int size)
{
char clean[64];
int out = 0;
for (int i = 0; name[i] != '\0' && out < (int) sizeof(clean) - 1; ++i)
{
unsigned char c = (unsigned char) name[i];
if (c < 32 || c > 126) continue; // controls, high bytes
if (c == ',' || c == '"') continue; // egg and CSV delimiters
if (c == '#' || c == ';') continue; // pilot.cfg comment marks
clean[out++] = (char) c;
}
clean[out] = '\0';
char *start = clean;
while (*start == ' ' || *start == '\t')
{
++start;
}
int end = (int) strlen(start);
while (end > 0 && (start[end - 1] == ' ' || start[end - 1] == '\t'))
{
start[--end] = '\0';
}
if (start[0] == '\0')
{
strcpy(start, "Pilot");
}
strncpy(name, start, size - 1);
name[size - 1] = '\0';
}
void SavePilotSettings(const char *name, const int *selection)
{
FILE *file = fopen(kPilotFileName, "wt");
if (file == NULL)
{
DEBUG_STREAM << "FrontEnd: could not write " << kPilotFileName
<< "\n" << std::flush;
return;
}
fputs("# RP412 pilot settings, written by the game on the way out of\n"
"# the setup screen. Delete this file to start over.\n", file);
fprintf(file, "callsign=%s\n", name);
if (selection != NULL)
{
for (int i = 0; i < FE_COUNT(kGroupKeys); ++i)
{
fprintf(file, "%s=%d\n", kGroupKeys[i].key,
selection[kGroupKeys[i].group]);
}
}
fclose(file);
DEBUG_STREAM << "FrontEnd: saved callsign \"" << name << "\""
<< ((selection != NULL) ? " and loadout" : "")
<< " to " << kPilotFileName << "\n" << std::flush;
}
//
// Read once per run. Absent or unreadable simply leaves the
// built-in defaults in place - a missing file is a first run, not
// an error, and every value is range-checked so a hand-edited or
// out-of-date file cannot select past the end of a list.
//
void EnsurePilotSettingsLoaded()
{
static Logical loaded = False;
if (loaded)
{
return;
}
loaded = True;
FILE *file = fopen(kPilotFileName, "rt");
if (file == NULL)
{
return;
}
int selection[GroupCount];
memset(selection, 0, sizeof(selection));
selection[GroupLength] = 2; // 5:00, as the menu defaults
Logical have_loadout = False;
char line[256];
while (fgets(line, sizeof(line), file) != NULL)
{
char *cursor = line;
while (*cursor == ' ' || *cursor == '\t')
{
++cursor;
}
if (*cursor == '#' || *cursor == ';')
{
continue;
}
char *equals = strchr(cursor, '=');
if (equals == NULL)
{
continue;
}
*equals = '\0';
char *key = cursor;
char *value = equals + 1;
while (*value == ' ' || *value == '\t')
{
++value;
}
char *newline = strpbrk(value, "\r\n");
if (newline != NULL)
{
*newline = '\0';
}
if (_stricmp(key, "callsign") == 0)
{
char candidate[24];
strncpy(candidate, value, sizeof(candidate) - 1);
candidate[sizeof(candidate) - 1] = '\0';
SanitizeCallsign(candidate, sizeof(candidate));
strcpy(gLastPilotName, candidate);
continue;
}
for (int i = 0; i < FE_COUNT(kGroupKeys); ++i)
{
if (_stricmp(key, kGroupKeys[i].key) != 0)
{
continue;
}
int index = atoi(value);
if (index >= 0 && index < GroupSize(kGroupKeys[i].group, selection))
{
selection[kGroupKeys[i].group] = index;
have_loadout = True;
}
break;
}
}
fclose(file);
//
// The track list belongs to the scenario, and the file is read in
// whatever order it was written, so re-check the track once the
// scenario is settled - the same clamp the menu applies when the
// scenario is switched by hand.
//
if (have_loadout)
{
int map_count = 0;
ActiveMaps(selection, &map_count);
if (selection[GroupMap] >= map_count)
{
selection[GroupMap] = 0;
}
memcpy(gPersistSelection, selection, sizeof(gPersistSelection));
gHavePersist = True;
}
DEBUG_STREAM << "FrontEnd: callsign \"" << gLastPilotName << "\""
<< (have_loadout ? " and loadout" : "")
<< " from " << kPilotFileName << "\n" << std::flush;
}
// [pilots]-order names of the last launched race (owner first), // [pilots]-order names of the last launched race (owner first),
// comma separated - the network console labels results with them // comma separated - the network console labels results with them
char gLastPilotNamesCsv[256] = ""; char gLastPilotNamesCsv[256] = "";
@@ -1219,6 +1454,10 @@ Logical
{ {
gLastLaunchMode = FELaunchSingle; gLastLaunchMode = FELaunchSingle;
// before the lobby branch below: a member rejoining a room publishes
// the callsign as member data without the menu ever opening
EnsurePilotSettingsLoaded();
//--------------------------------------------------------------- //---------------------------------------------------------------
// Coming back from a race while still in a lobby: straight to // Coming back from a race while still in a lobby: straight to
// the room (the lobby outlives races - single binary payoff) // the room (the lobby outlives races - single binary payoff)
@@ -1248,6 +1487,7 @@ Logical
{ {
FEState fe; FEState fe;
memset(&fe, 0, sizeof(fe)); memset(&fe, 0, sizeof(fe));
EnsurePilotSettingsLoaded();
strcpy(fe.pilotName, gLastPilotName); strcpy(fe.pilotName, gLastPilotName);
if (gHavePersist) if (gHavePersist)
{ {
@@ -1346,20 +1586,41 @@ Logical
} }
//--------------------------------------------------------------- //---------------------------------------------------------------
// Harvest the loadout whichever way we leave the menu - the // Harvest the callsign whichever way we leave the menu, INCLUDING
// lobby publishes it as member data, launches build from it // a close: typing a name and then quitting is how somebody sets it
// for next time, and losing it there would be the one case that
// makes the whole thing feel unreliable. The loadout still only
// persists on a real exit - it is picked, not typed, and the menu
// reopens with it visible anyway.
//--------------------------------------------------------------- //---------------------------------------------------------------
if (!fe.closed) if (fe.nameEdit != NULL)
{ {
fe.pilotName[0] = '\0';
GetWindowTextA(fe.nameEdit, fe.pilotName, sizeof(fe.pilotName) - 1); GetWindowTextA(fe.nameEdit, fe.pilotName, sizeof(fe.pilotName) - 1);
if (fe.pilotName[0] == '\0') SanitizeCallsign(fe.pilotName, sizeof(fe.pilotName));
{
strcpy(fe.pilotName, "Pilot");
} }
else
{
strcpy(fe.pilotName, gLastPilotName);
}
strcpy(gLastPilotName, fe.pilotName); strcpy(gLastPilotName, fe.pilotName);
memcpy(gPersistSelection, fe.selection, sizeof(gPersistSelection)); memcpy(gPersistSelection, fe.selection, sizeof(gPersistSelection));
gHavePersist = True; gHavePersist = True;
}
//
// Written on the way out however the player leaves - launching,
// stepping into a lobby, or quitting. BT411 saves only on a
// launch, which loses a callsign typed by somebody who then
// changed their mind, and that is the one moment this feature
// exists for.
//
// Unconditionally, rather than only when something changed: the
// file is a few hundred bytes, and writing it every time means a
// value that was hand-edited out of range comes back corrected
// instead of being quietly re-rejected on every launch forever.
//
SavePilotSettings(gLastPilotName, gPersistSelection);
Logical launched = fe.launched; Logical launched = fe.launched;
Logical closed = fe.closed; Logical closed = fe.closed;
@@ -1825,6 +2086,10 @@ Logical
return False; return False;
} }
// this screen labels a row with the pilot's own callsign, and in the
// -egg and lobby paths it can be the first screen of the session
EnsurePilotSettingsLoaded();
ResultsState rs; ResultsState rs;
memset(&rs, 0, sizeof(rs)); memset(&rs, 0, sizeof(rs));
+84 -43
View File
@@ -725,6 +725,13 @@ void
mode_manager->AddModeMask(previousPresetModeMask); mode_manager->AddModeMask(previousPresetModeMask);
} }
//----------------------------------- //-----------------------------------
// Move the lamps with the mappings.
// Doing it here rather than in the
// switch handler keeps the keyboard
// presets (1-6) in step as well.
//-----------------------------------
NotifyOfPresetChange(previousPresetNumber, preset_number);
//-----------------------------------
// Save the new preset number // Save the new preset number
//----------------------------------- //-----------------------------------
previousPresetNumber = preset_number; previousPresetNumber = preset_number;
@@ -733,6 +740,19 @@ void
Check_Fpu(); Check_Fpu();
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
L4VTVControlsMapper::NotifyOfPresetChange(
int /*old_preset*/,
int /*new_preset*/
)
{
Check(this);
// The base mapper has no preset lamps to move.
Check_Fpu();
}
//############################################################################# //#############################################################################
//########################### ThrustmasterMapper ############################## //########################### ThrustmasterMapper ##############################
//############################################################################# //#############################################################################
@@ -1400,45 +1420,13 @@ void
if (message->dataContents > 0) if (message->dataContents > 0)
{ {
//----------------------------------- //-----------------------------------
// Choose a new preset // Choose a new preset. PresetEnable
// ignores a repeat of the lit switch
// and moves the lamps itself.
//----------------------------------- //-----------------------------------
int PresetEnable(
current_preset_number = (message->dataContents - 1) (message->dataContents - 1) - LBE4ControlsManager::ButtonSecondary7
- LBE4ControlsManager::ButtonSecondary7; );
if (previousPresetNumber != current_preset_number)
{
//-----------------------------------
// Set the old preset lamp to 'dim'
//-----------------------------------
if (previousPresetNumber >= 0)
{
Verify(previousPresetNumber < presetCount);
if (modeLamp[previousPresetNumber] != NULL)
{
Check(modeLamp[previousPresetNumber]);
modeLamp[previousPresetNumber]->SetState(L4Lamp::LampStateDim);
}
}
//-----------------------------------
// Set the new preset lamp to 'on'
//-----------------------------------
if (current_preset_number >= 0)
{
Verify(current_preset_number < presetCount);
if (modeLamp[current_preset_number] != NULL)
{
Check(modeLamp[current_preset_number]);
modeLamp[current_preset_number]->SetState(L4Lamp::LampStateOn);
}
}
//-----------------------------------
// Change presets
//-----------------------------------
PresetEnable(current_preset_number);
}
} }
Check_Fpu(); Check_Fpu();
} }
@@ -1655,6 +1643,44 @@ void
Check_Fpu(); Check_Fpu();
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The six amber switches down the map's right flank. Called by PresetEnable,
// so the lamps follow the mappings no matter what asked for the change.
//
void
VTVRIOMapper::NotifyOfPresetChange(int old_preset, int new_preset)
{
Check(this);
//----------------------------------
// Set the old preset lamp to 'dim'
//----------------------------------
if (old_preset >= 0)
{
Verify(old_preset < presetCount);
if (presetLamp[old_preset] != NULL)
{
Check(presetLamp[old_preset]);
presetLamp[old_preset]->SetState(L4Lamp::LampStateDim);
}
}
//----------------------------------
// Set the new preset lamp to 'on'
//----------------------------------
if (new_preset >= 0)
{
Verify(new_preset < presetCount);
if (presetLamp[new_preset] != NULL)
{
Check(presetLamp[new_preset]);
presetLamp[new_preset]->SetState(L4Lamp::LampStateOn);
}
}
Check_Fpu();
}
//############################################################################# //#############################################################################
// Construction and Destruction Support // Construction and Destruction Support
// //
@@ -1680,6 +1706,20 @@ VTVRIOMapper::VTVRIOMapper(
leftPedal = 0.0f; leftPedal = 0.0f;
rightPedal = 0.0f; rightPedal = 0.0f;
//------------------------------------------------
// There are no lamps until the mapping blocks
// below make them - and under NOMODES they never
// do, so the notify methods must see NULLs.
//------------------------------------------------
{
int
i;
for(i=0; i<configLampCount; ++i) configLamp[i] = NULL;
for(i=0; i<modeLampCount; ++i) modeLamp[i] = NULL;
for(i=0; i<presetCount; ++i) presetLamp[i] = NULL;
}
Check(application); Check(application);
LBE4ControlsManager LBE4ControlsManager
*controls = Cast_Object( *controls = Cast_Object(
@@ -1915,13 +1955,14 @@ VTVRIOMapper::VTVRIOMapper(
this this
); );
// These lamps are explicitly controlled by SelectPresetMessageHandler // These lamps are explicitly controlled by NotifyOfPresetChange.
modeLamp[i] = CreateControlledLamp(button_number[i]); // They are six, and they are NOT the four mode lamps above.
presetLamp[i] = CreateControlledLamp(button_number[i]);
if (modeLamp[i] != NULL) if (presetLamp[i] != NULL)
{ {
Check(modeLamp[i]); Check(presetLamp[i]);
modeLamp[i]->SetState( presetLamp[i]->SetState(
(i==0)? L4Lamp::LampStateOn : L4Lamp::LampStateDim (i==0)? L4Lamp::LampStateOn : L4Lamp::LampStateDim
); );
} }
+9
View File
@@ -104,6 +104,12 @@ ModeMask
// //
void void
PresetEnable(int preset_number); PresetEnable(int preset_number);
// Announced by PresetEnable for EVERY preset change, whichever way it was
// triggered - map-flank switch or keyboard. Platforms carrying preset
// lamps move them here; the base mapper has none.
virtual void
NotifyOfPresetChange(int old_preset, int new_preset);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Protected data // Protected data
// //
@@ -250,6 +256,9 @@ public:
void void
NotifyOfConfigurationModeChange(Logical new_state); NotifyOfConfigurationModeChange(Logical new_state);
void
NotifyOfPresetChange(int old_preset, int new_preset);
void void
SetPerformance(Performance performance) SetPerformance(Performance performance)
{ {
+12
View File
@@ -356,10 +356,22 @@ Logical
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// //
if (GetApplicationState() == RunningMission) if (GetApplicationState() == RunningMission)
{
// same rule as Application::ExecuteForeground - the console's
// countdown when there is one, our own reckoning otherwise. There
// is no console in mission review, so this takes the fallback.
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{ {
secondsRemainingInGame = secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted); currentMission->GetGameLength() - (Now() - gameStarted);
} }
}
CLEAR_FOREGROUND_PROCESSING(); CLEAR_FOREGROUND_PROCESSING();
Check_Fpu(); Check_Fpu();
+11
View File
@@ -70,6 +70,15 @@
<RandomizedBaseAddress>false</RandomizedBaseAddress> <RandomizedBaseAddress>false</RandomizedBaseAddress>
<GenerateDebugInformation>true</GenerateDebugInformation> <GenerateDebugInformation>true</GenerateDebugInformation>
</Link> </Link>
<!-- rpl4build.h is generated, not committed: the patch number is the
repository's commit count, so a hardcoded one would be stale the
moment it was committed. The script rewrites the header only when
the stamp actually changes, so this does not drag RPL4.CPP through
a recompile on every build. -->
<PreBuildEvent>
<Command>powershell -NoProfile -ExecutionPolicy Bypass -File "$(ProjectDir)..\stamp-version.ps1"</Command>
<Message>Stamping the build version from git</Message>
</PreBuildEvent>
</ItemDefinitionGroup> </ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile> <ClCompile>
@@ -109,6 +118,7 @@
<ClCompile Include=".\RPL4APP.cpp" /> <ClCompile Include=".\RPL4APP.cpp" />
<ClCompile Include=".\RPL4CONSOLE.cpp" /> <ClCompile Include=".\RPL4CONSOLE.cpp" />
<ClCompile Include=".\RPL4FE.cpp" /> <ClCompile Include=".\RPL4FE.cpp" />
<ClCompile Include=".\RPL4ENVIRON.cpp" />
<ClCompile Include=".\RPL4LOBBY.cpp" /> <ClCompile Include=".\RPL4LOBBY.cpp" />
<ClCompile Include=".\RPL4ARND.cpp" /> <ClCompile Include=".\RPL4ARND.cpp" />
<ClCompile Include=".\RPL4GAUG.cpp" /> <ClCompile Include=".\RPL4GAUG.cpp" />
@@ -147,6 +157,7 @@
<ClInclude Include=".\RPL4APP.h" /> <ClInclude Include=".\RPL4APP.h" />
<ClInclude Include=".\RPL4CONSOLE.h" /> <ClInclude Include=".\RPL4CONSOLE.h" />
<ClInclude Include=".\RPL4FE.h" /> <ClInclude Include=".\RPL4FE.h" />
<ClInclude Include=".\rpl4environ.h" />
<ClInclude Include=".\RPL4LOBBY.h" /> <ClInclude Include=".\RPL4LOBBY.h" />
<ClInclude Include=".\RPL4ARND.h" /> <ClInclude Include=".\RPL4ARND.h" />
<ClInclude Include=".\RPL4GAUG.h" /> <ClInclude Include=".\RPL4GAUG.h" />
+349 -382
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More