67 Commits
Author SHA1 Message Date
CydandClaude Fable 5 34abf40e7f Fifty hertz is the physics
RP412PHYSICSHZ defaults to 50: the simulation advances in fixed 20 ms
steps whatever the display does, and every machine plays the same race.
The proof preceded the promotion - a scripted lap with a crash, a burn,
a tumble and two respawns runs bit-identical at 30, 60 and 144 fps, and
identical runs reproduce exactly, neither of which was ever true of
this engine at any frame rate.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured on the biggest track, 1920x1080:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three parts to it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

It costs much less than the 111 MB on disk suggests: 767 of the 998 files
are byte-identical to assets/RP411 and git stores a blob once. The real
additions are the 223 WAVs, every one of which differs from ours, and
four audio files with no counterpart at all. Whether those WAVs are
better masters or just different renderings is unestablished, so they
stay unpromoted. Thumbs.db was already covered by .gitignore.
2026-08-07 12:13:08 -05:00
Cyd 5ad55aa1eb Nine tracks get the console's own map, and the projection is corrected
RPConfig.xml has always named a picture for nine of the eighteen tracks.
The pictures exist after all, so the page reads the mapping straight out
of the config and uses them: score zones, drop zone, the chambers drawn
properly and labelled. No reconstruction beats the real thing. The other
nine keep the course traced from their gates, and each card now says
which of the two it is showing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:38:16 -05:00
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
1534 changed files with 366005 additions and 995 deletions
+7
View File
@@ -84,3 +84,10 @@ assets/**/last.spl
# packaged releases (attached to Gitea releases, not tracked)
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/
+171
View File
@@ -16,6 +16,7 @@
#include "console.h"
#include "appmsg.h"
#include "evtstat.h"
#include "inputscript.h"
#if defined(TRACE_FOREGROUND_PROCESSING)
BitTrace Foreground_Processing("Foreground Processing");
@@ -273,6 +274,35 @@ Scalar
return mgr->GetFrameRate();
}
//
//#############################################################################
// GetMissionElapsed
//#############################################################################
//
Scalar
Application::GetMissionElapsed()
{
Check(this);
//
//--------------------------------------------------------------------------
// gameStarted is only ever stamped by RunMissionMessageHandler, so before
// the race it is uninitialized - and entities that are pre-runnable do get
// performed before then. Answer zero until the clock actually exists.
//--------------------------------------------------------------------------
//
if (
GetApplicationState() != RunningMission
&& GetApplicationState() != EndingMission
)
{
return 0.0f;
}
Scalar elapsed = Now() - gameStarted;
return (elapsed > 0.0f) ? elapsed : 0.0f;
}
//
//#############################################################################
// Initialize
@@ -577,6 +607,147 @@ Time startUpdate = Now();
updateManager->Execute(start_of_frame);
Time endUpdate = Now();
//
//--------------------------------------------------------------------------
// RP412PHYSTRACE=1: the player's position, sampled on the SIMULATION's
// own clock rather than per frame.
//
// This is the acceptance test for decoupling physics from frame rate.
// Run the same egg at two frame rates and diff the traces: today they
// diverge, because the simulation advances by whatever the last frame
// happened to cost (SIMULATE.cpp, slice = till - lastPerformance), so a
// 30 fps machine integrates in 33 ms steps and a 144 fps machine in 7 ms
// ones and they are not the same race. Fixed-step them and the two
// traces have to agree.
//
// Sampled every 0.25 s of SIM time on purpose - sampling per frame would
// compare different instants and prove nothing.
//--------------------------------------------------------------------------
//
{
static int physTrace = -1;
if (physTrace < 0)
{
const char *setting = getenv("RP412PHYSTRACE");
physTrace = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
//
// The scripted-input harness shares this anchor: its clock has to
// start at the same instant the vehicle is stopped, or the script
// timeline shifts against the settling transient by however long
// the load happened to take.
//
if ((physTrace || RPInputScript_Active()) &&
GetApplicationState() == RunningMission)
{
static Logical traceStarted = False;
static Time traceOrigin;
static Scalar traceDue = (Scalar) 0;
if (!traceStarted)
{
traceStarted = True;
traceOrigin = start_of_frame;
traceDue = (Scalar) 0;
//
//----------------------------------------------------------
// Start the measurement from a known state, not merely a
// known place.
//
// The pod sits on its pad simulating while the mission
// loads, and a load is not the same length twice - two runs
// of the same egg reached the green light 776 steps in and
// 599 steps in. Same pad, same position, different VELOCITY,
// and a trajectory compared from there measures the loader,
// not the physics.
//
// So: stop the vehicle dead and put its clock on the same
// mark. Every run then starts from rest at the same instant
// and any difference that follows belongs to the simulation.
//
// Test scaffolding, and it only runs with the trace asked
// for - it would be a cheat in a real race.
//----------------------------------------------------------
//
Player *reset_player = GetMissionPlayer();
Entity *reset_vehicle =
(reset_player != NULL)
? reset_player->GetPlayerVehicle() : NULL;
if (reset_vehicle != NULL &&
reset_vehicle->IsDerivedFrom(*Mover::GetClassDerivations()))
{
Mover *reset_mover = (Mover *) reset_vehicle;
reset_mover->localVelocity = Motion::Identity;
reset_mover->localAcceleration = Motion::Identity;
//
// The clock is NOT touched. lastPerformance sits on the
// vehicle's own step grid and the trace reads that grid
// instead. The first version forced it to the frame
// timestamp, which knocked the vehicle off its grid by
// a random fraction of a step per run - and that read
// as physics drift when it was only ever measurement.
//
traceOrigin = reset_mover->GetLastPerformance();
// the script's t=0 is this same instant
RPInputScript_Arm(traceOrigin);
DEBUG_STREAM << "PhysTrace: vehicle stopped "
<< "at the green light\n" << std::flush;
}
}
if (physTrace)
{
//
// Sampled on the SIMULATION's clock - the vehicle's own
// lastPerformance, which advances in whole fixed steps - so two
// runs sample at identical step counts and their traces compare
// exactly. Frame time samples mid-step at whatever phase the
// frame happened to land on, which compares different instants
// and calls the difference physics.
//
Player *clock_player = GetMissionPlayer();
Entity *clock_vehicle =
(clock_player != NULL) ? clock_player->GetPlayerVehicle() : NULL;
Scalar elapsed =
(clock_vehicle != NULL)
? (Scalar)(clock_vehicle->GetLastPerformance() - traceOrigin)
: (Scalar)(start_of_frame - traceOrigin);
if (elapsed >= traceDue)
{
traceDue += (Scalar) 0.25;
Player *trace_player = GetMissionPlayer();
Entity *trace_vehicle =
(trace_player != NULL) ? trace_player->GetPlayerVehicle() : NULL;
if (trace_vehicle != NULL)
{
extern long gPhysicsStepsTaken;
char buffer[160];
sprintf(buffer,
"PhysTrace: t=%7.3f steps=%6ld pos %12.5f %12.5f %12.5f\n",
(double) elapsed,
gPhysicsStepsTaken,
(double) trace_vehicle->localOrigin.linearPosition.x,
(double) trace_vehicle->localOrigin.linearPosition.y,
(double) trace_vehicle->localOrigin.linearPosition.z);
DEBUG_STREAM << buffer << std::flush;
}
}
}
}
}
CLEAR_UPDATE_MANAGER();
//
+9
View File
@@ -318,6 +318,15 @@ public:
Scalar
GetSecondsRemainingInGame()
{return secondsRemainingInGame;}
//
// Seconds since the console's RunMission started the race, counting up.
// Every machine anchors this on the same message, so anything derived
// from it agrees across the mesh without being replicated - see the
// clockwork doors in DOOR.cpp. Reads 0 outside a running mission
// (gameStarted holds garbage until RunMission stamps it).
//
Scalar
GetMissionElapsed();
ApplicationID
GetApplicationID()
{return applicationID;}
+68
View File
@@ -234,6 +234,74 @@ Background_Loop:
}
Time endBackground = Now();
//
//---------------------------------------------------------------------
// RP412GAUGEDIAG=1: where the frame actually goes.
//
// These four timestamps have been computed every frame since forever
// and never reported. The whole cockpit problem is a question about
// this split - the background loop only gets what the foreground
// leaves - and it has been measurable all along.
//---------------------------------------------------------------------
//
{
static int
frameSplitDiag = -1;
if (frameSplitDiag < 0)
{
const char
*setting = getenv("RP412GAUGEDIAG");
frameSplitDiag = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (frameSplitDiag)
{
static Scalar
foregroundSum = (Scalar) 0,
backgroundSum = (Scalar) 0,
frameSum = (Scalar) 0;
static int
splitFrames = 0;
static Logical
splitStarted = False;
static Time
splitWindowStart;
Time
splitNow = Now();
foregroundSum += (Scalar)(endForeground - startForeground);
backgroundSum += (Scalar)(endBackground - startBackground);
frameSum += (Scalar)(splitNow - beginFrameTimestamp);
++splitFrames;
if (!splitStarted)
{
splitStarted = True;
splitWindowStart = splitNow;
}
else if ((Scalar)(splitNow - splitWindowStart) >= (Scalar) 2)
{
char
buffer[200];
sprintf(buffer,
"FrameSplit: %d frames | foreground %.2f ms | "
"background %.2f ms | whole frame %.2f ms\n",
splitFrames,
(double)(foregroundSum * 1000.0f / splitFrames),
(double)(backgroundSum * 1000.0f / splitFrames),
(double)(frameSum * 1000.0f / splitFrames));
DEBUG_STREAM << buffer << std::flush;
foregroundSum = backgroundSum = frameSum = (Scalar) 0;
splitFrames = 0;
splitWindowStart = splitNow;
}
}
}
//char str[256];
//Scalar lastFrameLength = Now() - beginFrameTimestamp;
//sprintf(str, "RPL4 - %.2f FPS", 1.0f / lastFrameLength);
+10 -2
View File
@@ -94,8 +94,16 @@ void
}
headEntitySocket.Add(entity);
alDistanceModel(AL_LINEAR_DISTANCE);
alDopplerFactor(0.3f);
// FIDELITY (docs/SOUND.md F3/F10): the engine computes the AUTHORED distance
// 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
//
+65 -140
View File
@@ -72,172 +72,96 @@ Door::AttributeIndexSet& Door::GetAttributeIndex()
//#############################################################################
// Model Support
//
void
Door::ReadUpdateRecord(Simulation::UpdateRecord *message)
{
Check(this);
Check_Pointer(message);
Subsystem::ReadUpdateRecord(message);
UpdateRecord* record = (UpdateRecord*) message;
percentOpen = record->percentOpen;
switch (GetSimulationState())
{
case Opening:
case Closing:
phaseTimeRemaining = travelTime;
break;
case Opened:
case Closed:
phaseTimeRemaining = deadTime;
break;
}
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door updated to state "
// << GetSimulationState() << " @ "
// << application->GetSecondsRemainingInGame() << endl;
MoveCollisionVolume(percentOpen);
Check_Fpu();
}
// There is no ReadUpdateRecord/WriteUpdateRecord pair here on purpose. Doors
// are Hermit instances built independently on every host, so no door state is
// ever sent or received - the phase function below is the only thing that
// decides where a door is, and it reaches the same answer everywhere.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Door::WriteUpdateRecord(Simulation::UpdateRecord *record, int update_model)
{
Check(this);
Check_Pointer(record);
Subsystem::WriteUpdateRecord(record, update_model);
UpdateRecord *update = (UpdateRecord*)record;
update->percentOpen = percentOpen;
update->recordLength = sizeof(*update);
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Door::SlideDoor(Scalar time_slice)
Door::SlideDoor(Scalar)
{
Check(this);
//
//------------------------------------------------------------
// Advance the clock, then branch based upon our current state
//------------------------------------------------------------
//--------------------------------------------------------------------------
// The door is clockwork. Its position is a function of how long the race
// has been running, not of a countdown integrated frame by frame, so:
//
int new_state;
if (time_slice > 1.0f)
// - every machine puts this door in the same place from the same mission
// clock, without a byte crossing the wire,
// - a frame hitch of any length costs nothing, because there is no
// accumulated state left to fall behind (the old code dropped any slice
// over a second outright and never got that time back).
//
// Phase zero is the instant the door starts to close, fully open, which is
// where the original state machine began from DefaultState:
//
// [0, travel) Closing 1 -> 0
// [travel, travel+dead) Closed 0
// [travel+dead, 2travel+dead) Opening 0 -> 1
// [2travel+dead, cycle) Opened 1
//--------------------------------------------------------------------------
//
if (cycleTime <= 0.0f)
{
MoveCollisionVolume(0.0f);
SetSimulationState(Closed);
Check_Fpu();
return;
}
phaseTimeRemaining -= time_slice;
Scalar percent_open;
switch (GetSimulationState())
Check(application);
Scalar phase = fmod(application->GetMissionElapsed() - phaseOffset, cycleTime);
if (phase < 0.0f)
{
phase += cycleTime;
}
//
//------------------------------------------------------------------------
// If the door is not done opening, set its new position, otherwise branch
// to the opened state
//------------------------------------------------------------------------
//--------------------------------------------------------------------------
// Pick the band. Each division below is guarded by the comparison that
// selected the branch, so a door with a zero travelTime or deadTime simply
// loses that band rather than dividing by zero.
//--------------------------------------------------------------------------
//
case Opening:
Door_Opening:
new_state = Opening;
if (phaseTimeRemaining > 0.0f)
{
percent_open = 1.0f - phaseTimeRemaining/travelTime;
}
else
{
phaseTimeRemaining += deadTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door opened @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Opened;
}
currentVelocity.Subtract(
worldExtent,
GetEntity()->localOrigin.linearPosition
);
currentVelocity /= travelTime;
Check_Fpu();
break;
Scalar open_start = travelTime + deadTime;
int new_state;
Scalar percent_open;
//
//-------------------------------------------------------------
// If the door is ready to start closing, jump to closing state
//-------------------------------------------------------------
//
case Opened:
Door_Opened:
new_state = Opened;
if (phaseTimeRemaining <= 0.0f)
{
phaseTimeRemaining += travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door closing @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Closing;
}
percent_open = 1.0f;
currentVelocity = Vector3D::Identity;
Check_Fpu();
break;
//
//------------------------------------------------------------------------
// If the door is not done closing, set its new position, otherwise branch
// to the closed state
//------------------------------------------------------------------------
//
case DefaultState:
phaseTimeRemaining = travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door default @ "
// << application->GetSecondsRemainingInGame() << endl;
case Closing:
Door_Closing:
if (phase < travelTime)
{
new_state = Closing;
if (phaseTimeRemaining > 0.0f)
{
percent_open = phaseTimeRemaining/travelTime;
}
else
{
phaseTimeRemaining += deadTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door closed @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Closed;
}
percent_open = 1.0f - phase/travelTime;
currentVelocity.Subtract(
GetEntity()->localOrigin.linearPosition,
worldExtent
);
currentVelocity /= travelTime;
Check_Fpu();
break;
//
//-------------------------------------------------------------
// If the door is ready to start opening, jump to opening state
//-------------------------------------------------------------
//
case Closed:
Door_Closed:
}
else if (phase < open_start)
{
new_state = Closed;
if (phaseTimeRemaining <= 0.0f)
{
phaseTimeRemaining += travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door opening @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Opening;
}
percent_open = 0.0f;
currentVelocity = Vector3D::Identity;
Check_Fpu();
break;
}
else if (phase < open_start + travelTime)
{
new_state = Opening;
percent_open = (phase - open_start)/travelTime;
currentVelocity.Subtract(
worldExtent,
GetEntity()->localOrigin.linearPosition
);
currentVelocity /= travelTime;
}
else
{
new_state = Opened;
percent_open = 1.0f;
currentVelocity = Vector3D::Identity;
}
//
@@ -344,7 +268,8 @@ Door::Door(
//
// Initialize variables
//
phaseTimeRemaining = 0.0f;
phaseOffset = 0.0f;
cycleTime = 2.0f*(travelTime + deadTime);
currentPosition = Point3D::Identity;
SetPerformance(&Door::SlideDoor);
+16 -19
View File
@@ -20,16 +20,11 @@ struct Door__SubsystemResource:
collisionID;
};
//##########################################################################
//##################### Chute::UpdateRecord #####################
//##########################################################################
struct Door__UpdateRecord :
public Subsystem::UpdateRecord
{
Scalar
percentOpen;
};
//
// A door has no update record. It is Hermit clockwork - every host builds
// its own out of the map stream and derives the position from the mission
// clock, so there is nothing to publish and nothing to receive.
//
//##########################################################################
//######################### CLASS Door ########################
@@ -91,7 +86,6 @@ public:
typedef void
(Door::*Performance)(Scalar time_slice);
typedef Door__UpdateRecord UpdateRecord;
void
SetPerformance(Performance performance)
@@ -109,12 +103,6 @@ public:
GetFirstBoxedSolid()
{Check(this); return collisionVolumes;}
protected:
void
WriteUpdateRecord(Simulation::UpdateRecord *message, int update_model);
void
ReadUpdateRecord(Simulation::UpdateRecord *message);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
//
@@ -152,9 +140,18 @@ private:
worldExtent;
Scalar
phaseTimeRemaining,
travelTime,
deadTime;
deadTime,
//
// Where in the cycle this door sits at mission time zero, and the
// length of one full open-close-open cycle. phaseOffset is not in
// the subsystem resource yet: every door in the game is in lockstep,
// and adding a field to Door__SubsystemResource changes its sizeof,
// which invalidates every prebuilt .res. Wire it to a "PhaseOffset"
// notation entry when there is a reason to rebuild resources.
//
phaseOffset,
cycleTime;
int collisionVolumeCount;
+8 -1
View File
@@ -126,8 +126,15 @@ Logical
}
creation_message->classToCreate = RegisteredClass::DoorFrameClassID;
//
// Hermit, not Master: every host builds its own doorframe out of the map
// stream (see the DoorFrameClassID exemption in LoadMapStream) and runs it
// off the mission clock. Hermit is the instance kind DynamicEntityCreation
// does NOT broadcast, which is what stops N machines each announcing the
// same doorframe and producing N-squared of them.
//
creation_message->instanceFlags =
MasterInstance|DynamicFlag|MapFlag|TrappedFlag;
HermitInstance|DynamicFlag|MapFlag|TrappedFlag;
return true;
}
+39 -1
View File
@@ -224,9 +224,47 @@ void
//-----------------------------------------------------
//
highest = highest - lowest + 1;
//
//------------------------------------------------------------------------
// RP412SPAWNZONE pins which drop zone is tried first, so a test run can
// be repeated.
//
// The pick below is Random(), and Random() is seeded - but the seed only
// makes a run repeatable if the same NUMBER of draws happens first, and
// that depends on how many frames the mission load took. So two runs of
// the same egg with the same RANDOM= still start on different pads, over
// different ground, and no two traces can be compared. That is not a
// game bug, but it makes the physics unmeasurable.
//
// Pinned, the zone is tried first and the loop falls back to the random
// walk if it is taken - so this can never wedge, and it changes nothing
// unless it is set.
//------------------------------------------------------------------------
//
static int
pinnedZone = -2;
if (pinnedZone == -2)
{
const char *setting = getenv("RP412SPAWNZONE");
pinnedZone = (setting != NULL) ? atoi(setting) : -1;
}
Logical
tryPinnedZone = (pinnedZone >= 0) ? True : False;
while (remaining)
{
i = lowest + Random(highest);
if (tryPinnedZone)
{
tryPinnedZone = False;
i = lowest + (pinnedZone % highest);
}
else
{
i = lowest + Random(highest);
}
Verify(i < dropZoneCount && i >= 0);
if (IsAvailable(i))
{
+97
View File
@@ -745,6 +745,103 @@ void
//
if (GetInstance() != ReplicantInstance)
{
//
//----------------------------------------------------------------
// Fixed-step: the subsystems and the entity advance TOGETHER,
// one step at a time, because they read each other mid-flight.
// The VTV's hover spring is computed from its thrusters'
// measured heights, and each thruster measures from where the
// vehicle IS - so thrusters stepped twice against a vehicle
// that has not moved yet hand back two identical height
// samples, and the spring fires twice on stale data. Measured,
// that pod climbs at 30 fps and flies level at 144.
//
// So the step loop lives HERE, above both: everyone is walked
// to the same sub-frame instant before anyone takes the next
// step. Watchers and the update stream still run once per
// frame, after the loop - stepping is physics, watching is
// I/O, and only the first belongs inside.
//
// The interleave keys off the ENTITY's own clock so a
// subsystem created mid-flight (they are made alongside their
// owner) can never wedge the loop.
//----------------------------------------------------------------
//
Scalar fixed_step = Simulation::FixedStep();
if (fixed_step > (Scalar) 0)
{
//
// One grid for the whole vehicle. Every Simulation anchors
// its own lastPerformance at its creation time, so an
// entity and its subsystems were stepping on grids offset
// by a random fraction of a step - deterministic within a
// run, DIFFERENT between runs, because creation times ride
// on load timing. The thrusters' measurements then landed
// a different sub-step distance from the vehicle's
// integration every launch, which is physics drift no seed
// can pin. Snap the subsystems onto the entity's grid; the
// interleave below then keeps everyone in lockstep by
// construction, and once aligned this assignment is a
// no-op every frame after.
//
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->SetLastPerformance(
GetLastPerformance());
}
}
Time step_till = GetLastPerformance();
step_till += fixed_step;
while (step_till <= till)
{
//
// BeginStep on the ENTITY comes before the subsystems
// perform: the Mover's force accumulator is cleared
// here, and the thrusters then ADD this step's forces
// into a clean slate. The first version left that
// clear on the per-frame path, so a two-step frame
// integrated step one's thrust twice - and since how
// many steps land in a frame rides on wall-clock
// jitter, no two runs saw the same force history.
// Identical configs measured 0.23 apart because of it.
//
BeginStep();
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->BeginStep();
subsystemArray[i]->PerformTo(step_till);
}
}
Simulation::PerformTo(step_till);
step_till += fixed_step;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->WatchAndWrite(update_stream);
}
}
SET_PERFORM_ENTITY();
Simulation::WatchAndWrite(update_stream);
Check_Fpu();
CLEAR_PERFORM_ENTITY();
CLEAR_PERFORM_SUBSYSTEMS();
return;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i])
+17
View File
@@ -690,6 +690,23 @@ void
DEBUG_STREAM << "." << std::flush;
}
//
// The rate this gauge runs at, and which tier that puts it in. The
// renderer walks a sixteen-step wheel and a gauge draws only on the
// steps its rate names, so tier 4 is one turn of the wheel between
// redraws - seconds, once a race has the passes down to a handful a
// second. Without this the profile says how EXPENSIVE each gauge is
// but not how RARELY it runs, and the second one is what makes a
// display look stuck.
//
{
char
rate_buffer[32];
sprintf(rate_buffer, "%04x/t%d ", (unsigned) rate, DiscernTier());
DEBUG_STREAM << rate_buffer << std::flush;
}
if (profileCycles > 0)
{
Scalar
+100 -1
View File
@@ -21,6 +21,31 @@
BitTrace Gauge_Renderer("Gauge Renderer");
#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
//#######################################################################
@@ -3672,6 +3697,60 @@ Logical
Logical
result;
//
// RP412GAUGEPROFILE=<seconds> - dump the gauge profile on that
// cadence. Off unless set.
//
// ProfileReport already exists and PROFILE_GAUGES is already on, so
// the numbers are being collected whether anyone looks or not. It was
// only reachable from F11 through the RIO controls mapper, which is
// not the mapper a desktop player is running - so on PAD;KEYBOARD it
// could not be reached at all. This gives it a way out.
//
// It reports every gauge with its rate, its tier, how many times it
// ran and what it cost, then clears - so each dump covers the
// interval since the last one rather than all of history.
//
{
static long
profileInterval = -1L;
if (profileInterval < 0L)
{
const char
*setting = getenv("RP412GAUGEPROFILE");
profileInterval = (setting != NULL) ? atol(setting) : 0L;
if (profileInterval < 0L)
{
profileInterval = 0L;
}
}
if (profileInterval > 0L)
{
static Logical
profileScheduled = False;
static Time
profileDue;
Time
profileNow = Now();
if (!profileScheduled)
{
profileScheduled = True;
profileDue = profileNow;
profileDue += profileInterval * 1000L;
}
else if (profileDue < profileNow)
{
profileDue = profileNow;
profileDue += profileInterval * 1000L;
ProfileReport();
}
}
}
Time start, end;
int oldTaskMode = taskMode;
@@ -3683,7 +3762,27 @@ Logical
case background:
{
result = ProcessOneActiveGauge();
//-----------------------------------------------------------
// 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();
}
while (result && taskMode == background && Now() < slice_end);
break;
}
+182
View File
@@ -0,0 +1,182 @@
#include "munga.h"
#pragma hdrstop
#include "inputscript.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//##########################################################################
// RP412INPUTSCRIPT - see the header for what and why. This file is the
// how: a timeline of rows parsed once, held in a fixed array, evaluated
// by walking to the last row at or before the asked-for time.
//##########################################################################
namespace
{
enum { inputScriptMaxRows = 256 };
struct InputScriptRow
{
float t;
float throttle;
float stickX;
float stickY;
float pedals;
};
InputScriptRow gRows[inputScriptMaxRows];
int gRowCount = 0;
int gLoaded = -1; // -1 not tried, 0 no script, 1 loaded
Logical gArmed = False;
Time gOrigin;
float ClampInto(float value, float low, float high)
{
if (value < low) return low;
if (value > high) return high;
return value;
}
void Load()
{
gLoaded = 0;
const char *path = getenv("RP412INPUTSCRIPT");
if (path == NULL || *path == '\0')
{
return;
}
FILE *file = fopen(path, "rt");
if (file == NULL)
{
DEBUG_STREAM << "InputScript: cannot read '" << path
<< "' - driving unscripted\n" << std::flush;
return;
}
char line[256];
float last_t = -1.0f;
while (fgets(line, sizeof(line), file) != NULL &&
gRowCount < inputScriptMaxRows)
{
InputScriptRow row;
if (sscanf(line, " %f %f %f %f %f",
&row.t, &row.throttle, &row.stickX,
&row.stickY, &row.pedals) != 5)
{
continue; // comments, blanks, ragged lines
}
//
// Clamped HERE, not at sample time, so a script asking for
// throttle 2.0 is corrected once and visibly rather than
// silently every step - and the mapper's own Verify range
// checks can never trip on scripted input.
//
row.throttle = ClampInto(row.throttle, 0.0f, 1.0f);
row.stickX = ClampInto(row.stickX, -1.0f, 1.0f);
row.stickY = ClampInto(row.stickY, -1.0f, 1.0f);
row.pedals = ClampInto(row.pedals, -1.0f, 1.0f);
if (row.t < last_t)
{
DEBUG_STREAM << "InputScript: row at t=" << row.t
<< " is out of order - dropped\n" << std::flush;
continue;
}
last_t = row.t;
gRows[gRowCount++] = row;
}
fclose(file);
if (gRowCount > 0)
{
gLoaded = 1;
DEBUG_STREAM << "InputScript: '" << path << "', " << gRowCount
<< " row(s), last at t=" << gRows[gRowCount - 1].t
<< "s\n" << std::flush;
}
else
{
DEBUG_STREAM << "InputScript: '" << path
<< "' held no usable rows - driving unscripted\n" << std::flush;
}
}
}
int
RPInputScript_Active()
{
if (gLoaded < 0)
{
Load();
}
return (gLoaded == 1) ? 1 : 0;
}
void
RPInputScript_Arm(const Time &origin)
{
if (!RPInputScript_Active())
{
return;
}
gOrigin = origin;
gArmed = True;
DEBUG_STREAM << "InputScript: armed at the green light\n" << std::flush;
}
int
RPInputScript_Sample(
const Time &now,
float *throttle_out,
float *stick_x_out,
float *stick_y_out,
float *pedals_out
)
{
if (!gArmed || gLoaded != 1)
{
return 0;
}
Scalar t = now - gOrigin;
if (t < (Scalar) 0)
{
t = (Scalar) 0;
}
//
// The last row at or before t holds; before the first row, neutral.
// A linear walk, but the list is tiny and already ordered.
//
const InputScriptRow *current = NULL;
for (int i = 0; i < gRowCount; ++i)
{
if (gRows[i].t <= (float) t)
{
current = &gRows[i];
}
else
{
break;
}
}
if (current == NULL)
{
*throttle_out = 0.0f;
*stick_x_out = 0.0f;
*stick_y_out = 0.0f;
*pedals_out = 0.0f;
}
else
{
*throttle_out = current->throttle;
*stick_x_out = current->stickX;
*stick_y_out = current->stickY;
*pedals_out = current->pedals;
}
return 1;
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
//##########################################################################
// RP412INPUTSCRIPT - scripted analog input, on the simulation's clock.
//
// A race cannot be called deterministic until somebody DRIVES it, and a
// human cannot drive the same lap twice. This feeds the four analog
// channels the controls mapper interprets - throttle, stick X/Y, pedals -
// from a timeline file instead, evaluated against the mapper's own step
// clock, so the same script produces the same race at any frame rate.
//
// The file named by RP412INPUTSCRIPT= holds one row per change:
//
// # t throttle stickX stickY pedals
// 0.0 0.0 0 0 0
// 2.0 1.0 0 0 0
// 6.0 1.0 0.5 0 0
//
// Times are seconds of SIMULATION time from the green light. Each row
// HOLDS until the next row's time - a step function, no interpolation,
// because interpolation would sample differently at different physics
// rates and the whole point is that nothing does.
//
// Armed by the green-light anchor in Application::ExecuteForeground (the
// same instant RP412PHYSTRACE stops the pod dead), so the script clock,
// the trace clock and the vehicle's state all start together.
//
// Test harness: off unless the environment names a file, costs nothing
// when off, and it would be a cheat in a real race.
//##########################################################################
class Time;
// is a script named and readable? (parsed once, on first ask)
int
RPInputScript_Active();
// the green light: script time zero is this instant
void
RPInputScript_Arm(const Time &origin);
// evaluate at 'now' (a simulation clock, normally GetLastPerformance()).
// Returns 0 before Arm or with no script - callers leave their own
// values alone. Outputs are clamped to the mapper's legal ranges.
int
RPInputScript_Sample(
const Time &now,
float *throttle_out, // 0..1
float *stick_x_out, // -1..1
float *stick_y_out, // -1..1
float *pedals_out // -1..1
);
+13 -1
View File
@@ -411,8 +411,20 @@ void
// supposed to
//---------------------------------------------------------------------
//
//
// Doorframes are exempt: they are clockwork, computed identically on
// every machine from the mission clock, so each host builds its own
// Hermit copy instead of one host owning it and replicating. That
// also means they survive a peer dropping, which owned doors do not -
// ownership transfer is not implemented. Note this changes how many
// times the cursor below is advanced, so old and new builds deal the
// remaining map entities differently: they cannot share a session.
//
Logical post_make_message = True;
if (Entity::EntityFlagsIsMap(message->instanceFlags))
if (
Entity::EntityFlagsIsMap(message->instanceFlags)
&& message->classToCreate != DoorFrameClassID
)
{
Check(application);
HostManager *host_manager = application->GetHostManager();
+40 -1
View File
@@ -658,9 +658,48 @@ Bye_Bye:
//
//-----------------------------------------------
// Make sure the position quaternion stays stable
//
// Frame-counting, so it only runs on the frame-coupled path - fixed
// steps do the same thing in BeginStep, counted in STEPS, because
// "every 20 frames" lands at a different point of the step sequence
// on every machine and rounding at different points is drift.
//-----------------------------------------------
//
if (++normalizeCount == 20)
if (Simulation::FixedStep() <= (Scalar) 0 && ++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
normalizeCount = 0;
}
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The per-STEP set-up. This is the same work Mover::PerformAndWatch does
// once per frame above - and once per frame is exactly wrong under fixed
// stepping: the thrusters ADD their forces into localAcceleration every
// step, so an accumulator cleared per frame carries step one's thrust
// into step two whenever a frame holds two steps. How many steps a frame
// holds depends on wall-clock jitter, which made identical runs diverge
// by a quarter of a metre while sitting still on the pad.
//
// Idempotent on purpose: the frame-level copy still runs first on every
// path, and repeating this at each step start is a recompute from
// current state, not an accumulation.
//
void
Mover::BeginStep()
{
Check(this);
localVelocity.linearMotion.MultiplyByInverse(
worldLinearVelocity,
localToWorld
);
localAcceleration = Motion::Identity;
previousOrigin = localOrigin;
if (++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
normalizeCount = 0;
+8
View File
@@ -265,6 +265,14 @@ protected:
MemoryStream *update_stream
);
//
// Per-step set-up under fixed stepping: clears the force accumulator
// the thrusters add into, so each step integrates only its own
// forces. See the definition for the frame-jitter bug this closes.
//
void
BeginStep();
int
normalizeCount;
Environment
+199 -11
View File
@@ -621,9 +621,206 @@ void*
}
}
//#############################################################################
// RP412PHYSICSHZ - the size of one simulation step, as a rate in hertz.
//
// The engine simulates TO a timestamp: every entity keeps a lastPerformance
// marking how far it has been simulated, and PerformAndWatch hands Perform()
// the difference. That difference used to be however long the last frame
// took, which made the frame rate part of the physics - explicitly so, since
// Mover scales its bounce and penetration thresholds by delta_t.
//
// Advancing lastPerformance in fixed steps instead makes it the accumulator
// a fixed-step loop needs, and every Perform() in the game gets an identical
// dt without one of them being touched.
//
// 0 restores the old behaviour for comparison. The RATE is a game-feel
// decision, not a technical one: thirty years of handling constants were
// tuned against the DOS build's 40 ms steps, and RP412 has been running
// ~18 ms variable ones, so the feel has already drifted. Whatever is chosen
// here becomes the canonical physics for pods and PCs alike.
//#############################################################################
static Scalar
FixedPhysicsStep()
{
static Scalar
step = (Scalar) -1;
if (step < (Scalar) 0)
{
const char
*setting = getenv("RP412PHYSICSHZ");
//
// 50 Hz is the default: a 20 ms step, exact on the millisecond
// clock, and the rate whose settled hover ride height measured
// closest to the frame-coupled physics the game has always run.
// Proven before it was defaulted - a scripted lap with a crash,
// a burn and two respawns runs bit-identical at 30, 60 and 144
// fps, and identical runs reproduce exactly. 0 restores the
// original frame-coupled behaviour, where the frame rate is
// part of the physics.
//
int rate = (setting != NULL) ? atoi(setting) : 50;
//
// Guard the arithmetic rather than the taste: a rate below the
// frame rate is a legitimate choice (the pods ran at 25), but a
// step of zero or a negative one is not a choice at all.
//
if (rate < 0)
{
rate = 0;
}
if (rate > 1000)
{
rate = 1000;
}
step = (rate > 0) ? ((Scalar) 1 / (Scalar) rate) : (Scalar) 0;
DEBUG_STREAM << "Physics: ";
if (rate > 0)
{
DEBUG_STREAM << "fixed step, " << rate << " Hz";
//
// The engine's clock counts MILLISECONDS, so a step is
// really round(1000/rate) ms. A rate that does not divide
// 1000 evenly therefore runs at a neighbouring rate wearing
// this one's name - 60 asks for 16.67 ms and gets 17, which
// is 58.8 Hz. Say so, and name the rates that mean what
// they say.
//
if ((1000 % rate) != 0)
{
int step_ms = (1000 + rate / 2) / rate;
DEBUG_STREAM << " - NOT millisecond-exact, steps will run "
<< step_ms << " ms (" << (1000.0f / (float) step_ms)
<< " Hz). 25, 50 and 100 are exact";
}
}
else
{
DEBUG_STREAM << "frame-coupled (RP412PHYSICSHZ=0)";
}
DEBUG_STREAM << "\n" << std::flush;
}
return step;
}
//
// How far behind one frame may catch up: a quarter second of simulation,
// whatever the rate - enough to ride out a texture load or an alt-tab,
// short of letting a stalled machine spiral. Counted in steps because the
// loop is, so 6 steps at 25 Hz, 12 at 50, 25 at 100.
//
static int
MaximumCatchUpSteps(Scalar step)
{
int steps = (int)((Scalar) 0.25 / step);
return (steps < 4) ? 4 : steps;
}
// how many fixed steps the whole simulation has taken - the trace prints it,
// so 'is the step actually fixed' is answered by measurement not by reading
long gPhysicsStepsTaken = 0;
//#############################################################################
// Simulation Support
//
Scalar
Simulation::FixedStep()
{
return FixedPhysicsStep();
}
void
Simulation::PerformTo(const Time& till)
{
Check(this);
Check(&till);
Scalar step = FixedPhysicsStep();
if (step > (Scalar) 0)
{
//
//------------------------------------------------------------------
// Fixed step. The simulation advances in whole steps of the same
// size on every machine, and whatever is left over waits for the
// next frame - lastPerformance is the accumulator, and always was.
//
// Before this, the slice was simply however long the last frame
// took, so a 30 fps machine integrated gravity in 33 ms steps and
// a 144 fps machine in 7 ms ones. Nothing in any Perform()
// changes: it is handed a dt it can rely on instead of one that
// depended on the graphics card.
//
// NOTE the caller decides the interleaving. An entity's spring
// forces are computed from its subsystems (the VTV reads its
// thrusters' measured heights), so the subsystems and the entity
// must advance TOGETHER, one step at a time -
// Entity::PerformAndWatch owns that loop and hands everyone the
// same sub-frame 'till'. Stepping a subsystem all the way to the
// frame boundary before its owner moves at all is how the first
// attempt at this produced a pod that climbed at 30 fps and flew
// level at 144: two spring impulses from one stale height sample.
//------------------------------------------------------------------
//
Scalar behind = till - lastPerformance;
int taken = 0;
int max_steps = MaximumCatchUpSteps(step);
while (behind >= step && taken < max_steps)
{
Perform(step);
++gPhysicsStepsTaken;
lastPerformance += step;
behind -= step;
++taken;
}
//
// A machine that cannot keep up must not try to buy back the whole
// backlog next frame - that costs more time, which makes a bigger
// backlog. Drop what could not be run and carry on: the game slows
// down rather than seizing, and it does so identically everywhere.
//
if (taken >= max_steps && behind >= step)
{
lastPerformance = till;
}
}
else
{
Scalar slice = till - lastPerformance;
lastPerformance = till;
Perform(slice);
}
Check_Fpu();
}
void
Simulation::BeginStep()
{
// nothing by default - see the header
}
void
Simulation::WatchAndWrite(MemoryStream *update_stream)
{
Check(this);
if (!AreWatchersDelayed())
{
ExecuteWatchers();
}
WriteSimulationUpdate(update_stream);
Check_Fpu();
}
void
Simulation::PerformAndWatch(
const Time& till,
@@ -633,17 +830,8 @@ void
Check(this);
Check(&till);
Scalar slice = till - lastPerformance;
lastPerformance = till;
Perform(slice);
if (!AreWatchersDelayed())
{
ExecuteWatchers();
}
WriteSimulationUpdate(update_stream);
Check_Fpu();
PerformTo(till);
WatchAndWrite(update_stream);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+33
View File
@@ -147,6 +147,36 @@ public:
MemoryStream *update_stream
);
//
// The two halves of PerformAndWatch, so an ENTITY can interleave its
// subsystems' physics with its own, step by step, and still run the
// watchers and the update stream once per frame. PerformTo advances
// the simulation to the given time - in fixed steps when
// RP412PHYSICSHZ names a rate, in one variable slice otherwise.
//
void
PerformTo(const Time& till);
void
WatchAndWrite(MemoryStream *update_stream);
//
// Called by the entity interleave at the TOP of every fixed step,
// before any subsystem adds its forces for that step. Per-frame set-up
// work - clearing a force accumulator, deriving local velocity from
// world state - belongs here when the fixed step is on, because "once
// per frame" is a wall-clock cadence and the whole point is that wall
// clock no longer reaches the physics. Default: nothing.
//
virtual void
BeginStep();
//
// The fixed step in seconds, 0 when frame-coupled. Global on purpose:
// a mixed-rate simulation would be a worse bug than either mode.
//
static Scalar
FixedStep();
void
DoNothingOnce(Scalar time_slice);
void
@@ -155,6 +185,9 @@ public:
void
SetLastPerformance(const Time& when)
{Check(this); Check(&when); lastPerformance = when;}
const Time&
GetLastPerformance() const
{Check(this); return lastPerformance;}
void
RequestEncore(Encore encore);
+12 -2
View File
@@ -168,10 +168,20 @@ void
//
//-----------------------------------------------------------------------
// If update message is not null then send the change
// If update message is not null then send the change.
//
// The dynamic master socket holds Independant and Hermit instances as
// well as masters, and neither of those publishes: an Independant runs
// its own simulation on every host, and a Hermit is not replicated at
// all. EntityUpdateReplicants asserts MasterInstance, so the caller is
// the one that has to make that true - the clockwork doorframes are
// Hermits and would otherwise arrive there.
//-----------------------------------------------------------------------
//
if (update_message != NULL)
if (
update_message != NULL
&& entity->GetInstance() == Entity::MasterInstance
)
{
Check(update_message);
+4
View File
@@ -82,6 +82,10 @@ public:
// -fit: borderless window filling the monitor, with the render size
// chosen to match the cockpit canvas it will be presented into.
static bool GetFitDisplay() { return mFitDisplay; }
// -fit's borderless full-monitor placement. Applied once at startup so
// the window is in its final shape before ANY mission builds a device
// against it - see the definition for why the first race differed.
static void FitWindowToMonitor(HWND window);
static Logical GetSeeSolids() { return seeSolids; }
static unsigned long GetNetworkCommonFlatAddress() { return networkCommonFlatAddress; }
// The front end's multiplayer path turns network mode on at launch
+66
View File
@@ -302,6 +302,72 @@ void
<< monitor_w << "x" << monitor_h << " monitor\n" << std::flush;
}
//
//#############################################################################
// FitWindowToMonitor
//#############################################################################
//
// -fit's borderless full-monitor placement, applied to the shell window.
//
// This has to happen BEFORE the first race, not during it. SVGA16 does the
// same thing when it assembles the cockpit, but that is not until a mission
// starts - and the D3D device is created just ahead of it, against whatever
// the window is at that moment. So the first race got a device sized to a
// still-bordered window and every race after it got one sized to the
// borderless monitor: two different render targets, two different frame
// costs, from one lobby and one set of settings.
//
// A racing sim cannot have that. The window reaches its final shape while
// the front end is still up, so every mission of a session - the first one
// included - is set up against exactly the same client area.
//
// SVGA16 still applies it when it builds the cockpit. That call becomes a
// no-op rather than a change, which is the point.
//
void
L4Application::FitWindowToMonitor(HWND window)
{
if (window == NULL)
{
return;
}
RECT monitor_rect;
monitor_rect.left = 0;
monitor_rect.top = 0;
monitor_rect.right = GetSystemMetrics(SM_CXSCREEN);
monitor_rect.bottom = GetSystemMetrics(SM_CYSCREEN);
MONITORINFO monitor;
memset(&monitor, 0, sizeof(monitor));
monitor.cbSize = sizeof(monitor);
HMONITOR handle = MonitorFromWindow(window, MONITOR_DEFAULTTOPRIMARY);
if (GetMonitorInfoA(handle, &monitor))
{
monitor_rect = monitor.rcMonitor;
}
//
// Same style surgery SVGA16 performs, so the two agree exactly.
//
LONG_PTR style = GetWindowLongPtrA(window, GWL_STYLE);
style &= ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU |
WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_BORDER | WS_DLGFRAME);
style |= WS_POPUP | WS_CLIPCHILDREN;
SetWindowLongPtrA(window, GWL_STYLE, style);
SetWindowPos(window, NULL,
monitor_rect.left, monitor_rect.top,
monitor_rect.right - monitor_rect.left,
monitor_rect.bottom - monitor_rect.top,
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
DEBUG_STREAM << "L4Application: -fit placed the window borderless at "
<< (monitor_rect.right - monitor_rect.left) << "x"
<< (monitor_rect.bottom - monitor_rect.top)
<< " before the first mission\n" << std::flush;
}
//
//#############################################################################
// ParseCommandLine
+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
#include "l4audio.h"
#include "l4audefx.h"
#include "l4audlvl.h"
#include "l4app.h"
#include "l4audrnd.h"
@@ -9,6 +10,49 @@
#include "..\munga\player.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 #########################
//#############################################################################
@@ -658,6 +702,19 @@ L4AudioSource::L4AudioSource(
AudioSource(stream, entity)
{
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();
}
@@ -923,6 +980,22 @@ void
patch_resource->SetDistance(GetDistanceToSource());
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
//
@@ -1039,6 +1112,8 @@ void
// Apply filter scale
//--------------------------------------------------------------------------
//
float direct_gainhf = 1.0f;
if (UseSourceBrightnessScale())
{
const MIDINRPNValue filter_resolution = 2;// HACK - should come from audio.ini
@@ -1058,6 +1133,32 @@ void
{
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
volume_scale = CalculateSourceVolumeScale();
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);
//
// FIDELITY (docs/SOUND.md F4): the original ended its volume path in MIDI
// CC7, whose GM/SoundFont curve is concave -- amplitude ~ (v/127)^2. Writing
// volume_scale linearly to AL_GAIN played every intermediate level about
// +6 dB hot at mid-scale and compressed the authored dynamic range.
//
// 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++)
{
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head));
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale);
alSourcef(channelSet.sources[i], AL_GAIN,
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->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());
for (i = 0; i < AudioChannelSetSize; i++)
{
@@ -1405,15 +1524,71 @@ void
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);
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++)
{
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);
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
volume_scale = L4AudioSource::CalculateSourceVolumeScale();
return volume_scale;
//
// Update the spatial model that will result in the value
// for distance related volume attenuation
// FIDELITY (docs/SOUND.md F3): apply the AUTHORED distance 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);
Check(application->GetAudioRenderer());
UpdateSpatialModel(application->GetAudioRenderer()->GetAudioHead());
//
// Apply distance attenuation to the volume scale
// The spatial model is already refreshed each Execute, so the
// UpdateSpatialModel call the original comment carried is not needed here.
//
Check(GetAudioLocation());
volume_scale *= GetAudioLocation()->GetDistanceVolumeScale();
return volume_scale;*/
return volume_scale;
}
//#############################################################################
@@ -1468,6 +1646,26 @@ Static3DPatchSource::Static3DPatchSource(
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)
{
if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState)
@@ -1694,6 +1892,16 @@ void
Check(patch_resource);
patch_resource->SetDistance(GetDistanceToSource());
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++)
{
if ((channel = channelSet.GetNth(i)) != NULL)
@@ -1909,12 +2117,6 @@ void
Scalar volume_scale = CalculateSourceVolumeScale();
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;
@@ -1933,12 +2135,47 @@ void
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
//
// 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++)
{
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);
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);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Mix levels
//
public:
AudioControlValue
CalculateSourceVolumeScale();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// SetPosition
//
+34 -1
View File
@@ -128,8 +128,25 @@ void
// #endif
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
for (int i=0; i < sourceSet.count; i++)
for (int i=0; i < zone_count; i++)
{
info = PRESET_getSampleInfo(bankID,patchID,i);
if (info.bufferIndex >= 0)
@@ -310,3 +327,19 @@ MIDINRPNValue
Check(patch_level_of_detail);
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];
//
// 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);
int PRESET_getNumSamples(int bank, int preset);
SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd);
@@ -69,6 +75,14 @@ public:
GetVoiceCount()
{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
@@ -163,6 +177,12 @@ public:
void
SetupPatch(SourceSet sourceSet);
//
// Gain this zone takes from the Home/End bass trim, 1.0 when untouched.
//
float
GetZoneBassGain(int zone_index);
MIDINRPNValue
GetMaxMIDIFilterCutoff();
};
+155
View File
@@ -18,6 +18,130 @@
ALuint *g_buffers;
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 #############################
//#############################################################################
@@ -566,6 +690,18 @@ void
g_buffers = NULL;
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;
@@ -647,6 +783,15 @@ void
sf_read_raw(file,data,size);
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
alBufferData(g_buffers[bufferInd],format,data,size,alSampleRate);
PRESET_setBufferIndex(i,j,k,bufferInd);
@@ -726,6 +871,16 @@ void
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];
}
+10
View File
@@ -9,6 +9,16 @@ ALuint AL_getBuffer(int index);
extern ALuint *g_buffers;
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;
+310 -27
View File
@@ -2,9 +2,20 @@
#pragma hdrstop
#include "l4audrnd.h"
#include "l4audefx.h"
#include "..\munga\notation.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
@@ -379,6 +390,66 @@ void
{
ALCcontext *context = alcCreateContext(device,NULL);
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;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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
@@ -1271,30 +1526,54 @@ Logical
Check(this);
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;
bool failed = true;
alGetError();
if (requested > (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0])))
{
requested = (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0]));
source_request->count = requested;
}
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();
if (error == AL_NO_ERROR)
{
failed = false;
}
ALuint src = 0;
if (failed)
{
return False;
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;
}
source_request->sources[i] = src;
}
return True;
@@ -1375,23 +1654,27 @@ Logical
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++)
{
ALenum state;
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
if (state == AL_PLAYING)
if (sourceSet.sources[i] != 0)
{
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 ~~~~~~~~~~~~~~~~~~~~~~~~~
+8
View File
@@ -6,6 +6,14 @@
#include "l4audres.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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+74
View File
@@ -6,6 +6,8 @@
#include "l4ctrl.h"
#include "l4keybd.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 "..\munga\appmgr.h"
#include "dxutils.h"
@@ -1513,6 +1515,78 @@ void
// 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)
{
//RB 1/20/07
+387 -47
View File
@@ -508,13 +508,67 @@ int
// common, and no amount of documentation gets a player to work out which
// they own.
//
// The same principle runs deeper than the sign. A player should not have
// to know the SHAPE of their own rig either, so the wizard works that
// out too, and the two controls that cannot simply be watched are asked
// for differently:
//
// yaw asked for twice, right then left. One axis answering both
// is a twist grip or rudder bar - the signed Pedals
// composite. Two different axes are two real pedals, one per
// foot, the pod's own arrangement, bound to the real pair.
//
// throttle a lever sits wherever it was left, so no movement of it
// says which end is open. The player is asked to put it at
// ZERO and say so; the reading is taken there, and the
// direction it travels from a known idle means power.
//
//########################################################################
#include <conio.h>
#include <XInput.h>
#include "l4padbindings.h"
namespace
{
//
// Xbox-class pads are kept out of the capture on purpose: their
// layout is fixed and NAMED, so unlike a DirectInput axis there is
// nothing to identify by watching, and letting one answer a prompt
// would only bind it twice. Invisible is the wrong answer though -
// a player whose whole rig is a pad, or a wheel running in XInput
// mode, should be told it is already mapped rather than left reading
// "no devices found" and wondering what is broken.
//
int WizardXInputSlot(void)
{
XINPUT_STATE state;
for (int i = 0; i < 4; ++i)
{
if (XInputGetState((DWORD) i, &state) == ERROR_SUCCESS)
{
return i;
}
}
return -1;
}
//
// What an Xbox-class pad already does, said once and in one place.
// The triggers are the interesting half: XInput reports each as its
// own 0..255 byte rather than two halves of a shared axis, which is
// the pod's two-pedal arrangement exactly, and unipolar already - no
// 'lever' to fold, no sign to discover.
//
void WizardReportXInput(int slot)
{
printf(" [XInput slot %d] Xbox-class controller - ALREADY MAPPED, and\n"
" not part of this setup. Its two triggers are the pod's\n"
" left and right pedals, the left stick is the joystick and\n"
" the right stick the throttle. Edit the pad rows of\n"
" bindings.txt by hand to change any of that.\n", slot);
}
struct WizardCapture
{
int used;
@@ -532,6 +586,39 @@ namespace
return (axis >= 0 && axis < joyAxisCount) ? names[axis] : "?";
}
//
// A pedal is a ONE-WAY control: its spring holds it at the released
// end of its travel, so the direction of the press is the whole
// story and the row it writes says 'lever' - the -1..1 axis the
// driver reports then folds onto the 0..1 the channel runs on
// instead of throwing away the half that reads below zero.
//
// Where it RESTS is what decides that, and the wizard can see it.
// An axis sitting near the MIDDLE is not a pedal at all - a stick
// axis pressed into service as one - and already reads zero at
// rest, so 'lever' would jam it at half depression for good.
//
void WizardWritePedal(WizardCapture *capture, int axis, float rest,
float delta, const char *channel)
{
capture->invert = (delta < 0.0f);
if (rest > 0.5f || rest < -0.5f)
{
sprintf(capture->line, "joyaxis %s axis %s%s lever deadzone 0.05",
JoyAxisToken(axis), channel,
capture->invert ? " invert" : "");
}
else
{
printf(" (%s rests near centre rather than at one end, so it\n"
" is bound as a plain axis rather than as a pedal)\n",
JoyAxisToken(axis));
sprintf(capture->line, "joyaxis %s axis %s%s deadzone 0.08",
JoyAxisToken(axis), channel,
capture->invert ? " invert" : "");
}
}
void WizardBaseline(float baseline[joyMaxDevices][joyAxisCount])
{
//
@@ -746,11 +833,30 @@ int
PadBindings_Load(&ensure_default);
}
int xinput_slot = WizardXInputSlot();
if (RPJoyInit() == 0)
{
printf("No generic (non-Xbox) game devices found.\n");
printf("Plug in the stick, throttle or pedals and run joyconfig again.\n");
printf("(Xbox-class controllers already work - no setup needed.)\n\n");
if (xinput_slot >= 0)
{
//
// Not a failure, and it should not read like one: the pad IS
// the rig, and it is already configured. Say what it does
// rather than asking for hardware they have not got.
//
printf("Nothing here needs configuring.\n\n");
WizardReportXInput(xinput_slot);
printf("\nThere are no generic (DirectInput) sticks, throttles or\n");
printf("pedals attached, and those are the only thing this setup\n");
printf("has to work out. Plug one in and run joyconfig again if\n");
printf("you add one.\n\n");
}
else
{
printf("No generic (non-Xbox) game devices found.\n");
printf("Plug in the stick, throttle or pedals and run joyconfig again.\n");
printf("(Xbox-class controllers already work - no setup needed.)\n\n");
}
printf("Press any key to exit.\n");
_getch();
return 1;
@@ -783,48 +889,51 @@ int
}
printf("\n");
}
//
// Listed with the rest so a player who squeezes a trigger at a
// prompt and sees nothing happen knows why, rather than deciding
// the wizard cannot see their pad.
//
if (xinput_slot >= 0)
{
WizardReportXInput(xinput_slot);
}
}
printf("\nFor each prompt, MOVE the control you want, or press SPACE to\n"
"skip it, ESC to abort. Keep everything else still.\n\n");
WizardCapture captures[16];
memset(captures, 0, sizeof(captures));
int capture_count = 0;
//
// The pod's analog channels. wants_negative says the asked-for move
// should read NEGATIVE in the pod's sign convention, which is what
// decides whether the captured axis gets an invert:
// The stick, whose two axes are spring-centred and so give their
// sign away the moment they move. wants_negative says the asked-for
// move should read NEGATIVE in the pod's sign convention, which is
// what decides whether the captured axis gets an invert:
//
// JoystickX left +1, right -1
// JoystickY forward -1, back +1
// Pedals right +1, left -1 (the composite that decomposes
// into the pod's two pedals)
//
// Yaw and the throttle are not this simple and are asked for below.
//
struct AxisStep
{
const char *prompt;
const char *channel;
int wants_negative;
int lever; // full-travel lever: sign from where
// it ENDS, not which way it moved
int allow_skip;
};
static const AxisStep axisSteps[] =
{
{ "STEER: push the STICK / turn the WHEEL fully RIGHT",
"JoystickX", 1, 0, 0 },
"JoystickX", 1 },
{ "PITCH: push the STICK fully FORWARD\n"
" (add or remove the word invert on that line in\n"
" bindings.txt to flip it later)",
"JoystickY", 1, 0, 0 },
{ "PEDALS: twist the stick / press the RIGHT rudder pedal\n"
" (SPACE if you have neither)",
"Pedals", 0, 0, 1 },
{ "THROTTLE: move the throttle lever to FULL (SPACE if none)",
"Throttle", 0, 1, 1 }
"JoystickY", 1 }
};
printf("\nFor each prompt, MOVE the control you want, or press SPACE to\n"
"skip it, ESC to abort. Keep everything else still.\n\n");
float baseline[joyMaxDevices][joyAxisCount];
for (int s = 0; s < (int)(sizeof(axisSteps) / sizeof(axisSteps[0])); ++s)
@@ -834,7 +943,7 @@ int
int device, axis;
float delta, final_value;
int got = WizardCaptureAxis(baseline, captures, capture_count,
axisSteps[s].allow_skip, &device, &axis, &delta, &final_value);
0, &device, &axis, &delta, &final_value);
if (got < 0)
{
printf("\nAborted - nothing written.\n");
@@ -842,38 +951,18 @@ int
_getch();
return 1;
}
if (got == 0)
{
printf(" skipped.\n\n");
continue;
}
WizardCapture &capture = captures[capture_count++];
capture.used = 1;
capture.device = device;
capture.axis = axis;
capture.button = -1;
if (axisSteps[s].lever)
{
//
// A lever has no rest position to move away from, so the
// sign comes from where it finished: full-forward reading
// negative means the axis runs backwards for us.
//
capture.invert = (final_value < 0.0f);
sprintf(capture.line, "joyaxis %s axis %s%s deadzone 0",
JoyAxisToken(axis), axisSteps[s].channel,
capture.invert ? " invert" : "");
}
else
{
int went_negative = (delta < 0.0f);
capture.invert = axisSteps[s].wants_negative
? !went_negative : went_negative;
sprintf(capture.line, "joyaxis %s axis %s%s deadzone 0.08",
JoyAxisToken(axis), axisSteps[s].channel,
capture.invert ? " invert" : "");
}
int went_negative = (delta < 0.0f);
capture.invert = axisSteps[s].wants_negative
? !went_negative : went_negative;
sprintf(capture.line, "joyaxis %s axis %s%s deadzone 0.08",
JoyAxisToken(axis), axisSteps[s].channel,
capture.invert ? " invert" : "");
//
// The move is reported, not just the axis: a capture nobody made
// shows up here as a small delta, and a player who wonders why
@@ -887,6 +976,257 @@ int
Sleep(800); // let the control come back to rest
}
//---------------------------------------------------------------
// Yaw. The pod steered on two foot pedals mixed into the turn, and
// hardware answers that in two shapes - but a player should not have
// to know which shape they own, and plenty do not. So ask for RIGHT,
// then ask for LEFT, and watch WHICH axis answers each time:
//
// the same axis twice one control covering both directions - a
// twist grip, a rudder bar, pedals whose
// driver has already mixed them - which is
// the signed Pedals composite
//
// two different axes two real pedals, one per foot, which is
// what the pod itself had. They bind to the
// pod's own pair and the game does the
// mixing, so both at once does what both at
// once did in the pod.
//
// The LEFT capture is deliberately offered the RIGHT axis again -
// the usual claimed-axis exclusion would make every rig look like a
// pair, since "the same axis answered twice" is the measurement.
//---------------------------------------------------------------
{
printf("YAW RIGHT: press the RIGHT rudder pedal, or twist / push\n"
" the stick RIGHT (SPACE if you have no yaw control) ...\n");
WizardBaseline(baseline);
int right_device, right_axis;
float right_delta, right_final;
int got = WizardCaptureAxis(baseline, captures, capture_count, 1,
&right_device, &right_axis, &right_delta, &right_final);
if (got < 0)
{
printf("\nAborted - nothing written.\n");
printf("Press any key to continue into the game.\n");
_getch();
return 1;
}
if (got == 0)
{
printf(" skipped - no yaw control.\n\n");
}
else
{
float right_rest = baseline[right_device][right_axis];
printf(" -> device %d (%s) axis %s [moved %+.2f]\n",
right_device,
(RPJoyDevice(right_device) != NULL)
? RPJoyDevice(right_device)->name : "?",
JoyAxisToken(right_axis), right_delta);
Sleep(800); // let it come back to rest before we re-baseline
printf("YAW LEFT: now the other way - press the LEFT pedal, or\n"
" twist / push the stick LEFT ...\n");
WizardBaseline(baseline);
int left_device, left_axis;
float left_delta, left_final;
int got_left = WizardCaptureAxis(baseline, captures, capture_count,
1, &left_device, &left_axis, &left_delta, &left_final);
if (got_left < 0)
{
printf("\nAborted - nothing written.\n");
printf("Press any key to continue into the game.\n");
_getch();
return 1;
}
int same_axis = (got_left == 0) ||
(left_device == right_device && left_axis == right_axis);
if (got_left != 0)
{
printf(" -> device %d (%s) axis %s [moved %+.2f]\n",
left_device,
(RPJoyDevice(left_device) != NULL)
? RPJoyDevice(left_device)->name : "?",
JoyAxisToken(left_axis), left_delta);
}
if (same_axis)
{
//
// One axis, both ways: the signed composite, positive
// for the right pedal. Signed from the RIGHT answer,
// which is the one the convention is written in.
//
WizardCapture &capture = captures[capture_count++];
capture.used = 1;
capture.device = right_device;
capture.axis = right_axis;
capture.button = -1;
capture.invert = (right_delta < 0.0f);
sprintf(capture.line, "joyaxis %s axis Pedals%s deadzone 0.08",
JoyAxisToken(right_axis), capture.invert ? " invert" : "");
if (got_left == 0)
{
printf(" left skipped - taking %s as one control that\n"
" covers both ways.\n", JoyAxisToken(right_axis));
}
else if ((left_delta < 0.0f) == (right_delta < 0.0f))
{
//
// Both moves read the same way, which no single
// control does. Say so rather than write a row that
// turns one way only and let them wonder.
//
printf(" NOTE: both moves pushed %s the SAME way"
" (%+.2f then %+.2f).\n"
" Bound as one control anyway - check that line if"
" yaw only turns\n one way.\n",
JoyAxisToken(right_axis), right_delta, left_delta);
}
else
{
printf(" ONE axis both ways%s: bound as the pedal PAIR,\n"
" a twist grip or rudder bar working both pedals.\n",
capture.invert ? " (inverted)" : "");
}
}
else
{
//
// Two axes: the pod's own arrangement, one pedal per
// foot, so they bind to the real pair rather than to the
// composite that stands in for it.
//
WizardCapture &right_capture = captures[capture_count++];
right_capture.used = 1;
right_capture.device = right_device;
right_capture.axis = right_axis;
right_capture.button = -1;
WizardWritePedal(&right_capture, right_axis, right_rest,
right_delta, "RightPedal");
WizardCapture &left_capture = captures[capture_count++];
left_capture.used = 1;
left_capture.device = left_device;
left_capture.axis = left_axis;
left_capture.button = -1;
WizardWritePedal(&left_capture, left_axis,
baseline[left_device][left_axis], left_delta, "LeftPedal");
printf(" TWO axes: %s is the right pedal, %s the left - the\n"
" pod's own arrangement, and the game mixes them into"
" the turn.\n",
JoyAxisToken(right_axis), JoyAxisToken(left_axis));
}
printf("\n");
Sleep(800);
}
}
//---------------------------------------------------------------
// The throttle, which cannot be read the way everything else is. A
// lever sits wherever it was last left - halfway, or hard against
// the stop that happens to read +1 - so watching it move says
// nothing about which END means power. Nor can the wizard ask the
// player which end that is: nobody knows what their driver reports.
//
// So it asks for the one thing the player DOES know - where zero is
// - and takes the reading there. Everything after that follows: the
// direction it travels from a known idle is the direction that
// means open.
//---------------------------------------------------------------
{
printf("THROTTLE: set the lever to ZERO - idle, fully closed - and\n"
" press SPACE. Here SPACE means \"it is at zero now\",\n"
" not skip; press S if you have no throttle lever ...\n");
int have_throttle = 0;
for (;;)
{
int key = _getch();
if (key == 27)
{
printf("\nAborted - nothing written.\n");
printf("Press any key to continue into the game.\n");
_getch();
return 1;
}
if (key == ' ')
{
have_throttle = 1;
break;
}
if (key == 's' || key == 'S')
{
printf(" skipped - no throttle lever.\n\n");
break;
}
}
if (have_throttle)
{
printf(" reading zero ...\n");
WizardBaseline(baseline);
printf(" now OPEN the throttle to FULL"
" (SPACE to skip) ...\n");
int device, axis;
float delta, final_value;
int got = WizardCaptureAxis(baseline, captures, capture_count, 1,
&device, &axis, &delta, &final_value);
if (got < 0)
{
printf("\nAborted - nothing written.\n");
printf("Press any key to continue into the game.\n");
_getch();
return 1;
}
if (got == 0)
{
printf(" skipped.\n\n");
}
else
{
float idle = baseline[device][axis];
WizardCapture &capture = captures[capture_count++];
capture.used = 1;
capture.device = device;
capture.axis = axis;
capture.button = -1;
capture.invert = (delta < 0.0f);
sprintf(capture.line, "joyaxis %s axis Throttle%s deadzone 0",
JoyAxisToken(axis), capture.invert ? " invert" : "");
printf(" -> device %d (%s) axis %s%s"
" [zero at %+.2f, opened %+.2f]\n",
device,
(RPJoyDevice(device) != NULL) ? RPJoyDevice(device)->name : "?",
JoyAxisToken(axis), capture.invert ? " (inverted)" : "",
idle, delta);
if (idle > -0.5f && idle < 0.5f)
{
//
// Zero somewhere in the middle of the travel. The
// lever owns the channel outright, so its whole
// -1..1 range becomes 0-100% and an idle at the
// centre is half power. Worth saying plainly.
//
printf(" NOTE: your zero reads %+.2f rather than an end"
" stop, and a\n"
" throttle's FULL travel becomes the pod's 0-100%%"
" - so at that\n"
" position the pod would sit near half power. Use"
" the lever's\n"
" real closed stop, or edit that row by hand.\n",
idle);
}
printf("\n");
Sleep(800);
}
}
}
//
// The pod's stick-head buttons, at their RIO addresses.
//
+33
View File
@@ -142,15 +142,48 @@ namespace
//---------------------------------------------------------------
void Worker()
{
bool ownsApartment = false;
try
{
init_apartment();
ownsApartment = true;
}
catch (...)
{
// apartment already set on this thread; carry on
}
//
// C++/WinRT caches an activation factory the first time a type
// is used, and that cache is PROCESS-wide - it outlives this
// thread. The apartment does not: COM tears it down when the
// worker exits and unloads the Lights server with it, because
// by then nothing holds a reference.
//
// So the cached factory is left pointing into an address range
// that no longer has a module in it, and the NEXT race's worker
// calls straight through it - the crash was a call through the
// stale vtable, on the second race, every time. A machine with
// no Dynamic Lighting keyboard is not spared: asking for the
// device selector is enough to populate the cache.
//
// Clear it before the apartment goes, and on every way out of
// here rather than only the tidy one - the watcher setup below
// returns early when Dynamic Lighting is unavailable.
//
struct WinRTExit
{
bool owns;
~WinRTExit()
{
clear_factory_cache();
if (owns)
{
uninit_apartment();
}
}
} winrtExit{ ownsApartment };
std::mutex claimedLock;
std::vector<ClaimedArray> claimed;
bool anySeen = false;
+58 -17
View File
@@ -201,11 +201,15 @@ namespace
//---------------------------------------------------------------
// Shared tail of the two axis-source rows: [invert] [deadzone <d>]
// [rate <n>], in any order.
// [rate <n>], in any order. 'lever' rides along for the rows that
// can take it - a NULL lever means the word is not legal here, and
// a padaxis row is exactly that: the pad's own triggers already
// read 0..1, so there is no half-travel to rescue.
//---------------------------------------------------------------
Logical ParseAxisOptions(
char *tokens[], int token_count, int first,
Logical *invert, Scalar *deadzone, Scalar *rate)
Logical *invert, Scalar *deadzone, Scalar *rate,
Logical *lever = NULL)
{
for (int i = first; i < token_count; ++i)
{
@@ -213,6 +217,10 @@ namespace
{
*invert = True;
}
else if (NameEquals(tokens[i], "lever") && lever != NULL)
{
*lever = True;
}
else if (NameEquals(tokens[i], "deadzone") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], deadzone))
@@ -355,12 +363,23 @@ namespace
{
return False;
}
PadPadAxisBinding *binding = &profile->padAxes[profile->padAxisCount++];
memset(binding, 0, sizeof(*binding));
binding->source = source;
binding->axis = axis;
return ParseAxisOptions(tokens, token_count, 4,
&binding->invert, &binding->deadzone, &binding->rate);
//
// Built aside and only then committed: a row whose options
// go bad half way through is a REJECTED row, and taking the
// slot first would leave the good half of it bound anyway,
// under a log line that says it was skipped.
//
PadPadAxisBinding candidate;
memset(&candidate, 0, sizeof(candidate));
candidate.source = source;
candidate.axis = axis;
if (!ParseAxisOptions(tokens, token_count, 4,
&candidate.invert, &candidate.deadzone, &candidate.rate))
{
return False;
}
profile->padAxes[profile->padAxisCount++] = candidate;
return True;
}
//---------------------------------------------------------------
@@ -377,13 +396,19 @@ namespace
{
return False;
}
PadJoyAxisBinding *binding = &profile->joyAxes[profile->joyAxisCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->source = source;
binding->axis = axis;
return ParseAxisOptions(tokens, token_count, 4,
&binding->invert, &binding->deadzone, &binding->rate);
PadJoyAxisBinding candidate;
memset(&candidate, 0, sizeof(candidate));
candidate.device = *joy_slot;
candidate.source = source;
candidate.axis = axis;
if (!ParseAxisOptions(tokens, token_count, 4,
&candidate.invert, &candidate.deadzone, &candidate.rate,
&candidate.lever))
{
return False;
}
profile->joyAxes[profile->joyAxisCount++] = candidate;
return True;
}
if (NameEquals(tokens[0], "joybutton") && NameEquals(tokens[2], "button"))
@@ -453,7 +478,7 @@ namespace
"# pad <button> button <addr> [toggle]\n"
"# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]\n"
"# joydev <slot> [product-name substring]\n"
"# joyaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]\n"
"# joyaxis <src> axis <axis> [invert] [lever] [deadzone <d>] [rate <n>]\n"
"# joybutton <n> button <addr> [toggle]\n"
"# joyhat <n> <up|down|left|right> button <addr>\n"
"#\n"
@@ -462,7 +487,10 @@ namespace
"# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX\n"
"# | Pedals - a signed axis that works the pedal PAIR, positive\n"
"# for the right pedal and negative for the left, so one rudder\n"
"# bar or twist grip drives both.\n"
"# bar or twist grip drives both. Two-pedal hardware - racing\n"
"# pedals, rudder pedals with an axis per foot - skips the\n"
"# composite and binds LeftPedal and RightPedal directly; the\n"
"# game mixes the pair into yaw the way the pod always did.\n"
"# <name> Keys name: A-Z, D0-D9 (digit row), F1-F12, NumPad0-NumPad9,\n"
"# Up, Down, Left, Right, Space, Enter, PageUp, PageDown,\n"
"# OemMinus, Oemplus, Oemcomma, OemPeriod, ...\n"
@@ -493,6 +521,12 @@ namespace
"# the full throttle range, rather than nudging the position the way a\n"
"# spring-centred pad stick has to.\n"
"#\n"
"# 'lever' marks a source that rests at one END of its travel instead of\n"
"# in the middle - a floor pedal, a slider. Windows reports it as a full\n"
"# -1..1 axis all the same, so without the word half the travel sits\n"
"# below zero and the first half of the press does nothing; with it the\n"
"# travel maps onto 0..1 and the deadzone measures from the released end.\n"
"#\n"
"# joydev 0 T.16000M\n"
"# joyaxis X axis JoystickX invert deadzone 0.08\n"
"# joyaxis Y axis JoystickY invert deadzone 0.08\n"
@@ -500,6 +534,13 @@ namespace
"# joyaxis SL0 axis Throttle deadzone 0\n"
"# joybutton 0 button 0x40\n"
"# joyhat 0 up button 0x42\n"
"#\n"
"# ...and the same stick with racing pedals on a second device, one\n"
"# axis per foot instead of the composite:\n"
"#\n"
"# joydev 1 Pedals\n"
"# joyaxis Y axis LeftPedal lever deadzone 0.05\n"
"# joyaxis RZ axis RightPedal lever deadzone 0.05\n"
"\n"
"# ---- Flight: number pad + modifiers -------------------------------\n"
"# The whole letter board stays free for the MFD banks; flight lives\n"
+9 -1
View File
@@ -19,7 +19,7 @@
// pad <button> button <addr> [toggle]
// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
// joydev <slot> [product-name substring...]
// joyaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
// joyaxis <src> axis <axis> [invert] [lever] [deadzone <d>] [rate <n>]
// joybutton <n> button <addr> [toggle]
// joyhat <n> <up|down|left|right> button <addr>
//
@@ -37,6 +37,13 @@
// layout - X Y Z RX RY RZ SL0 SL1 - where a twist grip is usually RZ
// and a HOTAS throttle usually Z or SL0. RP412JOYCONFIG=1 writes these
// rows for you by asking the player to move each control.
//
// 'lever' says the source is a one-way control that rests at one end of
// its travel rather than in the middle - a floor pedal, a throttle
// slider. DirectInput reports it as a full -1..1 axis all the same, so
// without the keyword half the travel sits below zero and the first
// half of the press does nothing. It maps that travel onto 0..1, and
// the deadzone then measures from the released end instead of centre.
//########################################################################
enum PadBindRioAxis
@@ -136,6 +143,7 @@ struct PadJoyAxisBinding
int source; // PadBindJoyAxis
int axis; // PadBindRioAxis
Logical invert;
Logical lever; // rests at one end: -1..1 travel means 0..1
Scalar deadzone; // normalized 0..1
Scalar rate; // 0 = direct position, >0 = speed integrate
};
+101 -7
View File
@@ -42,6 +42,48 @@ namespace
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0;
}
//
// RP412INPUTFOCUS - do the controls answer only while the game is the
// window in front? On unless the file says 0.
//
// The pod was the only thing running on its cabinet, so the virtual
// RIO reads the key state directly rather than waiting on the message
// pump. That is the right call for latency and it is why the pedals
// feel like pedals - but a direct read is a read of the WHOLE
// keyboard, whatever has focus, so a player alt-tabbed into a text
// editor was flying the pod with every note they typed.
//
Logical InputNeedsFocus()
{
static int setting = -1;
if (setting < 0)
{
const char *value = getenv("RP412INPUTFOCUS");
setting = (value != NULL && *value == '0') ? 0 : 1;
}
return setting ? True : False;
}
//
// Whether the foreground window is one of OURS - the process, not one
// particular handle. The cockpit is a shell full of child panes, the
// exploded view is six windows of its own and the plasma glass
// another, so any of them being in front is the game being in front.
// Matching a single HWND would drop the controls the moment somebody
// clicked an MFD.
//
Logical ProcessHasFocus()
{
HWND foreground = GetForegroundWindow();
if (foreground == NULL)
{
return False;
}
DWORD foreground_process = 0;
GetWindowThreadProcessId(foreground, &foreground_process);
return (foreground_process == GetCurrentProcessId()) ? True : False;
}
//
// A generic stick axis is already normalized -1..1, so the deadzone
// is a plain cut about centre with the remainder rescaled - press
@@ -65,6 +107,25 @@ namespace
return value;
}
//
// A one-way control - a floor pedal, a slider - rests at one END of
// its travel, not in the middle, and DirectInput still reports it as
// a full -1..1 axis. Fold that travel onto 0..1 so the pedal starts
// answering as soon as it moves instead of at half depression, and
// measure the deadzone from the released end, where the slack in a
// tired return spring actually lives.
//
Scalar JoyLeverValue(Scalar raw, Scalar deadzone)
{
Scalar value = (raw + 1.0f) * 0.5f;
if (value <= deadzone)
{
return (Scalar) 0;
}
if (value > 1.0f) value = 1.0f;
return value;
}
//
// A POV hat reports centidegrees clockwise from up, or -1 centered.
// The 45-degree window each way is what makes the diagonals press
@@ -212,6 +273,11 @@ PadRIO::PadRIO()
activeInstance = this;
DEBUG_STREAM << "PadRIO: virtual RIO active (XInput pad + keyboard)\n" << std::flush;
DEBUG_STREAM << "PadRIO: controls "
<< (InputNeedsFocus()
? "answer only while the game window has focus"
: "answer whether the game has focus or not (RP412INPUTFOCUS=0)")
<< "\n" << std::flush;
//
// Only open DirectInput when the profile actually asks for it. A
@@ -371,6 +437,25 @@ void
}
lastPollTick = now;
//---------------------------------------------------------------
// Is the game the window in front? Every source below is gated on
// this - keyboard, pad and stick alike.
//
// Gated rather than skipped, and that is the whole trick: each
// source reads as RELEASED instead of the poll returning early, so
// the diffs further down turn whatever was held at the moment you
// switched away into proper release events. Bail out instead and a
// key held on alt-tab stays down until you come back, which is the
// stuck throttle this is meant to prevent rather than cause.
//
// The throttle accumulator is the deliberate exception. It is the
// pod's one sticky axis and it integrates what the controls ask
// for, so controls asking for nothing simply stop moving it - you
// come back to the speed you left, not to a dead stop.
//---------------------------------------------------------------
Logical input_live =
(!InputNeedsFocus() || ProcessHasFocus()) ? True : False;
//---------------------------------------------------------------
// Find / keep the XInput pad. Probing empty slots is slow, so an
// absent pad is only re-probed every 3 seconds.
@@ -379,7 +464,7 @@ void
memset(&pad, 0, sizeof(pad));
Logical pad_live = False;
if (padIndex >= 0)
if (input_live && padIndex >= 0)
{
pad_live = (XInputGetState((DWORD) padIndex, &pad) == ERROR_SUCCESS);
if (!pad_live)
@@ -388,7 +473,7 @@ void
padIndex = -1;
}
}
if (padIndex < 0 && (now - lastPadCheckTick) >= 3000)
if (input_live && padIndex < 0 && (now - lastPadCheckTick) >= 3000)
{
lastPadCheckTick = now;
for (DWORD i = 0; i < 4; ++i)
@@ -423,7 +508,7 @@ void
for (int i = 0; i < profile.keyButtonCount; ++i)
{
PadKeyButtonBinding *binding = &profile.keyButtons[i];
Logical down = KeyDown(binding->virtualKey);
Logical down = input_live && KeyDown(binding->virtualKey);
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
@@ -476,8 +561,15 @@ void
{
joyDevice[slot] = -1;
}
if (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0)
//
// Unfocused this whole block is skipped, which leaves every joyDevice
// slot at -1 - so the button, hat and axis loops below find no device
// and read released and centred on their own. The stick is opened
// DISCL_BACKGROUND (it has to be, or it stops answering the moment a
// pane takes focus), so not polling it is what makes it go quiet.
//
if (input_live && (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0))
{
RPJoyPoll();
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
@@ -595,7 +687,7 @@ void
for (int i = 0; i < profile.keyAxisCount; ++i)
{
const PadKeyAxisBinding *binding = &profile.keyAxes[i];
if (KeyDown(binding->virtualKey))
if (input_live && KeyDown(binding->virtualKey))
{
if (binding->mode == BindKeyRate)
{
@@ -686,7 +778,9 @@ void
throttleLever = True;
continue;
}
Scalar value = JoyAxisValue(raw, binding->deadzone);
Scalar value = binding->lever
? JoyLeverValue(raw, binding->deadzone)
: JoyAxisValue(raw, binding->deadzone);
if (binding->rate > 0.0f)
{
rate[binding->axis] += value * binding->rate;
+39 -2
View File
@@ -249,14 +249,51 @@ void ParticleEmitter::Execute()
}
}
//
// Drop everything bound to the device we were last given.
//
// Null-safe, and it clears what it drops. Neither was true before: this
// runs on the device-lost path ahead of a Reset, where a texture that
// never loaded (a missing VIDEO\particles.png is enough) left one of
// these NULL and took the Reset down with it, and a released pointer
// left in place is a dangling one the moment anything looks again.
//
void ParticleEngine::Destroy()
{
mVertBuffer->Release();
mParticleTexture->Release();
if (mVertBuffer != NULL)
{
mVertBuffer->Release();
mVertBuffer = NULL;
}
if (mParticleTexture != NULL)
{
mParticleTexture->Release();
mParticleTexture = NULL;
}
//
// The paint paths test this before touching anything, so clearing it
// makes the gap between a Destroy and the next Initialize safe.
//
mDevice = NULL;
}
void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device)
{
//
// Whatever is still held belongs to the PREVIOUS device, and holding
// it kept that device alive. A fresh renderer is built per mission,
// so a new device used to arrive here while the old one's vertex
// buffer (D3DPOOL_DEFAULT) and texture still referenced it -
// ~DPLRenderer's release never reached zero and the whole device
// survived the race that made it, back buffer and depth buffer and
// all. That is one leaked render target per race.
//
// The device-lost path already released before re-initialising; this
// is the same contract for the case where the device is not lost but
// replaced.
//
Destroy();
mDevice = device;
memset(mInstalledEffects, 0, sizeof(mInstalledEffects));
+145 -8
View File
@@ -192,7 +192,7 @@ void SVGA16::BuildWindows(unsigned int width, unsigned int height, bool windowed
mPresentParams[j].hDeviceWindow = gaugeWindows[j];
mPresentParams[j].Flags = 0;
mPresentParams[j].FullScreen_RefreshRateInHz = (windowed)?D3DPRESENT_RATE_DEFAULT:60;
mPresentParams[j].PresentationInterval = D3DPRESENT_RATE_DEFAULT;
mPresentParams[j].PresentationInterval = RPPresentationInterval();
mPresentParams[j].BackBufferFormat = D3DFMT_R5G6B5;
//pp.EnableAutoDepthStencil = TRUE;
//pp.AutoDepthStencilFormat = D3DFMT_D24X8;
@@ -3828,6 +3828,13 @@ static LRESULT CALLBACK
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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
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
// 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;
cockpitViewscreen = NULL;
Logical explodedViews = False;
@@ -4679,9 +4695,25 @@ SVGA16::SVGA16(
GetClientRect(cockpit, &inner);
LayoutCockpit(inner.right, inner.bottom);
// catch maximise / restore / drag-resize and re-fit
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
//
// 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(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
gCockpitShellWindow = cockpit;
}
//
// Sticky placement for the shell. Position AND size: nothing
@@ -4917,6 +4949,30 @@ SVGA16::~SVGA16()
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();
}
@@ -4997,12 +5053,16 @@ Logical SVGA16::Update(Logical forceAll)
GaugeRenderer *renderer = application->GetGaugeRenderer();
if (!valid || renderer == NULL)
{
mDisplaysCopiedThisPass = 0;
CLEAR_SCREEN_COPY();
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;
}
//Top MFD's
L4GraphicsPort *UL = static_cast<L4GraphicsPort*>(renderer->GetGraphicsPort("auxUL2"));
@@ -5045,7 +5105,10 @@ Logical SVGA16::Update(Logical forceAll)
lrMask |= (lrMask << 16);
} 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;
}
} else
@@ -5057,7 +5120,8 @@ Logical SVGA16::Update(Logical forceAll)
secPalette = &((SVGA16 *) secPort->graphicsDisplay)->palette[secPort->paletteID];
} else
{
//No secondary, skip
//No secondary, skip - and end the sweep, as above.
mDisplaysCopiedThisPass = 0;
return False;
}
}
@@ -5227,7 +5291,80 @@ Logical SVGA16::Update(Logical forceAll)
// if (end.ticks - start.ticks > 100)
// 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;
//------------------------------------------------------------------
// 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
// the rotated map render as their own desktop windows; the packed
+275 -12
View File
@@ -17,6 +17,10 @@
#include "..\munga\nttmgr.h"
#include "..\munga\app.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"
using namespace std;
@@ -27,6 +31,54 @@ using namespace std;
LPDIRECT3D9 gD3D = NULL;
//
//#############################################################################
// RPPresentationInterval
//#############################################################################
//
// Every device in this game is created vsync-locked, and on a machine with
// a spare 23 cores that is the most expensive line in the build.
//
// The game is one thread: simulation, 3D, gauge drawing and the display
// copies all take turns on it. The frame loop runs the foreground, then
// spends whatever is LEFT of the frame on the background gauge work. A
// Present that blocks until the panel's next retrace spends that remainder
// doing nothing at all - and the gauge loop, guaranteed only a single step
// per frame, gets exactly that single step. A pass over the gauge list
// needs about twenty, so the cockpit falls to two passes a second and every
// slow-tier instrument sits seconds behind.
//
// That is why lowering TARGETFPS "fixed" the instruments: it did not make
// anything faster, it just made the frame long enough that there was time
// left over after the wait.
//
// So this is a knob. 0 = IMMEDIATE, Present returns and the leftover frame
// time goes to the gauges where it belongs. Tearing is the cost, and on a
// pod cockpit whose instruments are the point, it is a cheap one.
//
DWORD
RPPresentationInterval()
{
static DWORD
interval = 0xFFFFFFFF;
if (interval == 0xFFFFFFFF)
{
const char
*setting = getenv("RP412VSYNC");
interval = (setting != NULL && atoi(setting) == 0)
? D3DPRESENT_INTERVAL_IMMEDIATE
: D3DPRESENT_INTERVAL_DEFAULT;
DEBUG_STREAM << "Video: presentation interval "
<< ((interval == D3DPRESENT_INTERVAL_IMMEDIATE)
? "IMMEDIATE (RP412VSYNC=0)" : "vsync")
<< "\n" << std::flush;
}
return interval;
}
// Single-window cockpit: viewscreen child window the scene presents into
// (NULL = present to the device window as always).
HWND gMainPresentWindow = NULL;
@@ -1686,16 +1738,33 @@ DPLRenderer::DPLRenderer(
mPresentParams.hDeviceWindow = hWnd;
mPresentParams.Flags = 0;
mPresentParams.FullScreen_RefreshRateInHz = (fullscreen)?60:D3DPRESENT_RATE_DEFAULT;
mPresentParams.PresentationInterval = D3DPRESENT_RATE_DEFAULT;
mPresentParams.PresentationInterval = RPPresentationInterval();
mPresentParams.BackBufferFormat = D3DFMT_X8R8G8B8;
mPresentParams.EnableAutoDepthStencil = TRUE;
mPresentParams.AutoDepthStencilFormat = D3DFMT_D24X8;
mPresentParams.Windowed = !fullscreen;
if (fullscreen)
{
mPresentParams.BackBufferWidth = screenWidth;
mPresentParams.BackBufferHeight = screenHeight;
}
//
// The render size is asked for, not suggested - windowed as well as
// full-screen. Left at zero, D3D sizes the back buffer to the device
// window's client area AT THIS MOMENT, and everything downstream is
// built from the size we asked for instead: the projection matrix
// takes its aspect from it, and the reticle is centred on it. A
// window that is not exactly that size therefore renders at the
// wrong shape and gets rescaled on the way to the viewscreen pane.
//
// It only showed up on a SECOND race. A fresh renderer is built per
// mission while the window carries its cockpit placement across, so
// the first race creates its device against a still-bordered window
// - near enough the asked-for size to pass - and the next one
// against the borderless full-monitor client, which on a 3440x1440
// panel meant a 1.778 image drawn across a 2.389 target.
//
// -fit picks a render size to land on the viewscreen 1:1, so honour
// it: the back buffer is that size, and Present scales it to the
// pane in one uniform step.
//
mPresentParams.BackBufferWidth = screenWidth;
mPresentParams.BackBufferHeight = screenHeight;
HRESULT hr;
@@ -1725,6 +1794,15 @@ DPLRenderer::DPLRenderer(
//}
//DEBUG_STREAM<<"**************************"<<std::endl<<"**************************"<<std::endl<<std::flush;
//
// NULL before anything can leave this constructor early. It was not
// in the initialiser list, so until CreateDevice wrote it the member
// held whatever was on the stack - and the bail-out below it, plus
// the one that has always been here, both run the destructor and its
// SAFE_RELEASE(mDevice) over exactly that.
//
mDevice = NULL;
if (mPrimaryIndex == NULL)
{
DEBUG_STREAM<<"Unable to locate a suitable primary device index."<<std::endl<<std::flush;
@@ -1732,16 +1810,76 @@ DPLRenderer::DPLRenderer(
return;
}
V(gD3D->CreateDevice(*mPrimaryIndex, D3DDEVTYPE_HAL, hWnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &mPresentParams, &mDevice));
//
// RP412VERTEXPROC=hw asks the GPU to transform vertices instead of
// this thread.
//
// The device has always been created SOFTWARE_VERTEXPROCESSING - every
// vertex on the track transformed and lit on the CPU, on the one core
// this game uses for everything. That was not a choice when the engine
// was written; there was no hardware to hand it to. There is now, and
// the foreground is spending 17 ms of an 18 ms frame while the gauge
// loop starves on the 1 ms left over.
//
// On by default, and sw is the way back. Fixed-function T&L is not
// bit-identical between the old software path and a driver, so the
// escape hatch stays - but the picture was checked against both and
// the difference is not the one worth defending. A cockpit whose
// instruments update twice a second is.
//
DWORD vertex_processing = D3DCREATE_SOFTWARE_VERTEXPROCESSING;
{
const char *setting = getenv("RP412VERTEXPROC");
if (setting == NULL || (*setting != 's' && *setting != 'S'))
{
D3DCAPS9 caps;
if (SUCCEEDED(gD3D->GetDeviceCaps(*mPrimaryIndex, D3DDEVTYPE_HAL, &caps))
&& (caps.DevCaps & D3DDEVCAPS_HWTRANSFORMANDLIGHT) != 0)
{
vertex_processing = D3DCREATE_HARDWARE_VERTEXPROCESSING;
}
else
{
DEBUG_STREAM << "Video: adapter has no hardware T&L - "
<< "staying on software vertex processing\n" << std::flush;
}
}
DEBUG_STREAM << "Video: vertex processing "
<< ((vertex_processing == D3DCREATE_HARDWARE_VERTEXPROCESSING)
? "HARDWARE" : "software (RP412VERTEXPROC=sw)")
<< "\n" << std::flush;
}
V(gD3D->CreateDevice(*mPrimaryIndex, D3DDEVTYPE_HAL, hWnd, vertex_processing, &mPresentParams, &mDevice));
if (FAILED(hr))
{
DEBUG_STREAM<<"Couldn't create HARDWARE_VERTEXPROCESSING device."<<std::endl<<std::flush;
DEBUG_STREAM<<"Couldn't create the requested device - falling back to software vertex processing."<<std::endl<<std::flush;
V(gD3D->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &mPresentParams, &mDevice));
if (FAILED(hr))
{
PostQuitMessage(1);
}
}
//
// PostQuitMessage is a message, not a return. The fallback used to
// post one and then carry straight on into the Clear below, which
// dereferenced a device that was never created - so a machine that
// could not give us the mode we asked for died on an access
// violation instead of saying so.
//
// What was asked for goes in the line, because that is the question
// this failure raises: the back buffer is the requested size now,
// windowed as well as full-screen, so a request the adapter will not
// meet is the thing to look at first.
//
if (FAILED(hr) || mDevice == NULL)
{
DEBUG_STREAM << "DPLRenderer: no D3D device for a "
<< mPresentParams.BackBufferWidth << "x"
<< mPresentParams.BackBufferHeight
<< (mPresentParams.Windowed ? " windowed" : " full-screen")
<< " back buffer (hr=0x" << std::hex << hr << std::dec
<< ") - giving up\n" << std::flush;
PostQuitMessage(1);
return;
}
mDevice->Clear(0, NULL, D3DCLEAR_TARGET, 0xFF000000, 0.0f, 0);
@@ -3555,6 +3693,16 @@ DPLRenderer::~DPLRenderer()
// the next race of the single-binary loop - drop them with the device
d3d_OBJECT::FlushTextureCache();
//
// The particle engine is one of those caches and was missed. Its
// vertex buffer is D3DPOOL_DEFAULT and its texture belongs to this
// device, so while they were held the release below never reached
// zero: every race left a whole live device behind it, and the next
// race's Initialize was the only thing that ever let one go. Drop
// them here and the device dies with the mission that made it.
//
ParticleEngine::Destroy();
SAFE_RELEASE(mDevice);
SAFE_RELEASE(gD3D);
//STUBBED: DPL RB 1/14/07
@@ -5983,11 +6131,84 @@ void
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 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)
{
Component *component;
HRESULT hr;
RPSweepCockpitLamps(); // keep the lit buttons tracking the sim (see above)
// timing variables
__int64 ticks = HiResNowTicks();
#ifdef LOGFRAMERATE
@@ -6339,6 +6560,48 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
hr = mDevice->Present(NULL, NULL, gMainPresentWindow, NULL);
//
// RP412GAUGEDIAG=1: frames per second, on the same 2-second window the
// display-sweep line uses so the two read side by side.
//
// Without this the gauge rate has to be argued about rather than
// measured. A sweep rate far below the frame rate means the gauges are
// STARVED - the 3D is fine and the background loop is not getting
// through its cycle. A sweep rate that tracks the frame rate means
// there is nothing wrong with the gauges at all and the frame itself
// is the problem. Those two want opposite fixes, and the sweep line
// alone cannot tell them apart.
//
{
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 frames = 0;
unsigned long now = GetTickCount();
++frames;
if (window_start == 0)
{
window_start = now;
}
else if (now - window_start >= 2000)
{
int tenths = frames * 10000 / (int)(now - window_start);
DEBUG_STREAM << "FrameDiag: " << frames << " frame(s) in "
<< (now - window_start) << " ms ("
<< (tenths / 10) << '.' << (tenths % 10) << "/s)\n"
<< std::flush;
window_start = now;
frames = 0;
}
}
}
// hand the whole target back
if (mPresentationAspect > 0.0f)
{
+8
View File
@@ -643,3 +643,11 @@ public:
};
extern LPDIRECT3D9 gD3D;
//
// The presentation interval every device is created with. RP412VSYNC=0
// makes it IMMEDIATE, so Present returns instead of waiting for the
// panel's retrace - see the definition in L4VIDEO.cpp for why that
// matters far more here than tearing does.
//
DWORD RPPresentationInterval();
+200 -51
View File
@@ -2364,7 +2364,76 @@ ReticleRenderable::ReticleRenderable(
LPDIRECT3DDEVICE9 device = myRenderer->GetDevice();
device->CreateVertexBuffer(sizeof(L4VERTEX_2D) * 8, D3DUSAGE_WRITEONLY, L4VERTEX_2D_FVF, D3DPOOL_MANAGED, &mVB, NULL);
device->CreateVertexBuffer(sizeof(L4VERTEX_2D) * crosshairVertexCount, D3DUSAGE_WRITEONLY, L4VERTEX_2D_FVF, D3DPOOL_MANAGED, &mVB, NULL);
//
// Nothing is written here. The crosshair is measured against the
// render target it will actually be drawn into, and that is not
// known to be the renderer's requested size - see RebuildCrosshair.
//
mBuiltWidth = 0.0f;
mBuiltHeight = 0.0f;
mBuiltOriginX = -1.0f;
mBuiltOriginY = -1.0f;
RebuildCrosshair();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Centre the crosshair on the target it is about to be drawn into.
//
// It used to be baked once, in the constructor, from the renderer's
// GetWidth()/GetHeight() - the size the renderer ASKED for. A windowed
// device does not necessarily get it: BackBufferWidth/Height are only
// filled in for fullscreen (L4VIDEO.cpp), so windowed the back buffer is
// whatever the device window's client area happened to be when the
// device was created. A fresh renderer is built per mission while the
// window carries its restored cockpit placement across, so the two agree
// on the first race and can disagree on the next one - which put the
// crosshair off-centre by half the difference, and only ever on a second
// game.
//
// Measuring the viewport at draw time settles it for every case at once:
// the windowed mismatch, a device reset, and the podium's pillarbox crop.
//
void ReticleRenderable::RebuildCrosshair()
{
Check(this);
if (mVB == NULL)
{
return;
}
LPDIRECT3DDEVICE9 device = myRenderer->GetDevice();
if (device == NULL)
{
return;
}
D3DVIEWPORT9 viewport;
if (FAILED(device->GetViewport(&viewport)) ||
viewport.Width == 0 || viewport.Height == 0)
{
return;
}
float width = (float) viewport.Width;
float height = (float) viewport.Height;
//
// Origin included: a viewport that moves without resizing still
// carries the crosshair with it.
//
float origin_x = (float) viewport.X;
float origin_y = (float) viewport.Y;
if (width == mBuiltWidth && height == mBuiltHeight &&
origin_x == mBuiltOriginX && origin_y == mBuiltOriginY)
{
return;
}
mBuiltWidth = width;
mBuiltHeight = height;
mBuiltOriginX = origin_x;
mBuiltOriginY = origin_y;
L4VERTEX_2D *verts;
mVB->Lock(0, 0, (void**)&verts, 0);
@@ -2372,62 +2441,125 @@ ReticleRenderable::ReticleRenderable(
DWORD color = D3DCOLOR_XRGB(0, 128, 0);
float segmentLen = 5.0f / 192.0f;
float spread = 5.0f / 256.0f;
float width = myRenderer->GetWidth();
float height = myRenderer->GetHeight();
float centerX = width / 2.0f;
float centerY = height / 2.0f;
//
// Pre-transformed vertices are absolute screen pixels - the viewport
// clips them but does not shift them - so its origin is carried here.
//
float centerX = (float) viewport.X + width / 2.0f;
float centerY = (float) viewport.Y + height / 2.0f;
// top segment
verts[0].x = centerX;
verts[0].y = centerY - (spread + segmentLen) * height;
verts[0].z = 0.0f;
verts[0].rhw = 1.0f;
verts[0].color = color;
//
// Land on the pixel CENTRE, not the corner, so the quads below span
// whole pixels: an arm one pixel wide about a centre of x.5 runs from
// x.0 to x+1.0 and covers exactly one column.
//
centerX = (float)(int) centerX + 0.5f;
centerY = (float)(int) centerY + 0.5f;
verts[1].x = centerX;
verts[1].y = centerY - spread * height;
verts[1].z = 0.0f;
verts[1].rhw = 1.0f;
verts[1].color = color;
//
// Worth a line in the log: it names the target, the renderer's
// requested size and where the crosshair actually landed, so a
// report of it being off says which of the three moved.
//
DEBUG_STREAM << "Reticle: centred at " << centerX << "," << centerY
<< " on the " << viewport.Width << "x" << viewport.Height
<< " viewport at " << viewport.X << "," << viewport.Y
<< " (renderer asked for " << myRenderer->GetWidth() << "x"
<< myRenderer->GetHeight() << ")";
if (viewport.Width != myRenderer->GetWidth() ||
viewport.Height != myRenderer->GetHeight())
{
//
// Not the crosshair's problem alone: the projection matrix is
// built from the requested size too, so a target this does not
// match is being rendered at the wrong aspect and rescaled on
// the way to the pane.
//
DEBUG_STREAM << " - TARGET DISAGREES, aspect "
<< ((float) viewport.Width / (float) viewport.Height)
<< " drawn as "
<< ((float) myRenderer->GetWidth() / (float) myRenderer->GetHeight());
}
DEBUG_STREAM << "\n" << std::flush;
// right segment
verts[2].x = centerX + (spread + segmentLen) * height;
verts[2].y = centerY;
verts[2].z = 0.0f;
verts[2].rhw = 1.0f;
verts[2].color = color;
//
// Each arm is a quad one pixel thick rather than a line. D3D9 line
// rasterisation follows the diamond-exit rule and is free to differ
// between drivers on a segment that runs along a pixel boundary,
// which is how a crosshair loses one pair of arms and keeps the
// other. Triangles have a fill rule that does not vary, so an arm
// spanning whole pixels lands the same way everywhere - and on a
// full-screen device, where both viewport dimensions are usually
// even and BOTH pairs sit on boundaries, that is the difference
// between a crosshair and nothing at all.
//
float inner = spread * height;
float outer = (spread + segmentLen) * height;
verts[3].x = centerX + spread * height;
verts[3].y = centerY;
verts[3].z = 0.0f;
verts[3].rhw = 1.0f;
verts[3].color = color;
//
// Thickness follows the target the way the arms' length does, rather
// than being one pixel whatever the resolution.
//
// One pixel is a width the PRESENTATION can lose. The scene goes to
// the viewscreen pane through a stretch, and when the back buffer is
// wider than the pane that stretch samples straight past a feature a
// single pixel across. A second race rendering 3440 wide into the
// 2553-wide pane is 0.74 across and 1.00 down, which took the
// vertical arms and left the horizontal ones standing - the crosshair
// was being drawn correctly and thrown away on the way to the glass.
//
// Scaling it also keeps the pod's proportions: one pixel at the 480
// lines this was drawn for is three at 1440, not a hairline.
//
float thickness = (float)(int)(height / 480.0f);
if (thickness < 1.0f)
{
thickness = 1.0f;
}
float half = thickness * 0.5f;
// bottom segment
verts[4].x = centerX;
verts[4].y = centerY + (spread + segmentLen) * height;
verts[4].z = 0.0f;
verts[4].rhw = 1.0f;
verts[4].color = color;
struct Arm
{
float x0, y0, x1, y1; // opposite corners, in pixels
};
const Arm arms[4] =
{
// top
{ centerX - half, centerY - outer, centerX + half, centerY - inner },
// right
{ centerX + inner, centerY - half, centerX + outer, centerY + half },
// bottom
{ centerX - half, centerY + inner, centerX + half, centerY + outer },
// left
{ centerX - outer, centerY - half, centerX - inner, centerY + half }
};
verts[5].x = centerX;
verts[5].y = centerY + spread * height;
verts[5].z = 0.0f;
verts[5].rhw = 1.0f;
verts[5].color = color;
int v = 0;
for (int a = 0; a < 4; ++a)
{
const Arm &arm = arms[a];
//
// Two triangles, corners in the order top-left, top-right,
// bottom-left / top-right, bottom-right, bottom-left. Culling is
// turned off for the draw, so the winding does not have to agree
// with the renderer's global cull mode.
//
const float quad_x[6] =
{ arm.x0, arm.x1, arm.x0, arm.x1, arm.x1, arm.x0 };
const float quad_y[6] =
{ arm.y0, arm.y0, arm.y1, arm.y0, arm.y1, arm.y1 };
// left segment
verts[6].x = centerX - (spread + segmentLen) * height;
verts[6].y = centerY;
verts[6].z = 0.0f;
verts[6].rhw = 1.0f;
verts[6].color = color;
verts[7].x = centerX - spread * height;
verts[7].y = centerY;
verts[7].z = 0.0f;
verts[7].rhw = 1.0f;
verts[7].color = color;
for (int c = 0; c < 6; ++c)
{
verts[v].x = quad_x[c];
verts[v].y = quad_y[c];
verts[v].z = 0.0f;
verts[v].rhw = 1.0f;
verts[v].color = color;
++v;
}
}
Verify(v == crosshairVertexCount);
mVB->Unlock();
}
@@ -2497,9 +2629,26 @@ void ReticleRenderable::Render(int pass, const D3DXMATRIX *viewTransform)
{
LPDIRECT3DDEVICE9 device = myRenderer->GetDevice();
//
// The target is whatever is bound right now, so ask it now. A
// compare against the size already built means this costs one
// GetViewport a frame and rewrites nothing until it moves.
//
RebuildCrosshair();
//
// The arms are quads, and which way round they wind is not worth
// making the renderer's global cull mode responsible for.
//
DWORD cull_mode;
device->GetRenderState(D3DRS_CULLMODE, &cull_mode);
device->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
device->SetTexture(0, NULL);
device->SetStreamSource(0, mVB, 0, sizeof(L4VERTEX_2D));
device->DrawPrimitive(D3DPT_LINELIST, 0, 4);
device->DrawPrimitive(D3DPT_TRIANGLELIST, 0, 8);
device->SetRenderState(D3DRS_CULLMODE, cull_mode);
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+13
View File
@@ -783,6 +783,19 @@ class ReticleRenderable :
void Render(int pass, const D3DXMATRIX *viewTransform);
protected:
//
// The crosshair is written as pre-transformed vertices - screen
// PIXELS - so it is only centred for the target it was measured
// against. Re-measure before drawing and rebuild when that
// target changes, rather than baking it once at construction.
//
void RebuildCrosshair();
// four arms, two triangles each
enum { crosshairVertexCount = 24 };
// viewport the vertex buffer currently describes
float mBuiltWidth, mBuiltHeight, mBuiltOriginX, mBuiltOriginY;
// Last known position of the reticle
Vector2DOf<float> myOldReticlePosition;
+3
View File
@@ -151,6 +151,7 @@
<ClCompile Include="..\MUNGA\INTEREST.cpp" />
<ClCompile Include="..\MUNGA\INTORGN.cpp" />
<ClCompile Include="..\MUNGA\ITERATOR.cpp" />
<ClCompile Include="..\MUNGA\INPUTSCRIPT.cpp" />
<ClCompile Include="..\MUNGA\JMOVER.cpp" />
<ClCompile Include="..\MUNGA\JOINT.cpp" />
<ClCompile Include="..\MUNGA\LAMP.cpp" />
@@ -227,6 +228,7 @@
<ClCompile Include="..\MUNGA\WRHOUS.cpp" />
<ClCompile Include=".\DXUtils.cpp" />
<ClCompile Include=".\L4APP.cpp" />
<ClCompile Include=".\L4AUDEFX.cpp" />
<ClCompile Include=".\L4AUDHDW.cpp" />
<ClCompile Include=".\L4AUDIO.cpp" />
<ClCompile Include=".\L4AUDLVL.cpp" />
@@ -432,6 +434,7 @@
<ClInclude Include="..\MUNGA\WRHOUS.h" />
<ClInclude Include=".\DXUtils.h" />
<ClInclude Include=".\L4APP.H" />
<ClInclude Include=".\L4AUDEFX.h" />
<ClInclude Include=".\L4AUDHDW.h" />
<ClInclude Include=".\L4AUDIO.h" />
<ClInclude Include=".\L4AUDLVL.h" />
+6
View File
@@ -486,6 +486,9 @@
<ClCompile Include=".\L4AUDHDW.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
<ClCompile Include=".\L4AUDEFX.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
<ClCompile Include=".\L4AUDIO.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
@@ -1058,6 +1061,9 @@
<ClInclude Include=".\L4APP.H">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
<ClInclude Include=".\L4AUDEFX.h">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
<ClInclude Include=".\L4AUDHDW.h">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
+7
View File
@@ -32,6 +32,13 @@ SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd)
default.file = "";
default.implemented = false;
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)
{
+158 -2
View File
@@ -19,7 +19,40 @@
// fade; the rest is the podium. Kept under the +30s LightsOut post so that
// never fires while the stand is up.
//
const Scalar winnersCircleHoldTime = 11.0f;
// RP412PODIUMHOLD tunes it, and RP412PODIUM=0 declines it entirely: that
// option promises "straight to the results", and it used to get the full
// eleven seconds against a black screen anyway, because this timer never
// asked whether there was a podium to hold the mission open FOR. Zero
// here means "do not override the base fade" - the stock 3 seconds.
//
static Scalar
WinnersCircleHoldTime()
{
static Scalar
hold = (Scalar) -1;
if (hold < (Scalar) 0)
{
const char *podium = getenv("RP412PODIUM");
if (podium != NULL && atoi(podium) == 0)
{
hold = (Scalar) 0;
}
else
{
const char *setting = getenv("RP412PODIUMHOLD");
hold = (setting != NULL) ? (Scalar) atof(setting) : (Scalar) 11;
//
// Under a second cuts into the race's own fade-out, and a
// minute is a stuck-looking screen; both read as bugs, not
// choices.
//
if (hold < (Scalar) 1) hold = (Scalar) 1;
if (hold > (Scalar) 60) hold = (Scalar) 60;
}
}
return hold;
}
//#############################################################################
//######################## RPPlayer__StatusMessage ######################
@@ -214,7 +247,19 @@ void
//
if (application->GetApplicationState() == Application::EndingMission)
{
fadeTimeRemaining = winnersCircleHoldTime;
Scalar hold = WinnersCircleHoldTime();
if (hold > (Scalar) 0)
{
fadeTimeRemaining = hold;
DEBUG_STREAM << "WinnersCircle: holding the mission open "
<< hold << "s for the stand\n" << std::flush;
}
else
{
DEBUG_STREAM << "WinnersCircle: podium off - the race fade "
<< "stands (" << fadeTimeRemaining << "s) and the results "
<< "come straight up\n" << std::flush;
}
}
Check_Fpu();
}
@@ -382,6 +427,96 @@ void RPPlayer::ResetAfterDeath(DropZone::ReplyMessage *message)
ForceUpdate();
SetSimulationState(DropZoneAcquiredState);
dropZoneLocation = message->dropZoneLocation;
//
//------------------------------------------------------------------
// Fixed-step: the RECOVERY goes on the vehicle's own step grid.
//
// The event queue runs on wall clock, so a Reset fired from it
// lands between different sim steps on every run - the crash was
// measured bit-identical between runs and the first divergence was
// the step after the pod stood back up. The vehicle applies the
// teleport itself at the first step one SIM second after now; the
// message still makes its round trip below, but only for the
// player-state bookkeeping - the handler leaves the physics to the
// schedule it can see is pending.
//------------------------------------------------------------------
//
if (Simulation::FixedStep() > (Scalar) 0 &&
playerVehicle != NULL && playerVehicle->GetClassID() == VTVClassID)
{
VTV *vtv = (VTV *) playerVehicle;
//
// Anchored to the DEATH and quantized to a half-second grid.
//
// This handler runs when the drop-zone reply finally comes off
// the event queue, and the whole death-to-here chain is wall
// clock - the fry retries repost at Now()+2.0, and the measured
// arrival is about five sim-seconds after death, give or take a
// few STEPS of queue jitter. An anchor of death+1.0 is long past
// by then, so "fire at the next step" inherited the jitter
// whole.
//
// The death stamp is the last step-exact event in the chain, so:
// take the measured gap, add the second the old code waited, and
// round UP to the next half-second AFTER THE DEATH. The jitter
// is hundredths; the nearest grid boundary is tenths away; every
// run lands in the same cell and fires on the same step. The
// felt delay is the same six-ish seconds it has always been.
//
Time due;
Time death_mark;
if (vtv->ConsumeDeathClock(&death_mark))
{
Scalar gap = vtv->GetLastPerformance() - death_mark;
const Scalar quantum = (Scalar) 0.5;
int cells = (int)((gap + (Scalar) 1.0) / quantum) + 1;
due = death_mark;
due += quantum * (Scalar) cells;
}
else
{
due = vtv->GetLastPerformance();
due += 1.0f;
}
vtv->ScheduleRespawn(
message->dropZoneLocation, due,
(goalEntity != NULL)
? goalEntity->localOrigin.linearPosition
: Point3D(0.0f, 0.0f, 0.0f),
(goalEntity != NULL) ? True : False);
respawnScheduled = True;
//
// Under the trace, say what was scheduled in run-comparable
// terms: the pad (identity by position), and how far ahead of
// the vehicle's clock the due time sits. Two runs that disagree
// here diverge before the physics gets a vote.
//
{
static int diag = -1;
if (diag < 0)
{
const char *setting = getenv("RP412PHYSTRACE");
diag = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diag)
{
char buffer[160];
sprintf(buffer,
"PhysTrace: respawn #%d scheduled, pad %.2f,%.2f "
"due in %.4f sim-s\n",
deathCount,
(double) message->dropZoneLocation.linearPosition.x,
(double) message->dropZoneLocation.linearPosition.z,
(double)(Scalar)(due - vtv->GetLastPerformance()));
DEBUG_STREAM << buffer << std::flush;
}
}
}
Time when = Now();
when += 1.0f;
application->Post(HighEventPriority, this, message, when);
@@ -422,6 +557,7 @@ void
}
AlwaysExecute();
deathCount = 0;
respawnScheduled = False;
}
//
@@ -467,6 +603,26 @@ void
{
VTV *vtv = (VTV*)playerVehicle;
Check(vtv);
//
// A SCHEDULED respawn means the vehicle already holds - or has
// already applied - its teleport and goal-flip, on its own step
// grid. Resetting it AGAIN here, at whatever wall instant this
// message came off the queue, would re-teleport it mid-step and
// put the nondeterminism straight back.
//
// The flag, not "is fixed stepping on": this same leg also runs
// for the FIRST spawn of the mission, where the Reset below is
// what wakes a Mover out of its initial stasis. Gating on the
// mode alone skipped that wake-up and parked the pod, frozen at
// exactly its spawn point, for an entire race.
//
if (respawnScheduled)
{
respawnScheduled = False;
Check_Fpu();
return;
}
vtv->Reset(message->dropZoneLocation, VTV::RegularReset);
}
+12
View File
@@ -291,6 +291,18 @@ public:
Entity
*goalEntity;
//
// True between ResetAfterDeath handing the recovery to the vehicle's
// step grid (fixed-step only) and the bookkeeping message coming back
// round. The message handler must NOT Reset the vehicle again in that
// window - but it MUST still Reset on the first spawn of the mission,
// which is what wakes a Mover out of its initial stasis. Gating on
// "is fixed stepping on" instead of on this flag skipped that wake-up
// and froze the pod on its pad for the whole race.
//
Logical
respawnScheduled;
private:
static const IndexEntry AttributePointers[];
+128 -3
View File
@@ -803,9 +803,17 @@ void
Check(*damageZones);
(*damageZones)->TakeDamage(collision_damage);
localVelocity.angularMotion.x += 8.0f * Random - 4.0f;
localVelocity.angularMotion.y += 8.0f * Random - 4.0f;
localVelocity.angularMotion.z += 8.0f * Random - 4.0f;
//
// The tumble draws from the vehicle's OWN stream, not the
// global Random - the global one is shared with the frame
// loop's consumers (particles, mostly), so its position here
// depended on how many frames had rendered. This kick goes
// straight into physics state; it was the last wall-clocked
// input left in the whole death cycle.
//
localVelocity.angularMotion.x += 8.0f * TumbleRandom() - 4.0f;
localVelocity.angularMotion.y += 8.0f * TumbleRandom() - 4.0f;
localVelocity.angularMotion.z += 8.0f * TumbleRandom() - 4.0f;
}
}
worldLinearAcceleration = zippy_accel;
@@ -1273,6 +1281,18 @@ VTV::VTV(
boosterSmokeDensity = 0.0f;
doorHitNormal = Vector3D::Identity;
lastDoorHit = Time::Null;
respawnPending = False;
respawnHaveGoal = False;
deathClockValid = False;
//
// Creation order is deterministic, so each vehicle's tumble stream
// is too - see TumbleRandom in the header.
//
{
static unsigned long tumble_births = 0;
tumbleSeed = 0x52503431UL + 7919UL * ++tumble_births;
}
heightAboveTerrain = 0.0f;
forwardVelocity = 0.0f;
hornBlast = -1;
@@ -1685,6 +1705,101 @@ void
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
VTV::ScheduleRespawn(
const Origin &new_origin,
const Time &due,
const Point3D &face_toward,
Logical have_goal
)
{
Check(this);
Check(&new_origin);
respawnOrigin = new_origin;
respawnDue = due;
respawnGoal = face_toward;
respawnHaveGoal = have_goal;
respawnPending = True;
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The pending respawn lands here, at the top of the first STEP whose clock
// has reached its due time - the same step count after death on every run
// and every frame rate. The old path applied the Reset from the event
// queue, which runs on wall clock: the crash was deterministic and the
// recovery was not, measured as two identical runs diverging on the first
// step after the pod stood back up.
//
// The turn-to-face-the-scorezone flip is the same arithmetic
// RPPlayer::PointVTVTowardGoal does, done here because it reads the
// POST-reset heading - it belongs to the same step as the teleport.
//
void
VTV::BeginStep()
{
Check(this);
if (respawnPending && !(GetLastPerformance() < respawnDue))
{
respawnPending = False;
{
static int diag = -1;
if (diag < 0)
{
const char *setting = getenv("RP412PHYSTRACE");
diag = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diag)
{
char buffer[120];
sprintf(buffer,
"PhysTrace: respawn fired, %.4f sim-s late, pad %.2f,%.2f\n",
(double)(Scalar)(GetLastPerformance() - respawnDue),
(double) respawnOrigin.linearPosition.x,
(double) respawnOrigin.linearPosition.z);
DEBUG_STREAM << buffer << std::flush;
}
}
Reset(respawnOrigin, RegularReset);
if (respawnHaveGoal)
{
Vector3D to_goal;
to_goal.Subtract(respawnGoal, localOrigin.linearPosition);
UnitVector current_heading;
localToWorld.GetFromAxis(Z_Axis, &current_heading);
Scalar length_to_goal = to_goal.LengthSquared();
if (length_to_goal > SMALL)
{
Scalar dot_prod =
(to_goal * current_heading) / Sqrt(length_to_goal);
if (dot_prod >= 0.0f)
{
Quaternion turn_around;
Quaternion y_roll(0.0f, 1.0f, 0.0f, 0.0);
turn_around.Multiply(
localOrigin.angularPosition, y_roll);
localOrigin.angularPosition = turn_around;
localToWorld = localOrigin;
}
}
ForceUpdate();
}
}
Mover::BeginStep();
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -3100,6 +3215,16 @@ void
if (damageLevel >= 1.0f)
{
vtv->SetSimulationState(VTV::BurningState);
//
// Stamp the death on the vehicle's own step clock, here at the
// one site that declares it dead. The respawn schedule anchors
// to this instant - the last step-exact event in the death
// chain - so the recovery lands the same number of steps after
// the crash on every run. Marked once; repeated damage while
// already burning does not move it.
//
vtv->MarkDeathClock();
}
//
+84
View File
@@ -504,6 +504,58 @@ public:
void
Reset(const Origin &new_origin, int reset_command);
//
// A respawn that lands on this vehicle's own step grid. Under fixed
// stepping the player's death recovery cannot ride the event queue -
// the queue runs on wall clock, and a Reset that fires at a wall
// instant lands between different sim steps on every run. Scheduled
// here instead, BeginStep applies it at the first step whose clock
// reaches 'due': same step count after death, every run, every
// frame rate. The goal point rides along because the turn-to-face-
// the-scorezone flip depends on the POST-reset heading, so it has
// to happen in the same step as the teleport.
//
void
ScheduleRespawn(
const Origin &new_origin,
const Time &due,
const Point3D &face_toward,
Logical have_goal
);
void
BeginStep();
//
// The instant this vehicle died, on its own step clock - stamped at
// the single site that sets BurningState, which runs inside the step
// machinery and is therefore already deterministic. The respawn
// schedule anchors HERE rather than at the moment the drop-zone
// reply happens to come off the event queue: the death is the last
// step-exact event in the chain, so "one second after death" is the
// same step count on every run. Marked once per death; consuming it
// re-arms it for the next one.
//
void
MarkDeathClock()
{
if (!deathClockValid)
{
deathClock = GetLastPerformance();
deathClockValid = True;
}
}
Logical
ConsumeDeathClock(Time *when_out)
{
if (!deathClockValid)
{
return False;
}
*when_out = deathClock;
deathClockValid = False;
return True;
}
void
DeathShutdown(int shutdown_command);
@@ -514,6 +566,38 @@ public:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Navigation support
protected:
// the pending step-grid respawn - see ScheduleRespawn
Origin
respawnOrigin;
Time
respawnDue,
deathClock;
Point3D
respawnGoal;
Logical
respawnPending,
respawnHaveGoal,
deathClockValid;
//
// The out-of-world tumble's own random stream. The global Random is
// shared with frame-cadence consumers - particles above all - so its
// position when a burning pod draws from it depends on how many
// frames have rendered, which is wall clock, which makes the tumble
// differ between identical runs. A per-vehicle generator seeded by
// creation order keeps the tumble looking random while drawing the
// same kicks at the same steps every run.
//
unsigned long
tumbleSeed;
Scalar
TumbleRandom()
{
tumbleSeed = tumbleSeed * 1103515245UL + 12345UL;
return (Scalar)((tumbleSeed >> 16) & 0x7FFF) / (Scalar) 32767;
}
Scalar
targetRangeExponent,
currentRangeExponent;
+31
View File
@@ -5,6 +5,7 @@
#include "vtvpwr.h"
#include "..\munga\icom.h"
#include "..\munga\app.h"
#include "..\munga\inputscript.h"
#include "rpplayer.h"
#include "vtv.h"
@@ -414,6 +415,36 @@ void
VTVPower *power_system =
Cast_Object(VTVPower*, vtv->GetSubsystem(VTV::PowerSubsystem));
//
//----------------------------------------------------------------
// RP412INPUTSCRIPT: scripted driving, on this subsystem's own step
// clock.
//
// This is the one place every mapper - RIO, Thrustmaster, pad -
// funnels through, and it runs per SIMULATION STEP, so a scripted
// value lands on the same step of every run whatever the frame
// rate. Overriding at the RIO or the controls manager would key
// the timeline to the frame loop, which is wall clock, which is
// the thing the whole harness exists to keep out of the physics.
//
// Only the player's own vehicle: replicants get their state from
// the network, and the mapper does not run for them anyway.
//----------------------------------------------------------------
//
if (RPInputScript_Active())
{
float script_throttle, script_x, script_y, script_pedals;
if (RPInputScript_Sample(GetLastPerformance(),
&script_throttle, &script_x, &script_y, &script_pedals))
{
throttlePosition = script_throttle;
stickPosition.x = script_x;
stickPosition.y = script_y;
pedalsPosition = script_pedals;
}
}
//
//----------------------------------------------
// Make sure the control inputs are within range
+78 -8
View File
@@ -229,8 +229,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
" Expired %s\n\n"
"Test builds are good for a fortnight so that nobody spends an "
"afternoon chasing something that was fixed a week ago.\n\n"
"Grab the current one:\n"
"https://gitea.mysticmachines.com/VWE/RP412/releases",
"Ask for the current one.",
RP412_VERSION_LONG, RP412_EXPIRY_TEXT);
MessageBoxA(NULL, notice, "Red Planet - test build expired",
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
@@ -343,12 +342,41 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
DWORD wsStyle = WS_OVERLAPPED | WS_SYSMENU;
if (L4Application::GetFullscreen())
wsStyle = WS_POPUP;
hWnd = CreateWindowEx(0, L"MainWndClass", L"RPL4", WS_OVERLAPPEDWINDOW, 0, 0, L4Application::GetScreenWidth(), L4Application::GetScreenHeight(), (HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd)
return FALSE;
//
// The style the window is BORN with, rather than one worked out and
// then thrown away - the old code computed a style and handed
// CreateWindowEx a literal WS_OVERLAPPEDWINDOW regardless.
//
// Windowed keeps the full overlapped set on purpose: the sizing
// border and the maximise box are how the player arranges the
// cockpit, and mfd_layout.cfg remembers where they left it. The
// borderless modes are born borderless instead of being restyled a
// moment later, so there is no framed window on screen first.
//
DWORD wsStyle = WS_OVERLAPPEDWINDOW;
if (L4Application::GetFullscreen() || L4Application::GetFitDisplay())
{
wsStyle = WS_POPUP;
}
//
// -res is a RENDER size, so it is the client area that has to be it.
// Passed straight to CreateWindowEx it sets the OUTER rectangle and
// the chrome comes out of the middle - which is how -res 640 480
// came to present into a 624x441 client.
//
RECT wanted;
wanted.left = 0;
wanted.top = 0;
wanted.right = (LONG) L4Application::GetScreenWidth();
wanted.bottom = (LONG) L4Application::GetScreenHeight();
AdjustWindowRect(&wanted, wsStyle, FALSE);
hWnd = CreateWindowEx(0, L"MainWndClass", L"RPL4", wsStyle, 0, 0,
wanted.right - wanted.left, wanted.bottom - wanted.top,
(HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd)
return FALSE;
ShowWindow(hWnd, nShowCmd);
@@ -365,6 +393,23 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
RPWindowLayout_Register(hWnd, "RPL4", True);
RPWindowLayout_Load();
}
else if (L4Application::GetFitDisplay())
{
//
// -fit has no saved placement to restore, but it does have a
// shape to take, and it has to take it NOW. SVGA16 applies the
// same borderless full-monitor rect when it assembles the
// cockpit, which is after the first mission has already built
// its D3D device against the window as it stands - so the first
// race of a session used to render to a bordered client and
// every race after it to the borderless monitor. Same lobby,
// same settings, different target and different frame cost.
//
// Settle the window here and every mission of the session,
// first included, is set up against the identical client area.
//
L4Application::FitWindowToMonitor(hWnd);
}
//
//-------------------------------------------------------------------------
@@ -414,6 +459,31 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
int last_launch_mode = FELaunchSingle;
//
//-------------------------------------------------------------------------
// A hand-fed egg run stays unmarshaled - no console, so the race never
// ends - UNLESS it is given a length. RP412MISSIONSECONDS supplies one,
// and with it '-egg' can play a whole race through to the buzzer, the
// podium and the results.
//
// That is the difference between a shortcut that can only be watched and
// one that can be TESTED: everything after the chequered flag - the fade,
// the winners' circle, the teardown - was unreachable from the command
// line, so it could only ever be exercised by hand through the menu.
//-------------------------------------------------------------------------
//
if (!front_end_mode && L4Application::GetEggNotationFileName())
{
const char *egg_seconds = getenv("RP412MISSIONSECONDS");
if (egg_seconds != NULL && atoi(egg_seconds) > 0)
{
DEBUG_STREAM << "LocalConsole: marshalling the hand-fed egg run ("
<< atoi(egg_seconds) << "s, RP412MISSIONSECONDS)\n" << std::flush;
L4Application::SetNetworkCommonFlatAddress(0);
RPL4LocalConsole_Install(atoi(egg_seconds));
}
}
for (;;)
{
if (front_end_mode)
+21
View File
@@ -244,6 +244,14 @@ void
// you would be the one empty spot. Turn it inside out before the shot.
//---------------------------------------------------------------------
//
//
// Timed, because the black screen between the race fading out and the
// stand fading in is several seconds long and the fade only accounts
// for 0.7 of them. Nothing else runs while this does - the whole game
// is one thread - so whatever these two cost IS that gap.
//
Time podiumOutsideStart = Now();
dpl_renderer->ShowViewpointFromOutside();
//
@@ -253,8 +261,21 @@ void
// whatever order the players were created.
//---------------------------------------------------------------------
//
Time podiumNamesStart = Now();
dpl_renderer->SortAndReloadNameBitmaps();
{
Time podiumNamesEnd = Now();
char timing[160];
sprintf(timing,
"WinnersCircle: exterior %.0f ms, name plates %.0f ms\n",
(double)((Scalar)(podiumNamesStart - podiumOutsideStart) * 1000.0f),
(double)((Scalar)(podiumNamesEnd - podiumNamesStart) * 1000.0f));
DEBUG_STREAM << timing << std::flush;
}
//
//---------------------------------------------------------------------
// Widen to 45 degrees and pull back in front of the stand so the whole
+165
View File
@@ -50,6 +50,20 @@ namespace
"# Unset falls back to KEYBOARD alone.\n"
"L4CONTROLS=PAD;KEYBOARD\n"
"\n"
"# Read the keyboard, pad and stick only while the game is the window in\n"
"# front. The pod was the only thing running on its cabinet, so the\n"
"# virtual RIO reads the key state directly rather than waiting on the\n"
"# message pump - which means it reads it whatever is in front, and\n"
"# switching to another window to type flies the pod around while you\n"
"# type in it.\n"
"# 1 controls go neutral when you switch away (default)\n"
"# 0 read them regardless, as earlier builds did\n"
"# Any window of the game counts as the game, so clicking an MFD pane or\n"
"# the plasma glass does not drop your controls. Real RIO cockpit\n"
"# hardware is unaffected either way - this is the keyboard, pad and\n"
"# joystick path only.\n"
"RP412INPUTFOCUS=1\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"
@@ -176,6 +190,12 @@ namespace
"#RP412PODIUMFADEIN=0.45\n"
"#RP412PODIUMCAM=1\n"
"\n"
"# How long the podium holds before the results screen, in seconds\n"
"# (1-60). The stand is worth a look but eleven seconds of one parked\n"
"# pod is a long look in single player. With RP412PODIUM=0 there is no\n"
"# hold at all - straight to the results, as that option promises.\n"
"#RP412PODIUMHOLD=11\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"
@@ -185,6 +205,58 @@ namespace
"# default is 60; the arcade pods shipped at 25.\n"
"TARGETFPS=60\n"
"\n"
"# The physics step, in steps per second. 50 is the default: the\n"
"# simulation advances in fixed 20 ms steps whatever the display does,\n"
"# so the SAME race plays out on every machine - measured bit-identical\n"
"# at 30, 60 and 144 fps, through a scripted lap with a crash, a burn\n"
"# and two respawns. A pod at 30 fps and a pod at 144 are finally in\n"
"# the same gravity.\n"
"#\n"
"# The alternatives, all exact on the engine's millisecond clock:\n"
"# 25 the arcade pods' rate - the step the original handling was\n"
"# tuned against, coarsest contact response\n"
"# 100 the smoothest contact and terrain response\n"
"# 0 the original frame-coupled physics, where the frame rate is\n"
"# part of the simulation - kept for comparison\n"
"# (Rates that do not divide 1000 evenly - 60, say - quietly run at the\n"
"# nearest millisecond step instead; the log says so if you try one.)\n"
"#\n"
"# What to feel for between rates: hover bounce, wall hits, how the pod\n"
"# takes the crest of a hill. Report the rate with the verdict.\n"
"RP412PHYSICSHZ=50\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"
"# How many times the map redraws per turn of the gauge rate wheel, 1 to\n"
"# 16. The renderer gives each gauge one step of a sixteen-step wheel and\n"
"# a gauge redraws only on its own step, so a map left on one step waits a\n"
"# whole turn. 16 = redraw on every step (default); 1 = whatever the gauge\n"
"# data asks for, which is how it behaved before this existed. Each step\n"
"# costs one map redraw against a pass that runs ninety gauges.\n"
"RP412MAPRATE=16\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"
@@ -198,6 +270,52 @@ namespace
"# the old arrival-time behaviour if you want to compare.\n"
"#RP412NETCLOCK=0\n"
"\n"
"# ---- Test harness -----------------------------------------------------------\n"
"\n"
"# The knobs that make a run repeatable and measurable. All are off\n"
"# unless set and cost nothing when off; none belongs in a real race.\n"
"# They exist so a claim about the game can be tested instead of argued.\n"
"\n"
"# Dump the gauge profile to rpl4.log every N seconds: every cockpit\n"
"# display with its rate mask and tier, how often it ran and what it\n"
"# cost. This is the engine's own ProfileReport, which was only ever\n"
"# reachable from the arcade RIO mapper's F11 before.\n"
"#RP412GAUGEPROFILE=8\n"
"\n"
"# 1 = log renderable construction and what each frame is made of, so a\n"
"# model that never got built can be told from one that is simply out\n"
"# of shot.\n"
"#RP412RENDERDIAG=1\n"
"\n"
"# 1 = trace the player pod's position to rpl4.log on the SIMULATION's\n"
"# own clock, stopping the pod dead at the green light so every run\n"
"# starts from rest. Two runs of the same race then compare sample for\n"
"# sample - this is the instrument that proved RP412PHYSICSHZ plays the\n"
"# same race at every frame rate, bit for bit.\n"
"#RP412PHYSTRACE=1\n"
"\n"
"# Try this drop zone first at spawn instead of a random pick. The pick\n"
"# is seeded by RANDOM=, but a seed only repeats a run if the same\n"
"# NUMBER of draws comes before the pick, and that count rides on load\n"
"# timing - so pin the pad too, or two 'identical' runs start over\n"
"# different ground. Falls back to the random walk if the zone is\n"
"# taken, so it cannot wedge.\n"
"#RP412SPAWNZONE=3\n"
"\n"
"# Drive the pod from a timeline file instead of the controls - the\n"
"# same lap, exactly, every run. One row per change, held until the\n"
"# next row: time-in-seconds throttle stickX stickY pedals, values\n"
"# 0..1 for throttle and -1..1 elsewhere, # for comments. Times are\n"
"# SIMULATION seconds from the green light, so with RP412PHYSICSHZ set\n"
"# the same script is the same race at any frame rate - this is how\n"
"# driving, not just settling, gets verified bit-identical.\n"
"#RP412INPUTSCRIPT=testlap.txt\n"
"\n"
"# 1 = log the XInput-class controllers the generic-joystick scan skips\n"
"# (attached DirectInput devices are always logged). For debugging a pad\n"
"# that answers twice or a stick that does not answer at all.\n"
"#RP412JOYLOG=1\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"
@@ -205,10 +323,57 @@ namespace
"# 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"
"# Who transforms the vertices: hw hands it to the GPU, sw does it on the\n"
"# CPU as this engine always has. There was no hardware to hand it to when\n"
"# it was written; there is now, and it is not close - on a busy track the\n"
"# 3D foreground drops from about 17ms a frame to under half a\n"
"# millisecond, and all of that time goes back to the cockpit displays,\n"
"# which is what makes the map, the clock and the gauges live rather than\n"
"# updating every few seconds.\n"
"# Falls back to sw by itself if the adapter has no hardware T&L. sw is\n"
"# the way back if a driver's fixed-function lighting or fog looks wrong -\n"
"# the two are not bit-identical.\n"
"RP412VERTEXPROC=hw\n"
"\n"
"# 0 = present without waiting for the panel's retrace. Costs tearing,\n"
"# buys latency. Measured to make very little difference to the frame\n"
"# budget here - the frame is full of work, not waiting - so this is a\n"
"# preference rather than a fix.\n"
"#RP412VSYNC=0\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"
+367 -76
View File
@@ -33,6 +33,17 @@ namespace
{ "brewers", "Brewer's Bane" },
{ "pain", "Paingod's Passage" },
{ "headoff", "Freezemoon's Freeway" },
// Maps that came with the promoted resource file. The console
// config brackets their names in dashes, which is how it marks
// the ones that are not original arcade tracks.
{ "arena", "-- Arena (Small) --" },
{ "demoderb", "-- Arena (Large) --" },
{ "donut", "-- Donut --" },
{ "tourdemars", "-- Tour De Mars --" },
{ "trough", "-- Ares' Armpits --" },
{ "wiserguys", "-- Wiserguy's --" },
{ "rpweekend2014", "-- Zaxxis --" },
};
// Football excludes pain/headoff and adds the football build of
@@ -110,6 +121,23 @@ namespace
{ "spitter", "Spitter" },
{ "puck", "Armadillo" },
{ "dragon", "Dragon" },
// Restored with the promoted resource file: cut from the .bld
// after 4.10 shipped, back now that RPL4.RES carries them again.
// Names from the console's own RPConfig.xml.
{ "dark", "Blacker Puck" },
{ "blktrn", "Black Tarantula" },
{ "neut", "Neutrino" },
// Community variants that came with the same archive.
{ "blkrpuck", "Blacker Armadillo" },
{ "blkrbroc", "Blacker Broccoli" },
{ "blkrdragon", "Blacker Dragon" },
{ "blkrlepton", "Blacker Lepton" },
{ "blkrquark", "Blacker Quark" },
{ "blkrspk", "Blacker Speck" },
{ "blkrtran", "Blacker Tarantula" },
{ "blkrwasp", "Blacker Wasp" },
};
const CatalogEntry kColors[] =
@@ -214,6 +242,13 @@ namespace
HWND menuWindow;
HWND nameEdit;
// Every list group is a drop-down. They are owner-drawn so they
// keep the green-on-black panel look instead of arriving in
// system colours, and their labels are painted by PaintMenu.
HWND combo[GroupCount];
RECT comboLabel[GroupCount];
HFONT textFont;
HFONT titleFont;
HBRUSH editBrush;
@@ -223,6 +258,9 @@ namespace
};
FEState *gFE = NULL;
// ItemName() reads this to answer with the right track list.
const int *gItemNameSelection = NULL;
int gLastMissionSeconds = 0;
// carried across races so cycling back to the menu keeps the
@@ -584,69 +622,207 @@ namespace
//---------------------------------------------------------------
// Layout: three columns of lists + the launch button
//---------------------------------------------------------------
void AddGroupItems(
FEState *fe, int group, int count,
int x, int *y, int row_h, int width)
//---------------------------------------------------------------
// The groups that became drop-downs, in the order they are laid
// out. Scenario is deliberately NOT one of them: it decides what
// the others contain, so it stays visible as a pair of buttons.
//---------------------------------------------------------------
int ComboGroups(const int *selection, int *groups)
{
for (int i = 0; i < count; ++i)
int n = 0;
groups[n++] = GroupMap;
groups[n++] = GroupTime;
groups[n++] = GroupWeather;
groups[n++] = GroupLength;
groups[n++] = GroupVehicle;
if (IsFootball(selection))
{
FEItem *item = &fe->items[fe->itemCount++];
item->group = group;
item->index = i;
item->rect.left = x;
item->rect.top = *y;
item->rect.right = x + width;
item->rect.bottom = *y + row_h;
*y += row_h;
groups[n++] = GroupTeam;
groups[n++] = GroupPosition;
}
*y += row_h; // gap below the group
else
{
groups[n++] = GroupColor;
groups[n++] = GroupBadge;
}
return n;
}
// The combo id is its group, so CBN_SELCHANGE says which list moved.
const int kComboIdBase = 100;
//---------------------------------------------------------------
// CBS_OWNERDRAWFIXED only hands us the item area: the frame around
// the closed box and its drop arrow are still drawn by the system,
// which lands a white/grey Windows control in the middle of a black
// panel. So the closed box is painted here instead - black field,
// dim green border, bright green text and arrow - and the control
// keeps doing everything else, including the list it drops.
//---------------------------------------------------------------
WNDPROC gComboProc = NULL;
LRESULT CALLBACK ComboSubclassProc(
HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
if (message == WM_ERASEBKGND)
{
return 1;
}
if (message == WM_PAINT)
{
PAINTSTRUCT ps;
HDC dc = BeginPaint(hwnd, &ps);
RECT rc;
GetClientRect(hwnd, &rc);
HBRUSH field = CreateSolidBrush(kBlack);
FillRect(dc, &rc, field);
DeleteObject(field);
HBRUSH edge = CreateSolidBrush(kGreenDim);
FrameRect(dc, &rc, edge);
DeleteObject(edge);
int arrow_w = rc.bottom - rc.top;
char text[128];
text[0] = '\0';
int sel = (int) SendMessageA(hwnd, CB_GETCURSEL, 0, 0);
if (sel >= 0)
{
SendMessageA(hwnd, CB_GETLBTEXT, sel, (LPARAM) text);
}
HFONT font = (HFONT) SendMessageA(hwnd, WM_GETFONT, 0, 0);
HGDIOBJ old_font = (font != NULL) ? SelectObject(dc, font) : NULL;
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, kGreenBright);
RECT label = rc;
label.left += 6;
label.right -= arrow_w;
DrawTextA(dc, text, -1, &label,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
// the arrow, a plain triangle rather than a themed button
int cx = rc.right - arrow_w / 2;
int cy = (rc.top + rc.bottom) / 2;
int r = arrow_w / 6;
POINT tri[3];
tri[0].x = cx - r; tri[0].y = cy - r / 2;
tri[1].x = cx + r; tri[1].y = cy - r / 2;
tri[2].x = cx; tri[2].y = cy + r;
HBRUSH tip = CreateSolidBrush(kGreenBright);
HGDIOBJ old_brush = SelectObject(dc, tip);
HGDIOBJ old_pen = SelectObject(dc, GetStockObject(NULL_PEN));
Polygon(dc, tri, 3);
SelectObject(dc, old_pen);
SelectObject(dc, old_brush);
DeleteObject(tip);
if (old_font != NULL) SelectObject(dc, old_font);
EndPaint(hwnd, &ps);
return 0;
}
return CallWindowProcA(gComboProc, hwnd, message, wParam, lParam);
}
void DestroyCombos(FEState *fe)
{
for (int g = 0; g < GroupCount; ++g)
{
if (fe->combo[g] != NULL)
{
DestroyWindow(fe->combo[g]);
fe->combo[g] = NULL;
}
}
}
// defined below, next to the catalogs it reads
const char *ItemName(int group, int index);
void LayoutMenu(FEState *fe, int client_w, int client_h)
{
fe->itemCount = 0;
gItemNameSelection = fe->selection;
int row_h = client_h / 36;
if (row_h < 18) row_h = 18;
if (row_h > 30) row_h = 30;
int col_w = client_w / 5;
int col1 = client_w / 14;
int col2 = col1 + col_w + client_w / 28;
int col3 = col2 + col_w + client_w / 28;
int top = client_h / 7;
//---------------------------------------------------------------
// Every list is a drop-down, so the menu no longer grows with the
// content: eight controls instead of ninety-odd rows. Two columns
// of label-over-box, settings on the left and loadout on the right,
// with the scenario left as visible buttons because it decides what
// the other lists contain.
//---------------------------------------------------------------
int margin = client_w / 14;
int gap = client_w / 20;
int col_w = (client_w - 2 * margin - gap) / 2;
if (col_w < 1) col_w = 1;
int col2 = margin + col_w + gap;
int block_h = row_h * 5 / 2; // label, box, breathing room
// scenario: buttons, top of the left column
int y = top;
AddGroupItems(fe, GroupScenario, FE_COUNT(kScenarios), col1, &y, row_h, col_w);
int map_count;
ActiveMaps(fe->selection, &map_count);
AddGroupItems(fe, GroupMap, map_count, col1, &y, row_h, col_w);
AddGroupItems(fe, GroupTime, FE_COUNT(kTimes), col1, &y, row_h, col_w);
AddGroupItems(fe, GroupWeather, FE_COUNT(kWeather), col1, &y, row_h, col_w);
AddGroupItems(fe, GroupLength, FE_COUNT(kLengths), col1, &y, row_h, col_w);
y = top;
AddGroupItems(fe, GroupVehicle, FE_COUNT(kVehicles), col2, &y, row_h, col_w);
y = top + 2 * row_h; // leave room for the name edit + header
if (IsFootball(fe->selection))
fe->comboLabel[GroupScenario].left = margin;
fe->comboLabel[GroupScenario].right = margin + col_w;
fe->comboLabel[GroupScenario].top = y;
fe->comboLabel[GroupScenario].bottom = y + row_h;
y += row_h;
for (int i = 0; i < FE_COUNT(kScenarios); ++i)
{
AddGroupItems(fe, GroupTeam, FE_COUNT(kTeams), col3, &y, row_h, col_w);
AddGroupItems(fe, GroupPosition, FE_COUNT(kPositions), col3, &y, row_h, col_w);
FEItem *item = &fe->items[fe->itemCount++];
item->group = GroupScenario;
item->index = i;
item->rect.left = margin + i * (col_w / 2);
item->rect.top = y;
item->rect.right = item->rect.left + col_w / 2 - row_h / 3;
item->rect.bottom = y + row_h;
}
else
y += row_h + row_h / 2;
int groups[GroupCount];
int group_count = ComboGroups(fe->selection, groups);
// left column takes the mission settings, right the loadout; the
// split is where the vehicle list starts. The pilot name heads
// the loadout column - it is who you are, so it reads before
// what you are driving.
int left_y = y;
int right_y = top;
int name_y = right_y;
right_y += block_h;
for (int g = 0; g < group_count; ++g)
{
AddGroupItems(fe, GroupColor, FE_COUNT(kColors), col3, &y, row_h, col_w);
AddGroupItems(fe, GroupBadge, FE_COUNT(kBadges), col3, &y, row_h, col_w);
int group = groups[g];
Logical left = (group == GroupMap || group == GroupTime ||
group == GroupWeather || group == GroupLength);
int x = left ? margin : col2;
int *slot = left ? &left_y : &right_y;
fe->comboLabel[group].left = x;
fe->comboLabel[group].right = x + col_w;
fe->comboLabel[group].top = *slot;
fe->comboLabel[group].bottom = *slot + row_h;
*slot += block_h;
}
// launch button
FEItem *launch = &fe->items[fe->itemCount++];
launch->group = GroupLaunch;
launch->index = 0;
launch->rect.left = col3;
launch->rect.left = col2;
launch->rect.top = client_h - 3 * row_h;
launch->rect.right = col3 + col_w;
launch->rect.right = col2 + col_w;
launch->rect.bottom = client_h - row_h;
// Steam lobby buttons, offered whenever environ.ini asked for Steam.
@@ -656,17 +832,17 @@ namespace
FEItem *host = &fe->items[fe->itemCount++];
host->group = GroupSteamHost;
host->index = 0;
host->rect.left = col3;
host->rect.left = col2;
host->rect.top = client_h - 6 * row_h;
host->rect.right = col3 + col_w;
host->rect.right = col2 + col_w;
host->rect.bottom = client_h - 5 * row_h;
FEItem *join = &fe->items[fe->itemCount++];
join->group = GroupSteamJoin;
join->index = 0;
join->rect.left = col3;
join->rect.left = col2;
join->rect.top = client_h - (9 * row_h) / 2;
join->rect.right = col3 + col_w;
join->rect.right = col2 + col_w;
join->rect.bottom = client_h - (7 * row_h) / 2;
}
@@ -682,16 +858,67 @@ namespace
FEItem *quit = &fe->items[fe->itemCount++];
quit->group = GroupExit;
quit->index = 0;
quit->rect.left = col1;
quit->rect.left = margin;
quit->rect.top = client_h - 2 * row_h;
quit->rect.right = col1 + col_w / 2;
quit->rect.right = margin + col_w / 2;
quit->rect.bottom = client_h - row_h;
// pilot name edit sits at the top of column 3
// pilot name, under the loadout column
fe->comboLabel[GroupLaunch].left = col2; // reused: name label
fe->comboLabel[GroupLaunch].right = col2 + col_w;
fe->comboLabel[GroupLaunch].top = name_y;
fe->comboLabel[GroupLaunch].bottom = name_y + row_h;
if (fe->nameEdit != NULL)
{
MoveWindow(fe->nameEdit,
col3, top - row_h / 4, col_w, row_h, TRUE);
MoveWindow(fe->nameEdit, col2, name_y + row_h, col_w, row_h, TRUE);
}
//---------------------------------------------------------------
// The drop-downs themselves. Rebuilt rather than moved, because
// the scenario swaps two of them outright (team/position for
// colour/badge) and reshuffles the track list.
//---------------------------------------------------------------
DestroyCombos(fe);
for (int g = 0; g < group_count; ++g)
{
int group = groups[g];
const RECT &label = fe->comboLabel[group];
int count = GroupSize(group, fe->selection);
// The height given at creation is the DROPPED height - what the
// closed box shows is the item height - so ask for enough to
// show a dozen rows without a scrollbar where the list is short.
int drop_rows = (count < 12) ? count : 12;
HWND box = CreateWindowExA(
0, "COMBOBOX", "",
WS_CHILD | WS_VISIBLE | WS_VSCROLL |
CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS,
label.left, label.top + row_h,
label.right - label.left, row_h + drop_rows * row_h,
fe->menuWindow, (HMENU)(INT_PTR)(kComboIdBase + group),
(HINSTANCE) GetWindowLongPtr(fe->menuWindow, GWLP_HINSTANCE), NULL);
if (box == NULL)
{
continue;
}
SendMessageA(box, WM_SETFONT, (WPARAM) fe->textFont, TRUE);
SendMessageA(box, CB_SETITEMHEIGHT, (WPARAM) -1, row_h);
SendMessageA(box, CB_SETITEMHEIGHT, 0, row_h);
// paint the closed box ourselves - see ComboSubclassProc
WNDPROC previous = (WNDPROC) SetWindowLongPtrA(
box, GWLP_WNDPROC, (LONG_PTR) ComboSubclassProc);
if (gComboProc == NULL)
{
gComboProc = previous;
}
for (int i = 0; i < count; ++i)
{
SendMessageA(box, CB_ADDSTRING, 0, (LPARAM) ItemName(group, i));
}
if (fe->selection[group] >= count) fe->selection[group] = 0;
SendMessageA(box, CB_SETCURSEL, fe->selection[group], 0);
fe->combo[group] = box;
}
}
@@ -714,7 +941,6 @@ namespace
}
// the map rows need the active scenario's list
const int *gItemNameSelection = NULL;
const char *ItemName(int group, int index)
{
@@ -776,21 +1002,31 @@ namespace
SelectObject(mem, fe->textFont);
// group headers (drawn above each group's first item)
int previous_group = -1;
// Labels: one above each drop-down, one above the scenario
// buttons, one above the pilot name box. The drop-downs draw
// themselves (WM_DRAWITEM), so all that is left here is their
// captions.
SetTextColor(mem, kGreenBright);
for (int g = 0; g < GroupCount; ++g)
{
Logical labelled = (g == GroupScenario) || (fe->combo[g] != NULL);
if (!labelled && g != GroupLaunch)
{
continue;
}
RECT label = fe->comboLabel[g];
if (label.right <= label.left)
{
continue;
}
const char *caption = (g == GroupLaunch) ? "PILOT NAME" : GroupTitle(g);
DrawTextA(mem, caption, -1, &label,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
for (int i = 0; i < fe->itemCount; ++i)
{
const FEItem *item = &fe->items[i];
if (item->group != previous_group && item->group < GroupLaunch)
{
previous_group = item->group;
RECT header = item->rect;
header.top -= (item->rect.bottom - item->rect.top);
header.bottom = item->rect.top;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, GroupTitle(item->group), -1, &header,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
Logical selected =
(item->group >= GroupLaunch) ||
@@ -848,26 +1084,14 @@ namespace
}
RECT text = row;
text.left += 6;
// On a narrow window the longest names (Screaming Broccoli,
// Blacker Tarantula) outrun their column. Ellipsis rather than
// a glyph sliced down the middle.
DrawTextA(mem, ItemName(item->group, item->index), -1, &text,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
}
// pilot-name header
if (fe->nameEdit != NULL)
{
RECT edit_rect;
GetWindowRect(fe->nameEdit, &edit_rect);
POINT corner = { edit_rect.left, edit_rect.top };
ScreenToClient(fe->menuWindow, &corner);
RECT header;
header.left = corner.x;
header.right = corner.x + 300;
header.bottom = corner.y;
header.top = corner.y - 26;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, "PILOT NAME", -1, &header,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
// (the pilot-name caption is drawn with the other labels above)
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
@@ -954,6 +1178,65 @@ namespace
}
break;
case WM_COMMAND:
if (fe != NULL && HIWORD(wParam) == CBN_SELCHANGE)
{
int group = LOWORD(wParam) - kComboIdBase;
if (group >= 0 && group < GroupCount && fe->combo[group] != NULL)
{
int pick = (int) SendMessageA(fe->combo[group], CB_GETCURSEL, 0, 0);
if (pick >= 0)
{
fe->selection[group] = pick;
}
// the closed box is ours to redraw now
InvalidateRect(fe->combo[group], NULL, FALSE);
InvalidateRect(fe->menuWindow, NULL, FALSE);
return 0;
}
}
break;
//
// The drop-downs are owner-drawn so they read as part of the
// panel rather than as system widgets: green on black, and the
// highlight inverted rather than tinted.
//
case WM_DRAWITEM:
if (fe != NULL)
{
DRAWITEMSTRUCT *di = (DRAWITEMSTRUCT *) lParam;
if (di->CtlType == ODT_COMBOBOX && (int) di->itemID >= 0)
{
Logical hot =
((di->itemState & (ODS_SELECTED | ODS_COMBOBOXEDIT)) == ODS_SELECTED);
HBRUSH back = CreateSolidBrush(hot ? kGreenDim : kBlack);
FillRect(di->hDC, &di->rcItem, back);
DeleteObject(back);
char text[128];
text[0] = '\0';
SendMessageA(di->hwndItem, CB_GETLBTEXT, di->itemID, (LPARAM) text);
RECT label = di->rcItem;
label.left += 6;
SetBkMode(di->hDC, TRANSPARENT);
SetTextColor(di->hDC, hot ? kBlack : kGreenBright);
DrawTextA(di->hDC, text, -1, &label,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
return TRUE;
}
}
break;
case WM_CTLCOLORLISTBOX:
if (fe != NULL)
{
SetTextColor((HDC) wParam, kGreenBright);
SetBkColor((HDC) wParam, kBlack);
return (LRESULT) fe->editBrush;
}
break;
case WM_CTLCOLOREDIT:
if (fe != NULL)
{
@@ -1483,6 +1766,14 @@ Logical
// LobbyRoomLeft: fall through to the setup menu
}
// NOTE: the vehicle catalog below is hand-written while RPL4.RES is
// built elsewhere, so the two can drift - this menu offered 'blkspk'
// for a while before any vehicle resource backed it. Validating here
// does NOT work: the front end runs before the resource file is
// opened, so GetResourceFile() has nothing to search yet. If this is
// worth guarding, do it offline against the built RPL4.RES (see
// tools/resbuild) rather than at menu time.
for (;;)
{
FEState fe;
+241 -45
View File
@@ -1,4 +1,5 @@
#include "rpl4.h"
#pragma hdrstop
#define PRELOAD_ART
@@ -1951,7 +1952,75 @@ GPS::GPS(
//-----------------------------------------------------------
background = new Video8BitBuffered(width, height);
Register_Object(background);
//
//-----------------------------------------------------------------
// RP412MAPRATE - how many of the sixteen rate steps the map redraws
// on.
//
// The gauge renderer walks a 16-bit rate wheel: one bit per full
// pass over every active gauge, shifted right each pass and reset at
// the bottom (GAUGREND.cpp). A gauge redraws only on the step its
// configured rate names, so a map on one bit redraws once per
// SIXTEEN passes - and its update period is sixteen passes however
// cheap the redraw is.
//
// That was fine when a pass was quick. Racing, the background loop
// only gets what is left of the frame after the 3D, passes fall to
// about five a second, and sixteen of them is over three seconds
// between map updates - measured, on a 60 fps display with the 3D
// perfectly smooth. Nothing is slow here; the map is just waiting
// its turn on a wheel built for a machine that came round faster.
//
// Drawing on more of the steps costs one gauge's redraw per step,
// against a pass that runs ninety of them. It does not make the
// wheel turn faster - it stops the map needing a whole turn.
//-----------------------------------------------------------------
//
{
static int updates = -1;
if (updates < 0)
{
const char *setting = getenv("RP412MAPRATE");
updates = (setting != NULL) ? atoi(setting) : 16;
// 1..16, and only the powers of two divide the wheel evenly
if (updates > 16) updates = 16;
if (updates < 1) updates = 1;
}
if (updates > 1)
{
GaugeRate mask = 0;
for (int step = 0; step < 16; step += (16 / updates))
{
mask |= (GaugeRate)(0x8000 >> step);
}
//
// QUALIFIED, both of them. This constructor's first
// parameter is also called 'rate', so a bare assignment
// here writes the PARAMETER and leaves the member holding
// whatever the gauge data asked for - which is what it did,
// silently, and cost a long hunt for a writer that did not
// exist. oldRate is not shadowed, which is why it took the
// value and the pair disagreed.
//
// Gauge::Disable(False) restores rate from oldRate when the
// mode system enables a gauge, so both have to carry it or
// the first activation puts the old rate back.
//
Gauge::rate = mask;
Gauge::oldRate = mask;
}
}
needsStaticUpdate = True;
//
// Nothing drawn yet, so there is no picture to add a placement to and
// no scale it would be added at. The first pass builds both.
//
backgroundBuilt = False;
builtMinX = builtMinY = builtMinZ = (Scalar) 0;
builtMaxX = builtMaxY = builtMaxZ = (Scalar) 0;
Check_Fpu();
}
@@ -1989,6 +2058,95 @@ void
void
GPS::NotifyOfNewInterestingEntity(Entity *entity)
{
Check(this);
Check(entity);
if (!entity->IsDerivedFrom(*Terrain::GetClassDerivations()))
{
Check_Fpu();
return;
}
//
// A rebuild is already owed, or there is no picture to add to yet.
//
if (needsStaticUpdate || !backgroundBuilt)
{
needsStaticUpdate = True;
Check_Fpu();
return;
}
//
// Terrain arrives as you DRIVE - the interest system hands each piece
// over as it comes into range, not all of it at load - and this used
// to order a rebuild of the entire map for every one of them. A
// rebuild redraws every placement on the track, so the cost of one
// arriving piece was the whole track, and the map went seconds
// between updates on the tracks that draw the most: Tour De Mars at
// 351 placements and Ares' Armpits at 320, against 80-140 for a
// typical one. It is also not interruptible - the gauge loop checks
// its time slice BETWEEN gauges - so a rebuild stalled every other
// display with it.
//
// But the bounds are what set the scale, and the scale is what the
// whole cached picture was drawn at. An arrival that leaves the
// bounds alone leaves every placement already on the map exactly
// where it belongs, and the only thing missing from the picture is
// the new one. Draw that, and nothing else.
//
// Only a piece that moves an EDGE of the track changes the scale, and
// then the picture really is wrong everywhere and has to be redrawn.
// That is rare, and it gets rarer as the track fills in.
//
// RPL4GaugeRenderer::NotifyOfNewInterestingEntity adds the entity to
// staticEntities before chaining here, so these bounds already
// account for the arrival being judged.
//
Scalar
minX, minY, minZ,
maxX, maxY, maxZ;
Check(renderer);
renderer->GetStaticBounds(
&minX, &minY, &minZ,
&maxX, &maxY, &maxZ
);
if (minX != builtMinX || minY != builtMinY || minZ != builtMinZ ||
maxX != builtMaxX || maxY != builtMaxY || maxZ != builtMaxZ)
{
needsStaticUpdate = True;
}
else if (DrawStaticEntity(entity))
{
//
// Only when something was actually drawn. Terrain without a
// GaugeImage never reaches the map - on these tracks that is
// most of it, 256 of Tour De Mars' 607 - and blitting the
// background again for one of those is pure cost.
//
needsScreenUpdate = True;
}
Check_Fpu();
}
//
//#############################################################################
// NotifyOfBecomingUninterestingEntity
//#############################################################################
//
// The map showed only currently-interesting terrain before any of this,
// and it still does. A placement cannot be un-drawn from a composited
// picture, so a departure is the one case left that costs a full rebuild.
//
// Nothing used to listen for this at all: departures were swept up by the
// rebuild that the very next ARRIVAL ordered, which is no longer ordered.
// Without this the map would keep showing terrain that had gone until
// something moved the bounds.
//
void
GPS::NotifyOfBecomingUninterestingEntity(Entity *entity)
{
Check(this);
Check(entity);
@@ -2104,61 +2262,34 @@ void
ChainIteratorOf<Entity*>
i(staticEntityList.GetInstanceList());
Check(renderer);
L4Warehouse
*warehouse = (L4Warehouse *) renderer->warehousePointer;
Check(warehouse);
Entity
*entity;
L4GaugeImage
*gauge_image;
AffineMatrix
worldToView,
localToView;
Vector3D
scaling_vector;
//------------------------------------
// Scale the display
//------------------------------------
Verify(!Small_Enough(pixelsPerMeter));
scaling_vector.x = pixelsPerMeter;
scaling_vector.y = pixelsPerMeter;
scaling_vector.z = pixelsPerMeter;
worldToView.BuildIdentity();
worldToView *= centeringOffset; // translation
worldToView *= scaling_vector;
int
drawn = 0;
while ((entity=i.ReadAndNext()) != NULL)
{
Check(entity);
//-------------------------------------
// Draw image
//-------------------------------------
gauge_image = warehouse->
gaugeImageBin.GetIfAlreadyExists(entity->GetResourceID());
if (gauge_image != NULL)
{
Check(gauge_image);
localToView.Multiply(entity->localToWorld, worldToView);
gauge_image->Draw(
LODIndex, // value set by creator
metersPerPixel,
&backgroundView,
0, // default color
(AffineMatrix &) localToView
);
warehouse->gaugeImageBin.Release(entity->GetResourceID());
}
DrawStaticEntity(entity);
}
//
// The picture now matches these bounds, and they are what the scale
// everything on it was drawn at came from. Remember them: an arrival
// that leaves them alone can be added to this picture rather than
// replacing it.
//
builtMinX = minX;
builtMinY = minY;
builtMinZ = minZ;
builtMaxX = maxX;
builtMaxY = maxY;
builtMaxZ = maxZ;
backgroundBuilt = True;
//-----------------------------------------------------------
// Redraw new background, restart moving entity display
//-----------------------------------------------------------
@@ -2166,6 +2297,71 @@ void
Check_Fpu();
}
//
//#############################################################################
// DrawStaticEntity
//#############################################################################
//
// One placement onto the cached background, at the scale that background
// was built at. Shared by the full rebuild above and by the single-arrival
// path in NotifyOfNewInterestingEntity, so the two cannot drift into
// drawing the same track two different ways.
//
Logical
GPS::DrawStaticEntity(Entity *entity)
{
Check(this);
Check(entity);
Check(background);
Check(renderer);
L4Warehouse
*warehouse = (L4Warehouse *) renderer->warehousePointer;
Check(warehouse);
L4GaugeImage
*gauge_image =
warehouse->gaugeImageBin.GetIfAlreadyExists(entity->GetResourceID());
if (gauge_image == NULL)
{
return False; // nothing of it appears on the map
}
Check(gauge_image);
L4BytePort
backgroundPort(background, "background", 0);
GraphicsView
backgroundView(&backgroundPort);
backgroundView.SetOrigin(width>>1, height>>1);
Vector3D
scaling_vector;
scaling_vector.x = pixelsPerMeter;
scaling_vector.y = pixelsPerMeter;
scaling_vector.z = pixelsPerMeter;
AffineMatrix
worldToView,
localToView;
worldToView.BuildIdentity();
worldToView *= centeringOffset; // translation
worldToView *= scaling_vector;
localToView.Multiply(entity->localToWorld, worldToView);
gauge_image->Draw(
LODIndex, // value set by creator
metersPerPixel,
&backgroundView,
0, // default color
(AffineMatrix &) localToView
);
warehouse->gaugeImageBin.Release(entity->GetResourceID());
return True;
}
void
GPS::Execute()
+27 -1
View File
@@ -330,12 +330,27 @@ public:
BecameActive(); // virtual function in 'GaugeBase'
void
NotifyOfNewInterestingEntity(Entity *entity);
//
// Terrain that leaves interest range is dropped from the map, and
// there is no way to un-draw one placement from a composited picture
// - so this is the one case that still costs a full rebuild.
//
void
NotifyOfBecomingUninterestingEntity(Entity *entity);
void
Execute();
protected:
void
UpdateStaticEntities();
//
// Draw one placement into the cached background, at the scale that
// background was built at. False if the entity has no GaugeImage and
// so never appears on the map at all - which on the big tracks is
// most of the terrain in them.
//
Logical
DrawStaticEntity(Entity *entity);
enum
{
@@ -344,7 +359,18 @@ protected:
Logical
needsStaticUpdate,
needsScreenUpdate;
needsScreenUpdate,
// is there a picture worth adding a single placement to?
backgroundBuilt;
//
// The static bounds the background was last built for. They decide
// the scale, so terrain arriving inside them can be drawn onto the
// picture instead of forcing a new one - see
// GPS::NotifyOfNewInterestingEntity.
//
Scalar
builtMinX, builtMinY, builtMinZ,
builtMaxX, builtMaxY, builtMaxZ;
Scalar
LODIndex,
metersPerPixel,
+50 -1
View File
@@ -51,6 +51,20 @@ namespace
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
//-------------------------------------------------------------------
// Simulation protocol revision. Bump this whenever a change makes
// two builds simulate the same mission differently - it is not the
// wire format alone. Map entity ownership is dealt by advancing a
// shared cursor once per map entity, so anything that changes which
// entities are dealt at all silently desynchronizes who owns what.
//
// 2 - doorframes became local Hermit clockwork and are no longer
// dealt, which shifts every subsequent map entity's owner
// 1 - the 3-machine verified Steam build
//-------------------------------------------------------------------
const char kNetRevision[] = "2";
const char kNetRevKey[] = "nr";
// the owner's mission setup, shown to everyone in the room
const char kMapKey[] = "mp";
const char kTimeKey[] = "td";
@@ -164,6 +178,9 @@ namespace
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps",
RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex()));
// what this build simulates like, so a mismatched room cannot launch
SteamMatchmaking()->SetLobbyMemberData(gLobby, kNetRevKey, kNetRevision);
//---------------------------------------------------------------
// Only the owner's menu decides the mission, so the owner also
// publishes what it picked: the scenario (members need it to know
@@ -172,6 +189,8 @@ namespace
//---------------------------------------------------------------
if (IsOwner())
{
// members check this before they act on the owner's go
SteamMatchmaking()->SetLobbyData(gLobby, kNetRevKey, kNetRevision);
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
SteamMatchmaking()->SetLobbyData(gLobby, kMapKey,
@@ -226,6 +245,7 @@ namespace
char badge[24];
char team[32]; // football pick
char position[16];
char netRev[8]; // simulation protocol revision
Logical published;
};
@@ -266,6 +286,9 @@ namespace
strncpy(member->position,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"),
sizeof(member->position) - 1);
strncpy(member->netRev,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kNetRevKey),
sizeof(member->netRev) - 1);
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}
@@ -797,14 +820,22 @@ namespace
room.launchClicked = False;
room.memberCount = CollectMembers(room.members);
Logical all_published = True;
Logical all_same_build = True;
for (int i = 0; i < room.memberCount; ++i)
{
if (!room.members[i].published)
{
all_published = False;
}
if (strcmp(room.members[i].netRev, kNetRevision) != 0)
{
all_same_build = False;
DEBUG_STREAM << "Lobby: " << room.members[i].name
<< " simulates like rev '" << room.members[i].netRev
<< "', we are rev '" << kNetRevision << "'\n" << std::flush;
}
}
if (all_published && room.memberCount >= 1)
if (all_published && all_same_build && room.memberCount >= 1)
{
++gLastGoNonce;
char go[800];
@@ -834,6 +865,24 @@ namespace
//
if (!IsOwner())
{
//
// A room whose owner simulates differently than we do would
// desynchronize silently rather than fail, so sit the race out
// instead of flying into it.
//
const char *owner_rev = LobbyText(kNetRevKey);
if (owner_rev[0] != '\0' &&
strcmp(owner_rev, kNetRevision) != 0)
{
DEBUG_STREAM << "Lobby: owner simulates like rev '"
<< owner_rev << "', we are rev '" << kNetRevision
<< "' - not launching\n" << std::flush;
outcome = LobbyRoomLeft;
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
break;
}
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
if (go != NULL && go[0] != '\0')
{
+118 -56
View File
@@ -444,9 +444,18 @@ void
//----------------------------------------
// Notify of mode change
//----------------------------------------
//
// Unqualified, so the platform's override is the one that runs. The
// RIO carries the four mode lamps on the Upper Right MFD and lights
// them from here (VTVRIOMapper::NotifyOfControlModeChange); naming
// the class suppressed the virtual call and landed on the base's
// no-op instead, so the lamps never followed the mode the pilot had
// just selected. Its neighbour has always gone out this way - see
// VTVControlsMapper::SetConfigurationState.
//
if (previous_mode != controlMode)
{
L4VTVControlsMapper::NotifyOfControlModeChange(controlMode);
NotifyOfControlModeChange(controlMode);
}
Check_Fpu();
}
@@ -725,6 +734,13 @@ void
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
//-----------------------------------
previousPresetNumber = preset_number;
@@ -733,6 +749,19 @@ void
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 ##############################
//#############################################################################
@@ -854,18 +883,18 @@ void
//-------------------------------------------------------
// Set driving modes
//-------------------------------------------------------
case 'b':
case 'B': SetControlsMode(BasicMode); break;
case 's':
case 'S': SetControlsMode(StandardMode); break;
case 'v':
case 'V': SetControlsMode(VeteranMode); break;
case 'm':
case 'M': SetControlsMode(MasterMode); break;
//
// B / S / V / M used to drop straight into Basic, Standard,
// Veteran and Master here. The driving mode is a panel
// decision - the four buttons on the Upper Right MFD, with the
// lamps that say which one you are in - and a bare letter key
// changing it behind the player's back is not that. Worse in
// 4.12 than it ever was in the pod: the whole letter board is
// the MFD banks now, so those four letters are buttons in their
// own right and would have fired twice.
//
// Nothing replaces them. Press the mode you want.
//
//-------------------------------------------------------
// Configuration stuff
//-------------------------------------------------------
@@ -1400,45 +1429,13 @@ void
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
current_preset_number = (message->dataContents - 1)
- 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);
}
PresetEnable(
(message->dataContents - 1) - LBE4ControlsManager::ButtonSecondary7
);
}
Check_Fpu();
}
@@ -1626,6 +1623,18 @@ void
modeLamp[lamp_number]->SetState(L4Lamp::LampStateOn);
}
}
//----------------------------------
// Remember what is lit
//----------------------------------
//
// previousControlMode is the lamp the NEXT change dims, and nothing
// used to write it after construction set it to -1. Every mode
// therefore lit its own lamp against a dim that matched nothing, and
// the panel accumulated lamps instead of following the selection.
//
previousControlMode = controlMode;
//-----------------------------------
// Invoke ancestral method
//-----------------------------------
@@ -1655,6 +1664,44 @@ void
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
//
@@ -1680,6 +1727,20 @@ VTVRIOMapper::VTVRIOMapper(
leftPedal = 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);
LBE4ControlsManager
*controls = Cast_Object(
@@ -1915,13 +1976,14 @@ VTVRIOMapper::VTVRIOMapper(
this
);
// These lamps are explicitly controlled by SelectPresetMessageHandler
modeLamp[i] = CreateControlledLamp(button_number[i]);
// These lamps are explicitly controlled by NotifyOfPresetChange.
// 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]);
modeLamp[i]->SetState(
Check(presetLamp[i]);
presetLamp[i]->SetState(
(i==0)? L4Lamp::LampStateOn : L4Lamp::LampStateDim
);
}
+9
View File
@@ -104,6 +104,12 @@ ModeMask
//
void
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
//
@@ -250,6 +256,9 @@ public:
void
NotifyOfConfigurationModeChange(Logical new_state);
void
NotifyOfPresetChange(int old_preset, int new_preset);
void
SetPerformance(Performance performance)
{
+350 -383
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.

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