97 Commits
Author SHA1 Message Date
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
CydandClaude Opus 5 eb17220dd5 Test builds go stale after a fortnight
A tester still racing a two-week-old binary reports things that were
fixed a week ago, and the afternoon spent chasing them is gone. An
expired build now says so and stops: a dialog naming its version and
expiry date, pointing at the releases page, and an exit before anything
else runs. The log carries the same line, so a report from an expired
build identifies itself.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    sample = ourNow - theirStamp = trueOffset + oneWayLatency

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things the wiring turned up:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:27:19 -05:00
CydandClaude Opus 5 52da65bc0d Release 4.12.7
Flight sticks, HOTAS throttles, twist grips and rudder pedals, none of
which the game could see before: they arrive through DirectInput rather
than XInput, and PadRIO only read XInput.

joyconfig.bat is the setup: the wizard asks you to move each control in
turn and derives the sign convention from the direction of the move,
then writes the joystick rows of bindings.txt between marker lines,
leaving anything you have edited yourself alone.

Confirmed on the Logitech Extreme 3D: a full pass wrote all four axes,
six buttons and the hat, with X and the throttle lever inverted to match
the pod's convention and Y left alone - and the deadzone on the twist
grip was then hand-tuned from 0.08 to 0.18 in the file, which is the
workflow the marker section exists for.

Version strings bumped in RPL4.CPP, pack-dist.ps1 and the controls page
that ships in the zip.

Built clean, packed, zipped (1004 entries, nothing loose at the root)
and smoke-tested from the dist: boots reporting 4.12.7, virtual RIO up,
and no DirectInput enumeration at all on a default bindings.txt - the
joystick layer only opens when the profile asks for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:18:43 -05:00
CydandClaude Opus 5 91420b5cb2 Flight sticks, HOTAS and pedals, with a setup wizard
Ported from BT411, which needed the same thing for its glass cockpit.

PadRIO reads XInput, which covers Xbox-class pads and nothing else. A
flight stick, a HOTAS throttle, a twist grip, rudder pedals or a wheel
arrive through DirectInput instead, and until now the game could not see
any of them - the only generic-joystick path left was the 1995 single-
device DIJoystick behind L4CONTROLS=DIJOYSTICK, which is untouched here.

L4JOY is the reader: up to four devices as normalized state blocks, hot-
plug re-enumeration on the same ~3 s cadence PadRIO uses to look for a
pad, and a device lost mid-race zeroed rather than left holding whatever
was pressed when it went. XInput-class devices are excluded by VID/PID
against the RawInput paths carrying the "IG_" marker - without that an
Xbox pad arrives through both APIs and every button counts twice.

bindings.txt gains four rows in the grammar it already had, using its own
vocabulary (deadzone/rate) rather than BT411's:

  joydev <slot> [product-name substring]
  joyaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
  joybutton <n> button <addr> [toggle]
  joyhat <n> <up|down|left|right> button <addr>

Slots resolve to a live device every poll, by name substring or ordinal,
so unplugging and replugging does not rewrite anyone's file.

Two things the pod's shape forced that BT411 solved differently:

  Pedals - a signed composite axis that decomposes into the pod's two
  pedals, positive right and negative left. The pod has a pedal each
  side; a twist grip or rudder bar is one signed control, and pressing
  one or the other but never both is exactly what it wants to say. It
  is a channel name like any other, so a pad stick can drive the turn
  too.

  A joyaxis on Throttle with no rate is a real lever and OWNS the
  channel - full travel maps onto the 0..1 the pod runs on, instead of
  nudging the accumulator that a spring-centred pad stick has to use.

RP412JOYCONFIG=1 (joyconfig.bat) runs the capture wizard before the
console screen: it asks the player to move each control, and derives the
sign convention from the DIRECTION of the move. That is the point of it -
a stick that reads positive pushed right and one that reads negative are
equally common, and no amount of documentation gets a player to work out
which they own. It writes only its own section, between marker lines, so
hand-edited keyboard and pad rows survive re-running it.

The wizard also prints every axis at rest before it starts. A driver that
refuses the +-32767 range we ask for reports its own, and an axis then
sits hard over instead of near zero; seeing "X +1.00" on an untouched
stick is the difference between a five-minute fix and a bug report that
says it configured itself. Each capture reports the move it saw for the
same reason.

Verified on the Logitech Extreme 3D on this machine. Enumeration finds
it and excludes the Xbox pad, which still arrives separately through
XInput. Every row shape parses - 7 axes, 2 buttons, 4 hat directions -
and three deliberately malformed rows (a bad axis name, button 99, a
"sideways" hat) are each rejected by line number rather than silently
dropped. The wizard lists the device with its axes at rest reading
X +0.00 Y -0.01 RZ -0.04 SL0 +1.00, waits on the first prompt without
self-triggering, and with a hand on the stick captures X to steering,
Y to pitch, RZ to the pedals and SL0 to the throttle, inverting the ones
that read backwards.

Running the captures through to a written file needs a hand on the
stick, so that part is the machine's to confirm, not this build's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:06:30 -05:00
CydandClaude Opus 5 fd61316a40 Release 4.12.6
Seven commits since 4.12.5, all about where things sit on screen.

RP412MFDLAYOUT remembers window placement across the menu-race-menu
loop, in mfd_layout.cfg beside bindings.txt: the game window, the
exploded view's display panes, and the plasma glass. Append ,noframe to
a line to take that window's title bar and border off - a cockpit
filling a monitor edge to edge at a rect you chose, where -fit could
only do it by taking the whole screen.

That needed a way out of a window with no title bar, so the setup screen
carries an EXIT GAME button, bottom left and diagonally opposite LAUNCH.

The Steam host/join buttons dim and say STEAM NOT RUNNING rather than
disappearing - two buttons quietly missing reads as a broken build.

And the Winners Circle camera is framed off the award stand rather than
off whoever is standing on it. It had been averaging the filled spots,
which moved the shot with the head count: eight finishers put the eye 24
units closer to the stand and aimed it at the middle of the tiers
instead of at the winner. The framing constants are untouched, so the
shot everybody gets now is the one that was dialled in.

Version strings bumped in RPL4.CPP, pack-dist.ps1 and the controls page
that ships in the zip.

Built clean, packed, and smoke-tested from the dist: boots to the
console screen reporting 4.12.6, virtual RIO up, Steam transport up,
nothing alarming in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 09:15:41 -05:00
CydandClaude Opus 5 dce8818273 The plasma glass is placeable too
It is a draggable top-level window whenever L4PLASMA=SCREEN, which makes
it the last one still being placed fresh every launch. It joins
mfd_layout.cfg under "Plasma Display", the caption it already carries -
the same key-is-the-title rule the display panes follow.

Position only, like the panes: the glass is 128x32 at L4PLASMASCALE, so
its size is a setting rather than something to drag.

It registers and loads at the point it creates its window rather than
leaving that to SVGA16. The glass comes from the gauge renderer and the
panes from the video mode, and nothing guarantees which is built first;
loading in both places means whichever runs second simply re-applies a
placement the first already has. Its window procedure picks up the same
WM_EXITSIZEMOVE save the panes have, so a drag writes the file straight
away, and the destructor forgets the window before destroying it.

Verified by round trip in the exploded view: dragged to 640,880, the
file took "Plasma Display=640,880,528,167" alongside the panes and the
game window, and a fresh launch in load mode put it physically back at
640,880. The three load lines in the log - one window, then two, then
eight - are the ordering doing its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:05:50 -05:00
CydandClaude Opus 5 a1f9c0e3c0 Winners Circle framed off the stand, not off who is on it
The camera was built from the spots that actually got filled: the first
filled one for the front, the average of the filled ones for the centre.
So it moved with the finishing order and the head count, and would move
between machines if a remote player's vehicle was not there to place.

Measured on Wiseguy's Wake, where win1 sits at (1199.84, 3, 2.92) and
the eight spots run back to z~38 on the high tier:

  one finisher    eye 1199.84,15,-33.08   aim z 2.92
  eight finishers eye 1199.82,18.44,-9.03 aim z 26.97

Twenty-four units closer to the stand and looking at the middle of the
tiers instead of at the winner - a different photograph of the same
podium depending on how many people showed up.

The stand is fixed furniture on every map, so the shot comes off the
geometry now. The eight dropzones are read once, up front, before
anybody is placed; win1 anchors the framing and the axis from the back
rows out through win1 gives the facing, so a map that mounts its stand
at another angle is still photographed from the front rather than
relying on the old (0,0,-1) fallback. Placement then runs as its own
pass and is the only thing that cares who finished - the log reports
"N placed on M spots" precisely so a changing N beside unchanged camera
numbers is visible.

The framing constants are untouched and so is the shot they produce:
the new numbers for a single finisher are eye 1199.77,15,-33.08 aim
1199.84,5,2.92, which is the old single-finisher shot to within 0.07 in
x. That is the case the standoff/height/aim defaults were dialled in
against, so the approved photograph is what everybody gets now instead
of what one person got.

Verified by running a race to the podium: the eight spot positions log
as expected, the camera numbers match the calculation, and the frame is
the same one as before - rank 1 centred with its callsign, 2 and 3
flanking on the low tier, 4-8 across the high tier behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:05:30 -05:00
CydandClaude Opus 5 bfd5fa163e EXIT GAME on the console screen
,noframe takes the title bar away, and with it the only way out of the
game. The console screen now offers its own, bottom left: half width and
diagonally opposite LAUNCH GAME, because it is the one button on that
screen you cannot undo and it should not sit next to the one everybody
is aiming for.

It goes through the same door as closing the window - fe.closed, so
RPL4FrontEnd_Run returns False and the race loop breaks - rather than
opening a second shutdown path.

Two things had to move for it to make sense.

The saved placement now loads in RPL4.CPP, right after the main window
is shown, instead of only when SVGA16 builds the cockpit. That was not
until a mission started, so the console screen came up at the default
rect with its title bar still on and the window only jumped to the saved
placement once a race began - which, for a flag whose whole purpose is
to take the title bar off, meant it did nothing on the screen you land
on. RPL4.CPP now owns the main window's registration outright and
SVGA16's branches only reload; the reload after CockpitShellProc goes on
still matters, since its WM_SIZE is what re-fits the canvas.

That in turn made the exploded view's position-only registration
incoherent - the startup load had already applied the size - so the
game window is simply position and size everywhere now. The earlier
reasoning that its exploded size IS the -res render size does not hold:
the back buffer stretches to the window in either view, exactly as it
does for the cockpit.

WM_EXITSIZEMOVE moves from the cockpit subclass to RPL4.CPP's own
WndProc, which the subclass chains to anyway. In its old home it only
existed once a cockpit had been built, so dragging the window on the
console screen - the obvious moment to put it where you want it - saved
nothing. There is also a save on the way out of WinMain, for a session
that never started a race and so never ran SVGA16's teardown save.

Verified: on a bare-framed window the console screen comes up at the
saved 1280x760 with client == window rect, EXIT GAME ends the process
with code 0, and a screenshot shows it clear of the column content. A
console-only session dragged to 333,222 900x640 wrote that on the drag,
kept it through the exit, and came back to exactly it on relaunch -
without a race anywhere in the round trip. The noframe and cockpit
round trips still pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:01:16 -05:00
CydandClaude Opus 5 a52fec80a9 mfd_layout.cfg: ,noframe takes a window's title bar off
Append it to any line - "RPL4=240,120,1000,620,noframe" - and that
window comes up with no caption and no border. For the game window that
is a cockpit filling a monitor edge to edge at a rect you chose, which
-fit could only do by taking the whole screen; for an exploded pane it
is a display photographed without chrome.

Per line rather than global, so the shell can go bare while the panes
keep their captions, or the other way round.

The flag is an instruction rather than something measured off the
window, so Save carries it back out - otherwise the first finished drag
would rewrite the file and quietly drop it. Windows are always built
framed and Load only ever strips, so deleting the flag is all it takes
to get the frame back; there is no un-strip path to get wrong.

A bare window's rect IS its client rect, so the client area is what
survives: a window that had a size in the file keeps it as the client,
and a position-only pane keeps whatever client it had. That also makes
the round trip stable - once bare, what Save records is already the
client, so load-save-load does not creep.

WS_SYSMENU stays on. It draws nothing without a caption, but without it
DefWindowProc will not honour Alt+F4, and a window with no title bar and
no way to close it is a trap. Nothing else can be dragged either, hence
the note in the file header and environ.ini: place it first, add the
flag after.

Verified in both views. Cockpit: the same 240,120 1000x620 line with and
without the flag, CAPTION|THICKFRAME and a 984x581 client becoming POPUP
with a 1000x620 one, and a screenshot showing the displays hard against
all four edges where the framed shot had them inside a letterbox. Save
mode with the flag set, nudged with WM_EXITSIZEMOVE, rewrote the line
with ",noframe" intact. Exploded: Map bare at 777,333 with its 500x640
client preserved while the shell beside it kept its caption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:21:54 -05:00
CydandClaude Opus 5 40b00ddde1 The game window remembers where you put it too
RP412MFDLAYOUT already kept the exploded view's display panes where they
were dragged. The main window is the one people move most, and it was
still being placed fresh every launch, so it joins them.

MFDSplitView_LoadLayout/SaveLayout become RPWindowLayout_Load/Save, with
Register/Forget taking any HWND rather than the module reaching into a
pane registry. Same file, same format, one more line in it.

What comes back depends on the window, so Register takes it as a flag:

  display panes    position only, as before. A pane's size follows its
                   content and its button banks, so an old size from a
                   different build must not distort it.
  the game window  position and size in the cockpit view. Nothing
                   derives that size - the cockpit fits itself to
                   whatever client area it is given - so a window sized
                   to suit a monitor should come back that way, and
                   half-restoring it would be the strange behaviour. In
                   the exploded view its size IS the render resolution
                   -res asked for, so there only the position returns.

Registered after the CockpitShellProc subclass is installed, on purpose:
the restore's WM_SIZE then runs LayoutCockpit again and the canvas
re-fits the restored client area. -fit does not register at all - it
owns the whole monitor, so there is no placement of the player's to
keep.

CockpitShellProc gained the WM_EXITSIZEMOVE hook the panes already had,
so dragging or resizing the shell writes the file immediately rather
than waiting for teardown.

Two hazards the panes were small enough to get away with and the game
window is not:

  - Save reads rcNormalPosition rather than GetWindowRect. A minimised
    window reports a nonsense rect and a maximised one reports the
    screen; since the file is rewritten whole, either would have
    replaced a good line with a useless one. rcNormalPosition is the
    restored placement whatever state the window is in.
  - Load drops any placement that intersects none of the monitors
    currently plugged in. Restoring the game window onto a display that
    is no longer there would leave nothing to drag back.

Verified by round trip in both views. Cockpit: dragged and resized to
240,120 1000x620, the file took it, a fresh launch in load mode came up
exactly there with a 984x581 client - and a screenshot confirms the
canvas re-fit it, displays at the corners and the map centred at the
bottom, nothing spilling. Exploded: the shell came back at 60,60 still
1280x720 from -res while Map came back at 777,333, which is the
size-flag split doing its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:00:33 -05:00
CydandClaude Fable 5 16ce4dfbea Exploded view remembers where you drag its windows
Ported from BT411's BT_GLASS_LAYOUT (29c502d).

The exploded view's panes are draggable desktop windows, but the
arrangement is recomputed on every launch, so dragging one somewhere
useful never survived the menu-race-menu loop.

RP412MFDLAYOUT persists it to mfd_layout.cfg beside bindings.txt:

  off / 0 / unset   computed arrangement only, no file (default)
  load / restore    restore saved positions at startup, never write
  save / adjust     restore, then rewrite on each finished drag
                    (WM_EXITSIZEMOVE) and on teardown

One "<title>=x,y,w,h" line per pane. Position is restored and the size
read and discarded: a pane's size follows its content and its button
banks, so letting an old size back in would misshape it after any
geometry change - and this port has changed that geometry twice already.

Load runs after the computed arrangement rather than instead of it, so a
pane the file does not mention simply keeps its computed spot. Only the
exploded view registers: the composited cockpit's panes are chrome-less
children with nothing to drag, so they have no position worth keeping.

RP412 needs no equivalent of BT411's "restored" flag. Its re-snap is
LayoutCockpit on WM_SIZE, which only runs in cockpit mode, so nothing
comes back later to overwrite a hand-placed window.

Verified by round trip: dragged Map to 777,333 in save mode, the file
took all six panes, and a fresh launch in load mode put it physically
back at 777,333. The harness also resized the window while moving it,
which incidentally proved the saved size really is ignored - the pane
came back correctly sized from a cfg that recorded 136x39.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:28:44 -05:00
CydandClaude Fable 5 2a23ec0923 Say when the Steam buttons are off rather than removing them
With RP412STEAM=1 but no Steam client, the lobby buttons were not drawn
at all - the menu simply had two fewer buttons than last time, with the
only explanation a line near the top of rpl4.log that scrolls past during
a normal startup. It reads as a broken build, and it cost someone a
puzzled look today.

The buttons are now laid out whenever environ.ini asks for Steam, and
greyed out when the wire did not come up, with STEAM NOT RUNNING above
them. Clicks on a greyed button do nothing - a dead control that still
fires would be worse than the silence it replaces.

Two conditions rather than one, so RPL4Lobby_Configured joins Available:
configured is the ini switch, available is whether the transport got a
FakeIP. Configured keeps the #ifdef in the lobby module, so a build
without the Steam SDK still shows no buttons at all rather than a pair
that can never work.

The notice does not fit inside a button - they are about sixteen
characters wide and "HOST STEAM GAME - STEAM NOT RUNNING" is more than
twice that, so appending it clipped mid-word. It goes on its own line
above the pair instead.

Verified both ways, with the Steam client up and with it unavailable:
bright and clickable, dim and inert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:12:23 -05:00
CydandClaude Fable 5 1efd5137d6 Release 4.12.5
Version string, zip name and README, plus the new environ.ini options -
the podium and the mission-length override. Everything else in that file
documents itself, so these should not be the exception.

This one restores the Winners Circle: the race fades out and fades back
in on the award platform, finishers in finishing order with their
callsigns on the plates, held for a few seconds before the results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:18:05 -05:00
CydandClaude Fable 5 b97dcce3a2 Pilot callsigns on the Winners Circle plates
The plates beside each spot came out blank. They ask for textures called
player1..player8, which are not files - the renderer draws each pilot's
callsign into a texture at run time - so the load failed and left them
untextured. Nothing bound the two together.

SortAndReloadNameBitmaps already builds those textures indexed by
finishing place, which is exactly how the plates are numbered, so the
plate beside each spot wants mNameTextures[place]. Binding them is the
whole fix, but it takes two steps rather than one.

The plates have to survive mesh consolidation first. Static geometry is
merged with D3DXConcatenateMeshes and its draw ops deduped by material -
and eight failed texture loads leave eight identical untextured ops, so
all eight plates collapse into one that could only ever carry a single
name. Each plate now gets a distinct 1x1 marker texture as it loads,
which keeps it a subset of its own. The marker is never seen.

Then the binding runs against the consolidated mesh, not the objects the
plates were loaded from - by podium time those have been merged away and
are no longer drawn, which is why re-pointing them changed nothing.

Verified on a race: 8 plates found in the consolidated mesh, 1 bound,
and the winner's plate reads their callsign under their vehicle. One
bound of eight is right for a single-pod race - the rest of the places
have nobody in them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:40:53 -05:00
CydandClaude Fable 5 f31c8401c7 No gunsight on the Winners Circle
The reticle was still drawn over the podium. It goes out in the 2D pass,
after everything the presentation turns off, so it survived. The race is
over and nothing is being aimed at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:27:07 -05:00
CydandClaude Fable 5 e1a3ef7cf1 Put the pilot's own vehicle on the Winners Circle
Your own vehicle is built insideEntity - a cockpit and no hull, because
you are sitting in it and never see it. That is fine for a race and wrong
for a podium: from the presentation camera your spot on the stand was the
one that was empty, and on a single-pod race that is the whole picture.

The renderer now gives the viewpoint entity an exterior before the shot.
Disconnected_Eye is the engine's own switch for this case, documented as
being there "so higher level renderers can fix the eye in one spot and
watch the viewpoint entity drive around", and it is what makes
NotifyOfNewInterestingEntity choose outsideEntity.

The exterior is added alongside what is already there rather than by
tearing the entity down and rebuilding it. Teardown-and-rebuild is the
path the interest manager uses constantly for scenery dropping out of
range, so it looked safe, but it is not safe for the viewpoint entity:
that one is never uninteresting, the eye renderable goes down with it,
and doing it mid-mission stops the scene rendering at all - the screen
went black from the moment the podium came up and never came back.
Adding the renderables directly does the same job with nothing removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:50:06 -05:00
CydandClaude Fable 5 858fb7fb42 Fade into the Winners Circle instead of cutting to it
The podium arrived as a hard cut: the race was still on screen one frame
and the stand was there the next. The race has its own fade-to-black
already, and it was being suppressed to keep the fade from blacking out
the podium behind it - which threw away the transition along with the
problem.

Now the two are sequenced. StopMission lets the race fade out as it
always did and posts the podium to itself for when that fade has landed
on black; the handler stands the finishers up behind the black and ramps
back in. The fade-in is the end-of-mission fade run backwards - the same
multiply on the fog colour and both fog distances, from nothing up to
what the Winners Circle asked for.

Timings: 0.7s of fade-out and black, then a 0.45s fade in. Both come out
of the 11 second hold, leaving about ten seconds of podium.
RP412PODIUMFADEIN sets the ramp.

Verified by measuring frame brightness across the transition. The race
falls away and the screen reaches black, then the stand comes up - and
with the ramp stretched to 3s to make it resolvable at a half-second
sampling interval, it climbs 71.8, 77, 78.7, 79.7, 80.5 rather than
stepping, so it is a real fade and not a cut arriving late.

The camera also comes down and tilts up across the tiers, which is how a
podium wants to be shot. It is a balance in both directions: drop it
further or tilt harder and the sky takes the top half while the winner's
spot slides off the bottom of the frame; tilt down instead and the shot
turns into a floor plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:18:51 -05:00
CydandClaude Fable 5 28df53aa31 Frame the Winners Circle for the shape it was built for
The stand was composed to be looked at from a 4:3 pod monitor. On a 16:9
canvas its platform runs out at the sides and the shot fills up with sky
and void, so the podium is now pillarboxed: the scene renders into a
centred 4:3 viewport with the surround left black.

The projection has to use the cropped shape too, or the scene comes out
squashed into the narrower viewport instead of cropped by it.
RP412PODIUMASPECT overrides the ratio, 0 turns it off.

The camera also came in closer, from 33 units rather than 45, and now
aims slightly below the group rather than above it. That tilt is what
buys back the sky above the grandstand - aiming above the group tips the
camera up instead and walks the winner's spot off the bottom of the
frame, which is the one position that has to be in shot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:05:45 -05:00
CydandClaude Fable 5 461dcfb6b9 Clear the cockpit glass for the Winners Circle
The six secondary displays sit over the viewscreen like the pod's bezels
and have nothing to say once the race is over. Worse, the radar sits dead
centre along the bottom edge - directly on top of the winner's spot, so
the one position that matters was the one you could not see.

They are hidden when the podium comes up, which uncovers the canvas the
3D is already being drawn on. No matching show: the mission is over by
then, and the next race builds a fresh cockpit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:53:29 -05:00
CydandClaude Fable 5 9389ec2003 Winners Circle: the award platform at the end of a race
The pod hall stood the finishers on a numbered platform when the race
ended. All of it shipped in this repo and none of it ever ran here: the
stand geometry, the eight ranked dropzones win1-win8 in every one of the
11 maps, and the sequence that places the racers on them. The sequence
lived on RPL4PlaybackApplication - the mission-review build - behind a
spool file, so the app the pods actually race has never called it.

RPL4Application now has its own StopMission handler that ranks the
finishers, drops each onto their spot, freezes them, re-sorts the name
plates into finishing order and frames a camera on the stand. It fires
once: StopMission arrives twice, from the console at the buzzer and again
from the player when the ending fade expires, and only the first is the
end of the race.

Ranking works in football as well as a race. CalcFootballRanking ranks
only the RunnerPlayers group, which would have placed the runners and
stopped - but nothing calls it. What runs is Player::CalcRanking, every
frame, over every scoring player by score.

Three pieces of the original had been stubbed out in the D3D9 port and
are restored:

  SetViewAngle was an empty function, so the 45 degrees the sequence asks
  for did nothing. It now rebuilds the projection the way DPLReadINIPage
  does and pushes it, and sets viewRatio, which nothing had written since
  the DPL body was commented out.

  winnersCircleFogStyle was an empty case. The stand sits far off the
  track in open ground where the track's own fog leaves it dark; this is
  the blue-violet the original used, with the fog pushed back to 100/1050
  and the clip plane pulled to 1100.

  The end-of-mission fade had to be told to stand down. It multiplies the
  fog colour and both fog distances toward zero every frame - correct when
  a race just ends, fatal to anything shown afterwards. That fade is what
  made the podium a black screen, and it took a while to find because
  every frame was being built and presented correctly the whole time.

The presentation camera overrides D3DTS_VIEW between the eye renderable
writing it and ExecuteImplementation reading it back for the draw calls,
so no CameraShip is needed. It builds with LookAt LH, not RH: the
projection is LH, and RH aims the camera the opposite way - ask to look
down at the stand and you get the sky behind you. The engine's own eye
renderable is right to use RH, because its forward and up come out of the
entity matrix already in that convention.

The mission is held open 11 seconds rather than 3. That fade timer is the
only thing keeping the simulation and the renderer alive once the race is
over, and it has no upper bound on the ending path.

Switches, all off-by-default behaviour aside: RP412PODIUM=0 skips it,
RP412PODIUMCAM=0 keeps the cockpit view, RP412PODIUMSTANDOFF/HEIGHT/AIM
frame the shot, RP412MISSIONSECONDS overrides the menu game length (the
shortest it offers is 3:00, a long wait when what you are testing is the
buzzer), and RP412RENDERDIAG=1 reports what a frame is made of.

Verified end to end on Wiseguy's Wake: the stand, its tiers, the blue 2
and 3, the red 4 through 8 and all eight name bays, held steady for the
full 11 seconds and then handing off to the results screen.

Known gaps: the name plates are blank, because the player1-8 textures are
runtime name bitmaps that do not resolve as files in this port, and your
own vehicle has no exterior model - you see the others, not yourself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:46:38 -05:00
CydandClaude Fable 5 3a24de03e6 Release 4.12.4
Version string, zip name and README. Cut as its own release rather than
replacing 4.12.3's asset again: the three test machines need to be able
to tell builds apart from what the log and the README say, and a binary
that reports 4.12.3 while carrying the lobby work defeats exactly that.

This one is about the lobby. Both rooms show the host's mission setup,
the football team sheet names each player's VTV, and a lobby holds eight
players rather than four.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:38:24 -05:00
CydandClaude Fable 5 e4025c2aec Lobbies hold eight, and the room lays itself out to fit them
The lobby capped at four, which is half a grid. The pod hall raced eight
and the egg builder already carries the owner plus maxExtraPilots, so
eight members fit with room to spare. CreateLobby now asks Steam for
eight and the roster arrays follow.

Checked what eight has to travel through before raising it: the go
roster is 43 bytes a member against an 800 byte buffer, the score sheet
41 against 600, and the pods list 21 against 512, so all three hold
eight with margin. The member side parses the go string with a cursor
rather than a fixed array, and RegisterRoster walks the count it is
given, so neither needed touching.

The layout is the part that could not just be renumbered. Rows were a
fixed sixteenth of the window, and eight of those ran off the bottom of
every window we support - the buttons are laid out upward from the
bottom edge while the roster runs down from the top, so the two meet in
the middle. The room now measures the band between the title and the
topmost button and sizes its rows to it, counted in half rows as

  setup lines + gap + eight rows + gap + hint

so the row height falls out of the space actually available. It never
grows past the old sixteenth, so a two player lobby does not get circus
sized rows, and when the band cannot give eight rows the room the font
needs, the block is drawn in a font that does fit rather than letting
rows overlap.

The minimum row is the font cell plus two, not the cell plus an eighth.
That sounds like a detail and is not: at 1920x1080 the generous version
missed by one pixel and paid for it by dropping the conditions line out
of every football lobby, which is the one thing in the room nobody can
see for themselves.

Verified live on hosted lobbies at 1904x1041, and modelled across every
window size from 3440x1440 down to 800x480 in race and football, host
and member. Race keeps both setup lines everywhere; football, which
carries four stacked buttons, keeps both down to 1080 and drops to one
below that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:17:33 -05:00
CydandClaude Fable 5 43cf086ea4 Show the host mission setup in both lobbies, and the VTV in football
The room told you who was in it and nothing about what you were about to
fly into. Only the owner's menu decides the mission, so nobody else could
see the track or the conditions until the race had already started.

The owner now publishes its picks as lobby data (track, time of day,
weather, game length) and the room draws them under the title: the track
on its own line, the conditions under it. Published as display names
rather than catalog keys, because the map list differs between race and
football and resolving it at the source saves the room from knowing
which table a key came from.

Football also names the VTV now. The team sheet was team and position
only, but the vehicle still decides how someone plays the position, and
it was already travelling as member data, just never drawn. Both roster
rows now run keys back through the catalogs, so nobody reads bttlbrg
where Battle Barge belongs, and the rows widened from the middle half to
two thirds to carry three fields.

The setup lines are only taken if they fit. Buttons are laid out upward
from the bottom edge while the roster runs down from the top, so on a
short window the two extra lines would have pushed the roster into MY
TEAM. The room measures the gap first and takes two lines, one, or none.
Checked against every window size from 3440x1440 down to 640x400 in both
scenarios.

Verified live on a hosted lobby. Race shows the track, the conditions
line, and Battle Barge / Red. Football shows the same setup with Battle
Barge, Blue / Aqua, Crusher, tracking the MY TEAM and MY POSITION
buttons as they cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:08:10 -05:00
CydandClaude Fable 5 abd720dd6e Ship the controls map as a page in the dist
The published artifact source now lives in docs/, and pack-dist emits it
as CONTROLS.html beside the plain-text CONTROLS.txt so players get the
diagrams without the repo.

The artifact source is a fragment - the publisher supplies the document
shell - so it is wrapped here with a doctype, charset and viewport.
Without those a browser opens it in quirks mode and renders the
typography as mojibake. Written without a BOM so the charset declaration
is the only thing speaking.

The map itself gains a section for the mouse: the button banks reach
under the glass, and the six displays are the players to size and the
radar to place. Verified the five radar-placement diagrams against the
layout they describe - box 16:9, radar 16.7x39.3% of it, centred flush
to the bottom edge, flush in either bottom corner, and 30/30 vertically
when halfway up a side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:39:17 -05:00
CydandClaude Fable 5 0c696c9952 Release 4.12.3
Version string, zip name and README. This one is about the screen:
the cockpit scales both ways and re-fits on resize keeping 16:9, -fit
runs it borderless over the monitor at a matched render size, the six
secondary displays are the players to scale and the radar to place,
and the button banks reach under the glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:19:10 -05:00
CydandClaude Fable 5 ea9491d2d5 Add the cockpit glass-sizing declarations missed by the last commit
L4VB16.h carries GlassSize and CockpitGlassSizes, which L4VB16.cpp uses
in both the constructor and LayoutCockpit - 8dc6605 left it behind (the
add named the path in the wrong case), so that commit does not build on
its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:05:24 -05:00
CydandClaude Fable 5 8dc6605a07 Cockpit: buttons under the glass, -fit, and player display layout
Button banks
  The exploded diagnostic view was still display-only - it predates the
  button work - so it now builds the same banks as the cockpit, with
  the pod arrangement laid out from the panes measured sizes rather
  than a hardcoded 640/480 grid (the banks make each window bigger than
  its glass, and the bottom row hung off the work area otherwise).

  The map side columns were spread height/6 from the top, but the maps
  own legend grid is not sixths: measured off the bitmap it starts 13
  rows down with six 102-tall cells on a 105 pitch. Every button sat
  high of its label, worst at the bottom. Each buttons top and bottom
  now come off that grid separately and are subtracted - scaling a
  height directly would let rounding drift them back out of step on a
  resized cockpit.

  Depth 100 to 240: against the 480 glass the two banks meet in the
  middle bar the strips, so practically the whole display is a press
  target. This mattered most in the cockpit, where the panes are small
  enough that the halfway clamp governs - at 100 the MFDs had a 110px
  dead band straight through the middle of the glass.

-fit (also spelled -windowed-fullscreen)
  Borderless over the whole monitor, with the render size chosen to
  match. The cockpit presents the 3D into a viewscreen that fills its
  canvas, so the right -res is that canvas at the scale the cockpit
  will settle on; computing it with identical arithmetic makes the
  stretch a copy. On the 3440x1440 panel that is 133% and -res 2553
  1436, against 125% for the windowed path that pays for the taskbar.

  The pick runs after the whole command line, so an explicit -res wins
  from either side of -fit. Capped at 3840x2160. Cockpit mode only -
  mode 0 has to stay playable on real pod hardware and mode 2 is a dev
  view - so those get the resolution and keep their windows.

Display layout, in environ.ini
  L4MFDSCALE sizes all five MFDs, L4MFDSCALE_UL and friends override
  any one of them, L4RADARSCALE the radar, and L4RADARPOS puts the
  radar bottom centre, in either bottom corner, or halfway up either
  side. Scaling is applied in canvas units before the canvas is fitted
  to the window, so a number means the same thing on every monitor.

  Sizing each display separately let the clamps become exact rather
  than one conservative rule for all five: what limits a display is its
  actual neighbour. Which neighbour that is depends on the radar, so
  the clamps follow it - on the bottom edge it clears the one MFD above
  its column, but centred on a side it has one above AND below and
  grows from the middle both ways, so it must clear the taller twice
  over. Clamping shrinks uniformly; these are photographs of real
  instruments and a one-axis clamp would squash them.

Verified on the ultrawide: all five radar positions, per-display and
group scaling with the clamps biting, -fit with and without an explicit
-res, and the button geometry measured back off the screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:05:02 -05:00
CydandClaude Fable 5 6b43971d2d Cockpit scales both ways and re-fits on resize, aspect locked
The canvas was capped at 100%, so a bigger monitor got a 1920x1080
cockpit in the corner of the screen and maximising did nothing - the
layout only ran once at startup.

Now the fit is one uniform scale with no ceiling, recomputed whenever
the window changes size (maximise, restore, drag), and the canvas is
centred in whatever client area it gets. Uniform scale is what locks
the aspect: a wider-than-16:9 desktop letterboxes with even black bars
instead of stretching the cockpit. Panes gained Resize() so the glass
and its button banks re-scale with the canvas; the pixel buffers keep
their native source resolution.

Scaling up is free quality on the MFDs - their glass is a downscale of
a native 640x480 channel until about 200%.

Verified on the 3440x1392 ultrawide: opens at 125% (2400x1350) instead
of 100%, maximises to 126% centred with even bars, a 1884x661 window
fits 61% letterboxed left/right, 1084x961 fits 56% letterboxed top/
bottom, and a 2584x1461 client scales up to 134%.

The 3D still renders at -res and D3D stretches it to the canvas, so
raise -res to match a large screen for 1:1 pixels; start-windowed.bat
and the README say so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:41:47 -05:00
CydandClaude Fable 5 b6045f3c94 Glass cockpit: radar columns reach under the map too
The amber Secondary/Screen columns get the same treatment as the red
MFD banks, turned on its side: each button reaches 100px in behind the
map with a 10px indicator clearing the edge, so the lamp reads as a
slim column and the radar picture is the click target. The shared
buttonDepth/indicatorStrip constants now drive both banks, and the
columns are clamped so they can never meet behind a narrow map.

The map pane narrows from 404 to 344 for a 324-wide glass, handing the
difference back to the viewscreen.

Verified live: clicking 60px inside the radar from either edge presses
the column button behind it, with feedback in that side's strip (720
and 1350 pixels).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:31:10 -05:00
CydandClaude Fable 5 0bec0f9640 Glass cockpit: MFD buttons reach under the display
The red banks were thin strips sitting outside the glass, so the click
target was only as tall as the strip. They are now 100px buttons that
extend BEHIND the MFD picture with just a 10px indicator clearing the
edge: the lamp still reads as a slim strip along the bezel, but the
region of the display above or below it is what you press. Paint order
flipped to match - buttons first, glass over them.

The banks are clamped so they can never meet in the middle on a
scaled-down cockpit, and the panes lost 40px of height each (260 vs
300 for a 240-tall glass), which hands that space back to the
viewscreen.

Verified live: clicking 50px inside the picture presses the button
behind it and the press shows in the indicator strip (770 pixels
changed, all of them within the strip).

Amber map columns are untouched for now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:25:23 -05:00
CydandClaude Fable 5 8fb4b72f7a Pad Start and Back ship unbound
They were mapped to the config buttons (0x37/0x36), which the 9 and 0
keys already reach from the Upper Right MFD bank. Freeing them gives
players two pad buttons to assign; bindings.txt carries the mapping as
commented example lines so the way to re-enable it is in the file.
Docs and the dist README follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 21:08:05 -05:00
CydandClaude Fable 5 00cb87907c Controls map: docs/CONTROLS.md, shipped with the dist
Written from the default bindings.txt so it matches what the game
actually writes. Covers the Xbox pad (ASCII diagram plus a table of
every button and its RIO address), the keyboard - numpad flight
cluster diagram, the Shift/Ctrl/Alt throttle and reverse modifiers,
Alt+Q abort - and the panel button banks, showing how the number and
letter rows map onto the five MFD clusters exactly as printed on the
pod board, with the G/B gap keys and the F-key Secondary/Screen
columns. Also notes the keypads being deliberately unbound, the RGB
lamp mirror, and how to rebind with worked examples.

pack-dist copies it to the dist as CONTROLS.txt, flattened to ASCII so
it reads correctly in Notepad while the markdown source keeps its
typography. README links it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:24:43 -05:00
CydandClaude Fable 5 53228686b4 pack-dist: release zips nest everything under an RP412 folder
Unpacking the zip anywhere now yields one self-contained RP412\
directory instead of scattering ~1000 files into the extraction
folder. The -Zip step stages a copy under the temp dir as RP412\ and
compresses that folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:12:53 -05:00
CydandClaude Fable 5 1bd6dd83e2 MFD rendering: HALFTONE downscale, plus the exploded diagnostic view
Two pieces of MFD work that had been sitting in the tree (already in
every build and dist since).

The compact cockpit glass shows the full 640x480 gauge canvas at about
half size, and COLORONCOLOR did that by dropping every other row and
column - which shredded the 1-bit vector strokes and small text.
HALFTONE area-averages instead (with the brush origin set, as MSDN
requires) so the downscaled MFDs stay legible.

L4MFDSPLIT=2 adds an exploded diagnostic view: every display in its
own full-size desktop window at native resolution, decoded exactly as
the pod VDB split them from the single gauge canvas, with no cockpit
compositing and no downscale. It makes an individual MFD readable and
screenshottable at full resolution for comparison against the
emulator per-channel reference windows. L4MFDSPLIT=1 keeps the
composited glass cockpit and stays the default.

Also: .gitignore now covers the packaged RedPlanet-*.zip releases,
which live on the Gitea release page rather than in the tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:10:14 -05:00
CydandClaude Fable 5 1ba3bde36a pack-dist: ship steam_appid.txt so a fresh dist is test-ready
The packer wipes and rebuilds dist\, which silently discarded the
hand-made steam_appid.txt every repack - and without it SteamAPI_Init
fails and the game quietly falls back to TCP, which is the confusing
symptom testers hit. The dist now ships it (480, Spacewar) alongside
the RP412STEAM=1 environ.ini, so copying the folder to a machine with
Steam running is the whole setup. Test doc updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:08:25 -05:00
CydandClaude Fable 5 4b25f89e6f Football: every player picks their own team and position
Closes the gap left by the football commit. Members publish their
picks as lobby member data (tm/ps) alongside their loadout, and the
owner publishes the scenario (sc) so members know when the picks
matter. The lobby room turns into a team sheet: each member row shows
their team and position, and MY TEAM / MY POSITION buttons cycle the
local pick and republish it live, so everyone watches the sides fill
out before the host launches.

The egg builder honors explicit picks and only fills gaps:
- pilots who chose a team get it; the rest are spread across the
  host team and one other so the sides stay balanced
- a team with no volunteer runner promotes its first UNPICKED member,
  never overriding someone who chose crusher or blocker
- if two pilots on a team both claim runner, the first keeps it and
  the second lines up
- colors stay derived: runner in the team runner color, everyone else
  in the team color

Verified: a three-pilot egg puts the host on crusher in team color
with an unpicked teammate promoted to runner in runner color, the
other side gets its own runner, and the teams/pilots blocks group
correctly; the lobby room cycles picks and repaints the roster; both
scenarios still pass their single-player regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:59:42 -05:00
CydandClaude Fable 5 3194d3f973 Martian Football: the second scenario is playable
The setup menu gains a SCENARIO group. Picking Football relayouts the
menu live: the track list swaps to the football-legal set (drops
Paingod Passage and the race build of Freezemoon Freeway, adds the
football build headmf - RPConfig per-scenario invalid lists), the
COLOR and BADGE columns become TEAM and POSITION, and the title
reads MARTIAN FOOTBALL.

The egg builder was restructured around one flat pilot table so both
scenarios share it. Football emits scenario=football, per-pilot team=
and position= entries, and the [teams] / [team::X] / [pilots::X] /
[teambitmap] blocks in RPFootballMission layout (team name bitmaps
generated by the same GDI plasma renderer as pilot names). Colors are
derived rather than chosen, as the arcade did it: the runner wears the
team runner color, crushers and blockers the team color. Multi-pod
games alternate pilots across two teams and give each team exactly one
runner, honoring the host own position pick.

Verified end to end: Football launches from the menu, the engine loads
the football mission (the cockpit shows the RUNNER - GO FOR POINTS
panel), the console marshals it to a scored finish, and the Death Race
path still passes its regression unchanged.

Known gap: lobby members cannot pick their own team or position yet -
the host pick seeds a deterministic assignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:42:33 -05:00
CydandClaude Fable 5 0b84b32486 README: corrections from review
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:07:35 -05:00
1538 changed files with 372576 additions and 1168 deletions
+24
View File
@@ -40,6 +40,20 @@ ipch/
*.log
rpl4.log
# Where the game window and the exploded view's panes were last dragged
# to (RP412MFDLAYOUT). Per-machine by nature - it is screen coordinates
# on somebody's desk.
mfd_layout.cfg
# The pilot's callsign, remembered between sessions. Whoever is sitting
# at this machine, which is not the repo's business.
pilot.cfg
# Generated by stamp-version.ps1 as RP_L4's pre-build step. The patch
# number in it IS this repository's commit count, so a committed copy
# would be stale the moment it was committed.
/RP_L4/rpl4build.h
# Build-output static libs that land in lib/ (the two committed dependency
# libs, OpenAL32.lib and libsndfile-1.lib, stay tracked).
/lib/Munga_L4.lib
@@ -67,3 +81,13 @@ assets/RP411/startrp-800x600.ps1
# ...but never the spool runtime output or Windows thumbnail caches.
assets/**/*.spl
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/
+50 -3
View File
@@ -50,10 +50,57 @@ The solution is [WinTesla.sln](WinTesla.sln) with four v143 projects:
Build order is resolved by `ProjectReference` (RP_L4 and RPL4TOOL both reference
Munga_L4).
**Versioning:** the patch number *is* the repository's commit count, so a
build always names the commit it came from and there is no question about
which changes a given binary contains.
[stamp-version.ps1](stamp-version.ps1) runs as RP_L4's pre-build step and
writes the generated, uncommitted `RP_L4\rpl4build.h`:
```
#define RP412_VERSION "4.12.96"
#define RP412_VERSION_LONG "4.12.96 (a1b2c3d)"
```
The game logs the long form on its first line. A trailing `+` on the hash
means the tree had uncommitted changes to tracked files when it was built —
useful when a test machine reports something a clean build cannot reproduce.
Only the `4.12` product line is set by hand, at the top of the script.
**Test builds expire.** `$expireDays` at the top of the same script is the
shelf life in days (currently **14**, counted from the day it was *built*,
not the day the code was written — rebuilding an old commit gives a usable
binary rather than one born stale). An expired build says so in a dialog,
names its version and expiry date, points at the releases page, and exits
without running. It stops a tester spending an afternoon on something that
was fixed a week ago.
> ⚠️ **Set `$expireDays = 0` for a real release.** A shipped build that
> expires is a catastrophe, and that one line is what decides it.
`RP412NOEXPIRY=1` waives the check when an old build has to be run on
purpose, and says so in the log so a waived build is never mistaken for a
current one. It is deliberately **not** listed in `environ.ini` — a bypass
every tester can see is a bypass every tester will use. Negative
`$expireDays` backdates the expiry, which is how the refusal gets tested
without touching the machine's clock.
The header is deliberately not committed: the commit that recorded a
hardcoded number would itself change the count, so the file would be stale
the moment it landed. It is rewritten only when the stamp actually changes,
so ordinary rebuilds do not recompile `RPL4.CPP` for nothing. Building
outside a git checkout stamps `4.12.x (no repository)` rather than inventing
a number that would sort against real ones.
**Packaging:** [pack-dist.ps1](pack-dist.ps1) assembles a runnable game into
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, desktop
`environ.ini`, `start-windowed.bat`, README). Pass `-Zip` to also produce
`dist\RedPlanet412-prototype.zip` for handing to someone else.
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, launch scripts,
HANDBOOK.html, README). It deliberately does **not** write `environ.ini`
the exe carries that template and writes it on first run
([RP_L4/RPL4ENVIRON.cpp](RP_L4/RPL4ENVIRON.cpp)), so a tester can drop a new
build over an old folder without losing their settings. Pass `-Zip` to also produce
`RedPlanet-<version>.zip` for handing to someone else. It reads the version
from `rpl4build.h` rather than asking git again, so the package and the
binary inside it cannot disagree, and it warns if the build it is packing
came from a modified tree.
## 3. VS2022 migration notes (what changed and why)
+52 -2
View File
@@ -273,6 +273,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
@@ -649,8 +678,23 @@ Time endIntercom = Now();
//
if (GetApplicationState() == RunningMission)
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
//
// Ask the console first: it owns the clock that actually ends the
// race, so this is the countdown the buzzer will agree with. Its
// own reckoning is the fallback for everything with no console of
// its own - see gMissionClockHook in APPMGR.h.
//
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
}
}
routePacketFinished = False;
@@ -1125,6 +1169,12 @@ void
Check(this);
Check(egg_notation_file);
//
// Forget every peer's clock offset: the hosts in the next race are not
// the hosts in the last one, and a HostID gets reused.
//
NetClock_Reset();
//
//--------------------------------------------------------------------------
// Create mission from egg notation file
+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;}
+3
View File
@@ -15,6 +15,9 @@ Logical gConsoleMarshalsLaunch = False;
// losing the console mid-mission ends it (lobby-member races)
Logical gConsoleLossEndsMission = False;
// the console's countdown, when a console is marshalling (see APPMGR.h)
Logical (*gMissionClockHook)(Scalar *seconds_remaining) = NULL;
ApplicationManager* ApplicationManager::CurrentAppManager = NULL;
ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this)
+22
View File
@@ -20,6 +20,28 @@ extern Logical gConsoleMarshalsLaunch;
// console to return, exactly as always.
extern Logical gConsoleLossEndsMission;
//
// The console's own countdown, when there is a console to ask.
//
// A mission ends when the console says so, but secondsRemainingInGame was
// computed here from the engine clock and its own idea of when the race
// started - a different clock, from a different epoch, than the one that
// actually fires the buzzer. The two agree to within a frame or so, which
// is why nobody noticed, but they are not the same number: the cockpit
// clock could read 0:00 with the race still running, and the camera
// directors' "last 30 seconds" behaviour switched on the engine's reading
// rather than on the real remaining time.
//
// Set by the console when it is marshalling; NULL restores the engine's
// own reckoning, which is what the arcade -net pods, lobby members and
// mission review all use (none of them run a console locally, and their
// clock is anchored by the console's RunMission arriving anyway).
//
// Returns False when it has no answer yet - the window between the
// application reaching RunningMission and the console noticing.
//
extern Logical (*gMissionClockHook)(Scalar *seconds_remaining);
class ApplicationManager : public Node
{
public:
+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;
}
+25
View File
@@ -377,8 +377,28 @@ void
//------------------------------------------------------------------------
// Step through each block until there are no more remaining, and send the
// update out the the simulation indicated by the subsystemID
//
// This is the only point on the receive path that knows WHOSE update
// this is - the records themselves carry a timestamp but not an owner -
// so the sender is published here for the net clock to align against.
// Every record in the message, and the damage zones nested inside them,
// came from the same machine in the same frame.
//------------------------------------------------------------------------
//
//
// Only for an entity somebody else owns. Our own clock needs no
// aligning, and an update we somehow handed ourselves would otherwise
// drag lastUpdate back by a frame for no reason.
//
Check(application);
Check(application->GetHostManager());
Logical remote_owner =
GetOwnerID() != application->GetHostManager()->GetLocalHostID();
if (remote_owner)
{
NetClock_BeginUpdate(GetOwnerID());
}
while (stream.GetBytesRemaining())
{
Simulation::UpdateRecord *update =
@@ -389,6 +409,11 @@ void
simulation->ReadUpdateRecord(update);
stream.AdvancePointer(update->recordLength);
}
if (remote_owner)
{
NetClock_EndUpdate();
}
Check_Fpu();
}
+46 -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
//#######################################################################
@@ -3683,7 +3708,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;
}
+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();
+181 -1
View File
@@ -264,6 +264,109 @@ Simulation::SharedData
// Model support
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
//##########################################################################
// Net clock - see SIMULATE.h for why the sender's timestamp is estimated
// rather than used as it stands.
//##########################################################################
namespace
{
enum
{
netClockMaxPeers = 16,
// Samples per rolling minimum. A peer sends one record per
// simulation per frame, so at eight vehicles and 60 fps this is
// well under a second - fast enough to follow a route change,
// long enough that the minimum means something.
netClockWindow = 128,
// The furthest back we will believe a timestamp. Beyond this the
// packet is stale or the estimate is wrong, and extrapolating a
// vehicle half a second forward does more harm than the lag we
// are correcting.
netClockMaxLagTicks = 500
};
struct PeerClock
{
HostID host;
Logical inUse;
Logical settled;
long offsetTicks; // our clock - their clock
long windowMinTicks;
int windowCount;
};
PeerClock gPeerClocks[netClockMaxPeers];
HostID gUpdateSender = 0;
Logical gUpdateSenderValid = False;
Logical NetClockEnabled()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412NETCLOCK");
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
if (!enabled)
{
DEBUG_STREAM << "NetClock: disabled by RP412NETCLOCK=0 - "
<< "replicants dead-reckon from arrival time\n" << std::flush;
}
}
return enabled ? True : False;
}
PeerClock *FindPeer(HostID host)
{
PeerClock *free_slot = NULL;
for (int i = 0; i < netClockMaxPeers; ++i)
{
if (gPeerClocks[i].inUse)
{
if (gPeerClocks[i].host == host)
{
return &gPeerClocks[i];
}
}
else if (free_slot == NULL)
{
free_slot = &gPeerClocks[i];
}
}
if (free_slot != NULL)
{
free_slot->inUse = True;
free_slot->host = host;
free_slot->settled = False;
free_slot->offsetTicks = 0;
free_slot->windowMinTicks = 0;
free_slot->windowCount = 0;
}
return free_slot;
}
}
void NetClock_BeginUpdate(HostID sender)
{
gUpdateSender = sender;
gUpdateSenderValid = True;
}
void NetClock_EndUpdate()
{
gUpdateSenderValid = False;
}
void NetClock_Reset()
{
memset(gPeerClocks, 0, sizeof(gPeerClocks));
gUpdateSenderValid = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -272,7 +375,84 @@ void
Check(this);
Check_Pointer(message);
lastUpdate = Now(); // HACK - should be based upon message->timeStamp
//
//------------------------------------------------------------------
// When this update arrived is not when it was taken. Put lastUpdate
// at the sender's sampling moment, expressed in our clock, so the
// dead reckoner extrapolates over the network latency instead of
// starting from scratch once it has already elapsed.
//------------------------------------------------------------------
//
long now_ticks = Now().ticks;
long local_ticks = now_ticks;
PeerClock *peer = gUpdateSenderValid && NetClockEnabled()
? FindPeer(gUpdateSender) : NULL;
if (peer != NULL)
{
//
// sample = trueOffset + oneWayLatency, so the running minimum
// converges on the offset from above.
//
long sample = now_ticks - message->timeStamp.ticks;
if (!peer->settled)
{
peer->settled = True;
peer->offsetTicks = sample;
peer->windowMinTicks = sample;
peer->windowCount = 0;
DEBUG_STREAM << "NetClock: host " << peer->host
<< " first sample, offset " << sample << " ms\n" << std::flush;
}
else
{
if (sample < peer->windowMinTicks)
{
peer->windowMinTicks = sample;
}
if (sample < peer->offsetTicks)
{
peer->offsetTicks = sample; // a shorter path: believe it now
}
if (++peer->windowCount >= netClockWindow)
{
//
// Close the window: adopt its minimum even if it is
// LARGER than the running estimate, which is how the
// figure follows clock drift and a route that got
// slower rather than staying pinned to one old packet.
//
long moved = peer->windowMinTicks - peer->offsetTicks;
if (moved > 50 || moved < -50)
{
DEBUG_STREAM << "NetClock: host " << peer->host
<< " offset " << peer->offsetTicks << " -> "
<< peer->windowMinTicks << " ms\n" << std::flush;
}
peer->offsetTicks = peer->windowMinTicks;
peer->windowMinTicks = sample;
peer->windowCount = 0;
}
}
local_ticks = message->timeStamp.ticks + peer->offsetTicks;
//
// Never ahead of our own clock, and never further back than we
// are willing to extrapolate.
//
if (local_ticks > now_ticks)
{
local_ticks = now_ticks;
}
else if (now_ticks - local_ticks > netClockMaxLagTicks)
{
local_ticks = now_ticks - netClockMaxLagTicks;
}
}
lastUpdate.ticks = local_ticks;
SetSimulationState(message->simulationState);
Check_Fpu();
}
+35
View File
@@ -4,6 +4,41 @@
#include "receiver.h"
#include "time.h"
#include "resource.h"
#include "hostid.h"
//##########################################################################
//########################### Net clock ##############################
//##########################################################################
//
// Aligning a peer's clock with ours, so a replicant is dead-reckoned from
// when its update was SENT rather than when it happened to arrive.
//
// Every update record carries the sender's own timestamp. The receiver
// used to throw it away and stamp lastUpdate with its own Now() - the
// original code says so: "HACK - should be based upon message->timeStamp".
// The dead reckoner then extrapolates over (lastPerformance - lastUpdate),
// so starting that clock at ARRIVAL rather than at SEND leaves every
// remote vehicle exactly one network latency behind where it should be.
// On the 1 ms arcade LAN that was invisible. Over Steam Datagram Relay it
// is a constant 50-150 ms of positional lag - a bias, not jitter.
//
// The timestamp cannot be used raw: two machines' clocks share no epoch,
// both being QueryPerformanceCounter since their own boot. So we estimate
// the offset per peer. Each arriving record gives
//
// sample = ourNow - theirStamp = trueOffset + oneWayLatency
//
// and since latency is never negative, the SMALLEST sample seen is the
// closest to the true offset. Taking a minimum over a short rolling
// window tracks crystal drift and re-adapts when the route changes,
// instead of being pinned forever by one lucky packet.
//
// RP412NETCLOCK=0 turns the whole thing off and restores the arrival-time
// behaviour, so a test machine can A/B it without a rebuild.
//
void NetClock_BeginUpdate(HostID sender); // around one message's records
void NetClock_EndUpdate();
void NetClock_Reset(); // forget every peer (new mission)
class Simulation__SharedData;
class Simulation__IndexData;
+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);
+10
View File
@@ -79,6 +79,9 @@ public:
static unsigned int GetScreenWidth() { return mScreenWidth; }
static unsigned int GetScreenHeight() { return mScreenHeight; }
static bool GetFullscreen() { return mFullscreen; }
// -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; }
static Logical GetSeeSolids() { return seeSolids; }
static unsigned long GetNetworkCommonFlatAddress() { return networkCommonFlatAddress; }
// The front end's multiplayer path turns network mode on at launch
@@ -93,10 +96,17 @@ public:
static CString GetRIORecordingFileName() { return rioRecordingFileName; }
protected:
// Pick mScreenWidth/mScreenHeight from the monitor. Runs after the
// whole command line is parsed, so an explicit -res always wins no
// matter which side of -fit it appears on.
static void ChooseFitResolution();
static HINSTANCE mhInstance;
static unsigned int mScreenWidth;
static unsigned int mScreenHeight;
static bool mFullscreen;
static bool mFitDisplay;
static bool mResExplicit;
static Logical seeSolids;
static CString eggNotationFileName;
static CString spoolFileName;
+80 -1
View File
@@ -94,6 +94,8 @@ HINSTANCE L4Application::mhInstance = NULL;
unsigned int L4Application::mScreenWidth = 800;
unsigned int L4Application::mScreenHeight = 600;
bool L4Application::mFullscreen = true;
bool L4Application::mFitDisplay = false;
bool L4Application::mResExplicit = false;
Logical L4Application::seeSolids = False;
CString L4Application::eggNotationFileName;
CString L4Application::spoolFileName;
@@ -205,6 +207,7 @@ Logical
{
mScreenWidth = atoi(W2A(argv[++(*arguement)]));
mScreenHeight = atoi(W2A(argv[++(*arguement)]));
mResExplicit = true;
}
else
{
@@ -217,18 +220,88 @@ Logical
mFullscreen = false;
}
//-------------------------------------------------------------------
// -fit: borderless window over the whole monitor, and a render size
// picked to match. Spelled out as -windowed-fullscreen too, since
// that is what the rest of the world calls it.
//-------------------------------------------------------------------
else if (
!stricmp(W2A(argv[*arguement]), "-fit") ||
!stricmp(W2A(argv[*arguement]), "-windowed-fullscreen")
)
{
mFullscreen = false;
mFitDisplay = true;
}
else if (
!stricmp(W2A(argv[*arguement]), "-h") || !stricmp(W2A(argv[*arguement]), "-help")
)
{
DEBUG_STREAM << "\n" << argv[0] <<
" -egg <filename> -net <memory_address> -solids -h -help\n";
" -egg <filename> -net <memory_address> -solids -h -help\n"
" -windowed windowed, title bar and all\n"
" -fit borderless over the whole monitor,\n"
" render size chosen to match\n"
" (long form: -windowed-fullscreen)\n"
" -res <width> <height> explicit render size; overrides the\n"
" size -fit would have picked\n";
return False;
}
return True;
}
//
//#############################################################################
// ChooseFitResolution
//#############################################################################
//
// The cockpit presents the 3D into a viewscreen that fills its 1920x1080
// canvas, and that canvas is fitted to the window at one uniform scale.
// So the render size that lands 1:1 on screen is the canvas at the same
// scale the cockpit will choose - work it out with identical arithmetic
// here and the stretch becomes a copy.
//
void
L4Application::ChooseFitResolution()
{
int monitor_w = GetSystemMetrics(SM_CXSCREEN);
int monitor_h = GetSystemMetrics(SM_CYSCREEN);
if (monitor_w <= 0 || monitor_h <= 0)
{
return;
}
const int canvas_w = 1920;
const int canvas_h = 1080;
int fit_w = (monitor_w * 100) / canvas_w;
int fit_h = (monitor_h * 100) / canvas_h;
int scale = (fit_w < fit_h) ? fit_w : fit_h;
if (scale < 25) scale = 25;
unsigned int width = (canvas_w * scale) / 100;
unsigned int height = (canvas_h * scale) / 100;
//-------------------------------------------------------------------
// A 1995 engine on a 5K panel would be asked for a back buffer well
// past anything it was built for; cap and let D3D scale the last bit.
//-------------------------------------------------------------------
if (width > 3840)
{
width = 3840;
height = 2160;
}
mScreenWidth = width;
mScreenHeight = height;
DEBUG_STREAM << "L4Application: -fit chose -res " << width << " "
<< height << " (" << scale << "% canvas) for the "
<< monitor_w << "x" << monitor_h << " monitor\n" << std::flush;
}
//
//#############################################################################
// ParseCommandLine
@@ -270,6 +343,12 @@ Logical
}
}
}
// after the whole line, so -res wins from either side of -fit
if (mFitDisplay && !mResExplicit)
{
ChooseFitResolution();
}
return True;
}
+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);
+269 -32
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);
}
}
}
@@ -1430,24 +1605,27 @@ AudioControlValue
//
// Call inherited method to calculate volume scale
//
Scalar
Scalar
volume_scale = L4AudioSource::CalculateSourceVolumeScale();
return volume_scale;
//
// Update the spatial model that will result in the value
// for distance related volume attenuation
//
/*Check(application);
Check(application->GetAudioRenderer());
UpdateSpatialModel(application->GetAudioRenderer()->GetAudioHead());
//
// Apply distance attenuation to the volume scale
//
// 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.
//
// 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;
+311 -28
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;
}
if (failed)
{
return False;
ALuint src = 0;
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
+78 -1
View File
@@ -13,6 +13,7 @@ L4TEXOP::WrapType d3d_OBJECT::mLastWrapV = L4TEXOP::WrapType::REPEAT;
bool d3d_OBJECT::mLastTexturingState = true;
long d3d_OBJECT::mNextID = 1;
stdext::hash_map<std::string, L4TEXOP> d3d_OBJECT::mTextureCache;
LPDIRECT3DTEXTURE9 d3d_OBJECT::mNamePlateMarkers[d3d_OBJECT::kNamePlateCount + 1] = { NULL };
void chgext(char *filePath, const char *newExtension)
{
@@ -156,7 +157,36 @@ d3d_OBJECT *d3d_OBJECT::LoadObject(LPDIRECT3DDEVICE9 device, char *fileName)
{
sprintf(textureFilename, "VIDEO\\%s", materials[i].pTextureFilename);
}
object->mDrawOps[i].texture = LoadTexture(device, textureFilename);
//
// playerN is not a file - it is a pilot's callsign, drawn into
// a texture at run time, so loading it always fails. Give the
// plate its marker texture instead: it keeps the eight sign
// faces as eight separate draw ops through consolidation, and
// the Winners Circle swaps the real callsign in later.
//
const char *base = strrchr(textureFilename, '\\');
base = (base != NULL) ? base + 1 : textureFilename;
int plate = 0;
if (_strnicmp(base, "player", 6) == 0 &&
base[6] >= '1' && base[6] <= '8')
{
plate = base[6] - '0';
}
if (plate != 0)
{
memset(&object->mDrawOps[i].texture, 0, sizeof(L4TEXOP));
object->mDrawOps[i].texture.texture =
NamePlateMarker(device, plate);
if (object->mDrawOps[i].texture.texture != NULL)
{
object->mDrawOps[i].texture.texture->AddRef();
}
}
else
{
object->mDrawOps[i].texture = LoadTexture(device, textureFilename);
}
}
if (nextDetailOp < numDetailOps && detailOps[nextDetailOp] == i)
@@ -286,6 +316,53 @@ void d3d_OBJECT::FlushTextureCache()
(*iter).second.texture->Release();
}
mTextureCache.clear();
for (int plate = 0; plate <= kNamePlateCount; ++plate)
{
if (mNamePlateMarkers[plate] != NULL)
{
mNamePlateMarkers[plate]->Release();
mNamePlateMarkers[plate] = NULL;
}
}
}
//
// A distinct 1x1 texture per plate, so the eight sign faces stay eight
// separate draw ops through mesh consolidation instead of merging into one.
// Its content never shows - the callsign replaces it before the podium.
//
LPDIRECT3DTEXTURE9 d3d_OBJECT::NamePlateMarker(LPDIRECT3DDEVICE9 device, int plate)
{
if (plate < 1 || plate > kNamePlateCount || device == NULL)
{
return NULL;
}
if (mNamePlateMarkers[plate] == NULL)
{
device->CreateTexture(1, 1, 1, 0, D3DFMT_A4R4G4B4, D3DPOOL_MANAGED,
&mNamePlateMarkers[plate], NULL);
}
return mNamePlateMarkers[plate];
}
//
// Which plate a texture is the marker for, or 0 if it is not one.
//
int d3d_OBJECT::NamePlateFor(LPDIRECT3DTEXTURE9 texture)
{
if (texture == NULL)
{
return 0;
}
for (int plate = 1; plate <= kNamePlateCount; ++plate)
{
if (mNamePlateMarkers[plate] == texture)
{
return plate;
}
}
return 0;
}
d3d_OBJECT::d3d_OBJECT(LPDIRECT3DDEVICE9 device, int vertCount)
+24
View File
@@ -111,6 +111,27 @@ public:
// race loop tears the renderer down between missions).
static void FlushTextureCache();
//------------------------------------------------------------------
// Pilot name plates.
//
// The Winners Circle signs ask for textures called player1..player8,
// which are not files - the renderer draws each pilot's callsign into
// a texture at run time. Loading them fails and the plates come out
// blank, so the draw ops that wanted them are noted here as the
// geometry loads, and bound to the real textures once the finishing
// order is known.
//
// Cleared with the texture cache: the entries point at objects that
// belong to the mission being torn down.
//------------------------------------------------------------------
// Each plate is given a distinct marker texture as it loads. Without
// one they are eight identical untextured draw ops, and consolidation
// merges every static mesh by material - all eight plates would
// collapse into a single shared op that can only ever show one name.
enum { kNamePlateCount = 8 };
static LPDIRECT3DTEXTURE9 NamePlateMarker(LPDIRECT3DDEVICE9 device, int plate);
static int NamePlateFor(LPDIRECT3DTEXTURE9 texture);
private:
static d3d_OBJECT* LoadSpheres(LPDIRECT3DDEVICE9 device, char *fileName);
@@ -150,6 +171,9 @@ private:
static long mNextID;
static stdext::hash_map< std::string , L4TEXOP > mTextureCache;
// index 1..8; [0] unused so the index is the plate number
static LPDIRECT3DTEXTURE9 mNamePlateMarkers[kNamePlateCount + 1];
};
extern int gNumBatches;
+1392
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
//===========================================================================//
// File: l4joy.h //
// Project: MUNGA Brick: generic joystick reader //
// Contents: DirectInput 8 sticks, HOTAS throttles and pedals //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
//########################################################################
//
// L4JOY - the generic-joystick reader.
//
// PadRIO reads XInput, which covers Xbox-class pads and nothing else.
// This layer adds every OTHER game device Windows knows - flight sticks,
// HOTAS throttles, twist grips, rudder pedals, wheels - through
// DirectInput 8, the standard generic-HID game API. It exposes up to
// joyMaxDevices attached devices as normalized state blocks; the PadRIO
// poll maps them onto the pod's control channels through the joydev /
// joyaxis / joybutton / joyhat rows of bindings.txt (L4PADBINDINGS.h),
// the same binding machinery the pad and keyboard already use.
//
// XInput-class devices are EXCLUDED here, or they would double-feed
// through both APIs and every input would count twice. A DirectInput
// device whose VID/PID also appears in a RawInput device path containing
// the "IG_" marker is an XInput device - the documented detection that
// does not drag in WMI.
//
// This is distinct from the legacy L4DINPUT.cpp DIJoystick, the 1995-era
// single-device `Joystick` engine interface reachable only through the
// old L4CONTROLS=DIJOYSTICK profile. That path is untouched.
//
// RP412JOYCONFIG=1 runs the interactive setup wizard at boot: it asks
// the player to move each control, works out which device and axis moved
// and which way, and writes the joystick section of bindings.txt.
// RP412JOYLOG=1 logs device attach/detach.
//
// Ported from BT411, whose glass cockpit needed the same thing.
//
//########################################################################
enum
{
joyMaxDevices = 4,
joyAxisCount = 8, // X Y Z RX RY RZ SL0 SL1 (DIJOYSTATE2 order)
joyButtonCount = 32, // buttons exposed to bindings (DI carries 128)
joyHatCount = 4
};
struct RPJoyDeviceState
{
int attached;
float axis[joyAxisCount]; // normalized -1..1, raw: deadzones
// are the binding layer's business
unsigned buttons; // bit n = button n held
int hat[joyHatCount]; // POV in centidegrees, -1 = centered
char name[64]; // product name ("T.16000M", ...)
};
//
// Lifecycle. Init is lazy-safe (Poll calls it) and returns the attached
// non-XInput device count. Re-enumeration for hot-plug happens inside
// Poll on a ~3 s cadence whenever nothing is attached.
//
int RPJoyInit(void);
void RPJoyShutdown(void);
void RPJoyPoll(void);
int RPJoyDeviceCount(void);
const RPJoyDeviceState *RPJoyDevice(int index); // NULL out of range/detached
//
// Case-insensitive product-name substring match to a device index, -1 for
// no match. This is what a named joydev slot resolves through.
//
int RPJoyFindDevice(const char *name_substring);
//
// The RP412JOYCONFIG capture wizard (console UI; called from RPL4.CPP
// before the front end). Returns 0 if it wrote a config, non-zero on
// abort or no device.
//
int RPJoyConfigWizard(void);
+497 -31
View File
@@ -10,6 +10,101 @@ namespace
const int buttonGap = 4;
//---------------------------------------------------------------
// Sticky placement for the exploded view. See L4MFDVIEW.h; ported
// from BT411's BT_GLASS_LAYOUT.
//---------------------------------------------------------------
enum { LayoutOff = 0, LayoutLoad, LayoutSave };
const char layoutFileName[] = "mfd_layout.cfg";
// every window taking part, so Save can walk them
enum { maxLayoutWindows = 12 };
struct LayoutWindow
{
void *window;
const char *title;
Logical withSize;
Logical bare; // frame stripped, per the file
};
LayoutWindow layoutWindows[maxLayoutWindows];
int layoutWindowCount = 0;
//---------------------------------------------------------------
// Take a window's frame off: no title bar, no border, nothing to
// drag it by. WS_SYSMENU stays - it draws nothing once the caption
// is gone, but without one DefWindowProc will not honour Alt+F4,
// and a bare window with no way to close it is a trap.
//---------------------------------------------------------------
void StripWindowFrame(HWND window)
{
LONG_PTR style = GetWindowLongPtrA(window, GWL_STYLE);
style &= ~(WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX |
WS_MAXIMIZEBOX | WS_BORDER | WS_DLGFRAME);
style |= WS_POPUP;
SetWindowLongPtrA(window, GWL_STYLE, style);
}
int LayoutMode()
{
static int mode = -1;
if (mode < 0)
{
const char *setting = getenv("RP412MFDLAYOUT");
if (setting == NULL || setting[0] == '\0')
{
mode = LayoutOff;
}
else if (!stricmp(setting, "off") || !stricmp(setting, "0"))
{
mode = LayoutOff;
}
else if (!stricmp(setting, "load") || !stricmp(setting, "restore"))
{
mode = LayoutLoad;
}
else // save / adjust / on / 1
{
mode = LayoutSave;
}
if (mode != LayoutOff)
{
DEBUG_STREAM << "MFDLayout: RP412MFDLAYOUT="
<< ((mode == LayoutSave) ? "save" : "load")
<< " (" << layoutFileName << ")\n" << std::flush;
}
}
return mode;
}
//---------------------------------------------------------------
// Button banks run UNDER the glass: a button reaches this far in
// from the display edge, but only the indicator strip clears that
// edge - the rest sits behind the picture, which is what the
// player actually clicks. Applies to both the MFD strips (depth
// measured vertically) and the map's side columns (horizontally).
//---------------------------------------------------------------
// 240 against the 480 glass: the two banks reach in far enough to
// meet in the middle bar the strips' own width, so practically the
// whole display is a press target and neither bank overlaps the
// other. The clamps below keep that true on a scaled-down cockpit.
const int buttonDepth = 240;
const int indicatorStrip = 10;
//---------------------------------------------------------------
// The map paints its own legend beside each side button, and that
// grid is not the naive height/6: measured off the 640-tall map,
// the first cell starts 13 rows down and the six cells are 102
// tall on a 105 pitch (13 + 6*102 + 5*3 = 640). Spacing the
// buttons evenly from the top instead left every one of them
// riding high of its label. Scaled to the live glass height.
//---------------------------------------------------------------
const int mapLegendSpan = 640;
const int mapLegendTop = 13;
const int mapLegendCell = 102;
const int mapLegendPitch = 105;
//---------------------------------------------------------------
// Button fill colors by lamp brightness (0 off / 1-2 dim / 3
// bright), red family for MFD strips, amber for the side columns.
@@ -81,6 +176,15 @@ namespace
}
break;
case WM_EXITSIZEMOVE:
//
// A drag just finished. Write the whole arrangement now rather
// than trusting teardown, so a hard kill still leaves the
// layout that was on screen. No-op unless the mode is save.
//
RPWindowLayout_Save();
return 0;
case WM_CLOSE:
// Part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE);
@@ -121,6 +225,8 @@ MFDSplitView::MFDSplitView(
sourceHeight = source_height;
buttonCount = 0;
pressedIndex = -1;
paneTitle = title;
ownWindow = (parent == NULL) ? True : False;
pixels = new unsigned long[source_width * source_height];
memset(pixels, 0, source_width * source_height * sizeof(unsigned long));
@@ -135,6 +241,10 @@ MFDSplitView::MFDSplitView(
header->biCompression = BI_RGB;
blitHeader = header;
buttonStyle = button_style;
buttonAnchorA = anchorA;
buttonAnchorB = anchorB;
int client_width = display_width;
int client_height = display_height;
LayoutButtons(button_style, anchorA, anchorB,
@@ -199,6 +309,14 @@ MFDSplitView::MFDSplitView(
{
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, (LONG_PTR) this);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE);
// only the exploded view's own windows can be dragged, so only
// those have a placement worth remembering. Position only - see
// RPWindowLayout_Register.
if (ownWindow)
{
RPWindowLayout_Register(window, paneTitle, False);
}
}
else
{
@@ -210,6 +328,9 @@ MFDSplitView::~MFDSplitView()
{
Check_Pointer(this);
// out of the layout registry before the window goes
RPWindowLayout_Forget(window);
if (window != NULL)
{
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, 0);
@@ -228,6 +349,272 @@ Logical
return pixels != NULL;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Join the saved layout. Registering twice just refreshes the entry, so
// a window re-registering after being rebuilt is harmless.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
RPWindowLayout_Register(void *window, const char *title, Logical with_size)
{
if (window == NULL || title == NULL)
{
return;
}
for (int i = 0; i < layoutWindowCount; ++i)
{
if (layoutWindows[i].window == window)
{
layoutWindows[i].title = title;
layoutWindows[i].withSize = with_size;
return;
}
}
if (layoutWindowCount >= maxLayoutWindows)
{
return;
}
layoutWindows[layoutWindowCount].window = window;
layoutWindows[layoutWindowCount].title = title;
layoutWindows[layoutWindowCount].withSize = with_size;
layoutWindows[layoutWindowCount].bare = False;
++layoutWindowCount;
}
void
RPWindowLayout_Forget(void *window)
{
for (int i = 0; i < layoutWindowCount; ++i)
{
if (layoutWindows[i].window == window)
{
layoutWindows[i] = layoutWindows[--layoutWindowCount];
layoutWindows[layoutWindowCount].window = NULL;
layoutWindows[layoutWindowCount].title = NULL;
return;
}
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Sticky placement: restore the saved placement over the computed one.
//
// Called once everything has been built and placed, so a window the file
// does not mention simply keeps where it was put. Size comes back only
// for windows registered with it - see the header.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
RPWindowLayout_Load()
{
if (LayoutMode() == LayoutOff || layoutWindowCount == 0)
{
return;
}
FILE *file = fopen(layoutFileName, "rt");
if (file == NULL)
{
DEBUG_STREAM << "MFDLayout: no " << layoutFileName
<< " yet (using the computed arrangement)\n" << std::flush;
return;
}
int restored = 0;
char line[256];
while (fgets(line, sizeof(line), file) != NULL)
{
char *cursor = line;
while (*cursor == ' ' || *cursor == '\t')
{
++cursor;
}
if (*cursor == '#' || *cursor == '\r' || *cursor == '\n' || *cursor == '\0')
{
continue;
}
// titles carry no '=', so the first one splits the line
char *equals = strchr(cursor, '=');
if (equals == NULL)
{
continue;
}
*equals = '\0';
char *end = equals;
while (end > cursor && (end[-1] == ' ' || end[-1] == '\t'))
{
--end;
}
*end = '\0';
int x = 0, y = 0, w = 0, h = 0;
if (sscanf(equals + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
{
continue;
}
//
// Optional trailing keyword, "x,y,w,h,noframe". Searched for
// rather than parsed in place so a hand-edited line survives a
// stray space or a missing comma.
//
Logical bare = (strstr(equals + 1, "noframe") != NULL) ? True : False;
for (int i = 0; i < layoutWindowCount; ++i)
{
LayoutWindow &entry = layoutWindows[i];
if (entry.title == NULL || strcmp(entry.title, cursor) != 0)
{
continue;
}
HWND target = (HWND) entry.window;
if (target == NULL || !IsWindow(target))
{
break;
}
// a saved size of nothing is a corrupt line, not an instruction
Logical size_it = (entry.withSize && w > 0 && h > 0) ? True : False;
//
// Saved coordinates belong to whatever monitors were plugged
// in that day. If the placement lands on none of today's, drop
// it and keep the computed one - restoring the game window
// somewhere invisible would leave nothing to drag back.
//
RECT landing;
landing.left = x;
landing.top = y;
if (size_it)
{
landing.right = x + w;
landing.bottom = y + h;
}
else
{
RECT current;
if (!GetWindowRect(target, &current))
{
break;
}
landing.right = x + (current.right - current.left);
landing.bottom = y + (current.bottom - current.top);
}
if (MonitorFromRect(&landing, MONITOR_DEFAULTTONULL) == NULL)
{
DEBUG_STREAM << "MFDLayout: " << cursor << " was saved at "
<< x << "," << y << " - off every monitor now, ignoring\n"
<< std::flush;
break;
}
//
// Frame off, if the line asked. A bare window's rect IS its
// client rect, so whatever client area it has now is the size
// to hand back - which also makes the round trip stable: once
// bare, what Save records is already the client.
//
entry.bare = bare;
if (bare)
{
if (!size_it)
{
RECT client;
if (GetClientRect(target, &client))
{
w = client.right;
h = client.bottom;
size_it = True;
}
}
StripWindowFrame(target);
}
SetWindowPos(target, NULL, x, y, w, h,
SWP_NOZORDER | SWP_NOACTIVATE |
(size_it ? 0 : SWP_NOSIZE) | (bare ? SWP_FRAMECHANGED : 0));
++restored;
break;
}
}
fclose(file);
DEBUG_STREAM << "MFDLayout: restored " << restored << " window placement(s) from "
<< layoutFileName << "\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Write where every registered window currently is. The whole file each
// time - it is a handful of lines, and a rewrite leaves nothing
// half-written for a hard kill to catch.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
RPWindowLayout_Save()
{
if (LayoutMode() != LayoutSave || layoutWindowCount == 0)
{
return;
}
FILE *file = fopen(layoutFileName, "wt");
if (file == NULL)
{
DEBUG_STREAM << "MFDLayout: could not write " << layoutFileName
<< "\n" << std::flush;
return;
}
fputs("# RP412 window placement. RP412MFDLAYOUT=save writes this on each\n"
"# finished drag and on exit; =load restores it.\n"
"# <title>=<x>,<y>,<w>,<h>. The game window gets its size back too;\n"
"# for the display panes the size is reference only, since theirs\n"
"# follows their content.\n"
"# Append ,noframe to take that window's title bar and border off.\n"
"# Put it where you want it first - a bare window has nothing to\n"
"# drag by, so its line stops changing once the frame is gone.\n", file);
int wrote = 0;
for (int i = 0; i < layoutWindowCount; ++i)
{
LayoutWindow &entry = layoutWindows[i];
if (entry.title == NULL)
{
continue;
}
HWND entry_window = (HWND) entry.window;
if (entry_window == NULL || !IsWindow(entry_window))
{
continue;
}
//
// rcNormalPosition rather than GetWindowRect: a minimised window
// reports a nonsense rect and a maximised one reports the screen,
// and neither is what to come back to. This is the restored
// placement whatever state the window is in, so quitting from
// maximised still records where the window will reappear.
//
WINDOWPLACEMENT placement;
memset(&placement, 0, sizeof(placement));
placement.length = sizeof(placement);
if (!GetWindowPlacement(entry_window, &placement))
{
continue;
}
RECT bounds = placement.rcNormalPosition;
// the flag is the player's instruction, not something measured off
// the window, so a rewrite has to carry it back out
fprintf(file, "%s=%ld,%ld,%ld,%ld%s\n", entry.title,
(long) bounds.left, (long) bounds.top,
(long) (bounds.right - bounds.left),
(long) (bounds.bottom - bounds.top),
entry.bare ? ",noframe" : "");
++wrote;
}
fclose(file);
DEBUG_STREAM << "MFDLayout: saved " << wrote << " window placement(s) to "
<< layoutFileName << "\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Compute the display rectangle and the button rectangles, growing the
// client area to make room for the strips.
@@ -255,10 +642,20 @@ void
// edges (no border against the viewscreen behind); RIO
// addresses descend from the anchor, row-major (vRIO
// CockpitLayout::Mfd).
//
// The banks reach BEHIND the display: each button is
// mfdButtonHeight tall but only mfdIndicatorStrip of it
// clears the glass edge, so the lamp reads as a thin strip
// and the picture above/below it is the click target. Paint
// draws the buttons before the MFD image for that reason.
//-----------------------------------------------------------
int strip_h = display_height / 8;
if (strip_h < 18) strip_h = 18;
if (strip_h > 40) strip_h = 40;
int strip_h = indicatorStrip;
int button_h = buttonDepth;
// on a scaled-down cockpit, keep the two banks from meeting
int limit = (display_height + 2 * strip_h) / 2;
if (button_h > limit) button_h = limit;
if (button_h < strip_h) button_h = strip_h;
int button_w = (display_width - 3 * buttonGap) / 4;
@@ -267,56 +664,73 @@ void
for (int i = 0; i < 8; ++i)
{
int row = i / 4; // 0 = top strip, 1 = bottom strip
int row = i / 4; // 0 = top bank, 1 = bottom bank
int col = i % 4;
ScreenButton *button = &buttons[buttonCount++];
button->unit = anchorA - i;
button->amber = 0;
button->x = col * (button_w + buttonGap);
button->y = row ? (displayY + displayH) : 0;
button->y = row
? (displayY + displayH + strip_h - button_h)
: 0;
button->w = (col == 3)
? (display_width - 3 * (button_w + buttonGap))
: button_w;
button->h = strip_h;
button->h = button_h;
}
}
else if (button_style == SideColumns)
{
//-----------------------------------------------------------
// 6 buttons down each side of the glass, stacked contiguous
// and flush to the pane edges, like the pod's strips (the
// pod wires only 6 of each column's 8 addresses to buttons;
// the rest are Tesla relays). Left = anchorA ascending,
// right = anchorB.
// 6 buttons down each side of the glass (the pod wires only 6
// of each column's 8 addresses to buttons; the rest are Tesla
// relays). Left = anchorA ascending, right = anchorB.
//
// Same trick as the MFD banks, turned on its side: each
// button reaches buttonDepth in behind the map and leaves an
// indicatorStrip clearing the edge, so the lamp reads as a
// slim column and the map itself is the click target.
//-----------------------------------------------------------
int col_w = display_width / 8;
if (col_w < 20) col_w = 20;
if (col_w > 40) col_w = 40;
int strip_w = indicatorStrip;
int button_w = buttonDepth;
int button_h = display_height / 6;
// keep the two columns from meeting behind a narrow map
int limit = (display_width + 2 * strip_w) / 2;
if (button_w > limit) button_w = limit;
if (button_w < strip_w) button_w = strip_w;
displayX = col_w;
*client_width = display_width + 2 * col_w;
displayX = strip_w;
*client_width = display_width + 2 * strip_w;
for (int i = 0; i < 6; ++i)
{
int h = (i == 5) ? (display_height - 5 * button_h) : button_h;
//-------------------------------------------------------
// Take top and bottom off the legend grid separately and
// subtract: scaling a height directly would let rounding
// drift the buttons out of step with the labels.
//-------------------------------------------------------
int cell_top = mapLegendTop + i * mapLegendPitch;
int top = (cell_top * display_height) / mapLegendSpan;
int bottom =
((cell_top + mapLegendCell) * display_height) / mapLegendSpan;
int h = bottom - top;
if (h < 1) h = 1;
ScreenButton *left = &buttons[buttonCount++];
left->unit = anchorA + i;
left->amber = 1;
left->x = 0;
left->y = i * button_h;
left->w = col_w;
left->y = top;
left->w = button_w;
left->h = h;
ScreenButton *right = &buttons[buttonCount++];
right->unit = anchorB + i;
right->amber = 1;
right->x = displayX + displayW;
right->x = displayX + displayW + strip_w - button_w;
right->y = left->y;
right->w = col_w;
right->w = button_w;
right->h = h;
}
}
@@ -342,15 +756,11 @@ void
FillRect(mem, &client, (HBRUSH) GetStockObject(BLACK_BRUSH));
SetStretchBltMode(mem, COLORONCOLOR);
StretchDIBits(
mem,
displayX, displayY, displayW, displayH,
0, 0, sourceWidth, sourceHeight,
pixels, (const BITMAPINFO *) blitHeader,
DIB_RGB_COLORS, SRCCOPY
);
//---------------------------------------------------------------
// Buttons first, glass second: the MFD banks extend under the
// display so the picture is the click target, and the image
// painted over them leaves only their indicator strips showing.
//---------------------------------------------------------------
for (int i = 0; i < buttonCount; ++i)
{
const ScreenButton *button = &buttons[i];
@@ -375,6 +785,20 @@ void
DeleteObject(frame);
}
// HALFTONE area-averages the downscale (the compact glass shows the
// full 640x480 canvas at ~half size); COLORONCOLOR dropped every other
// row/column, which shredded the 1-bit vector strokes and small text.
// HALFTONE requires the brush origin be set (MSDN).
SetStretchBltMode(mem, HALFTONE);
SetBrushOrgEx(mem, 0, 0, NULL);
StretchDIBits(
mem,
displayX, displayY, displayW, displayH,
0, 0, sourceWidth, sourceHeight,
pixels, (const BITMAPINFO *) blitHeader,
DIB_RGB_COLORS, SRCCOPY
);
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old_surface);
@@ -422,6 +846,20 @@ void
// one message per frame, and queued WM_PAINTs (lowest priority) starve
// behind it - panes would freeze on their first frame.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Off screen, and stay off: the pane keeps its pixels and geometry, it
// simply stops being shown. Repaint() on a hidden window is harmless.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
MFDSplitView::Hide()
{
Check_Pointer(this);
if (window != NULL)
{
ShowWindow((HWND) window, SW_HIDE);
}
}
void
MFDSplitView::SetPosition(int x, int y)
{
@@ -433,6 +871,34 @@ void
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Re-scale to a new glass size: the pixel buffer keeps its native
// source resolution, so only the destination rectangle, the button
// geometry and the window size change.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
MFDSplitView::Resize(int display_width, int display_height)
{
Check_Pointer(this);
buttonCount = 0;
pressedIndex = -1;
int client_width = display_width;
int client_height = display_height;
LayoutButtons(buttonStyle, buttonAnchorA, buttonAnchorB,
display_width, display_height, &client_width, &client_height);
clientWidth = client_width;
clientHeight = client_height;
if (window != NULL)
{
SetWindowPos((HWND) window, NULL, 0, 0,
client_width, client_height,
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
}
}
void
MFDSplitView::Repaint()
{
+102
View File
@@ -65,6 +65,26 @@ public:
void
SetPosition(int x, int y);
// Re-scale the glass (cockpit resized): the source buffer is
// unchanged, only the destination size and the button geometry
// derived from it.
void
Resize(int display_width, int display_height);
// Take the pane off screen. Used for the Winners Circle, which wants
// the whole viewscreen: the panes overlap it like the pod's bezels,
// so hiding them uncovers the full canvas underneath.
void
Hide();
// caption and HWND, for the sticky-placement file
const char *
Title() const
{ return paneTitle; }
void *
Window()
{ return window; }
~MFDSplitView();
Logical
@@ -128,4 +148,86 @@ protected:
buttonCount;
int
pressedIndex;
// kept so the bank can be rebuilt when the cockpit re-scales
ButtonStyle
buttonStyle;
int
buttonAnchorA,
buttonAnchorB;
// the window caption, and the key this pane is saved under
const char
*paneTitle;
// True for a pane of its own on the desktop (the exploded view); the
// composited cockpit's panes are chrome-less children and cannot be
// dragged, so there is nothing to remember for them.
Logical
ownWindow;
};
//########################################################################
// Sticky window placement.
//
// The game window and, in the exploded view, each display pane are all
// draggable, but their placement is recomputed on every launch - so
// putting a window somewhere useful never survived the menu-race-menu
// loop. RP412MFDLAYOUT remembers it, in mfd_layout.cfg beside
// bindings.txt:
//
// off / 0 / unset computed placement only, no file (default)
// load / restore restore saved placement at startup, never write
// save / adjust restore, then rewrite on each finished drag and
// on teardown - the round trip
//
// One "<title>=x,y,w,h" line per window, plus an optional ",noframe"
// that takes that window's title bar and border off - for a cockpit that
// fills a monitor edge to edge without -fit's all-or-nothing, or an
// exploded pane photographed without chrome. A bare window has nothing
// to drag by, so place it first and add the flag after; Save carries the
// flag back out, since it is an instruction rather than something
// measured off the window. Windows are always built framed, so deleting
// the flag is all it takes to get the frame back.
//
// Whether the size comes back depends on the window, which is why
// Register takes it as a flag:
//
// display panes position only. A pane's size follows its content and
// its button banks, so an old size from a different
// build must not distort it. The plasma glass registers
// the same way - its size is L4PLASMASCALE's business.
// the game window position and size. Nothing derives that size - -res
// only decides how sharp the scene is, and the cockpit
// fits itself to whatever client area it is given - so
// a window sized to suit a monitor should come back
// that way, and half-restoring it would be the strange
// behaviour. RPL4.CPP registers it before the console
// screen, so the saved placement is there from the
// first frame rather than arriving when a race starts.
//
// A placement that lands on none of the monitors currently plugged in is
// ignored rather than applied - restoring the game window off screen
// would leave nothing to drag back.
//
// Lives here because the display panes were the first to need it; the
// game window joined later rather than grow a second copy of the same
// file format.
//
// Ported from BT411's BT_GLASS_LAYOUT.
//########################################################################
// Take part in the saved layout. title is the key in the file and must
// outlive the window (the callers pass string literals).
void
RPWindowLayout_Register(void *window, const char *title, Logical with_size);
void
RPWindowLayout_Forget(void *window);
// Apply the saved placement over the computed one. Call once everything
// has been built and placed.
void
RPWindowLayout_Load();
// Write every registered window's placement. No-op unless mode is save.
void
RPWindowLayout_Save();
+247 -32
View File
@@ -2,6 +2,7 @@
#pragma hdrstop
#include "l4padbindings.h"
#include "l4joy.h" // joyButtonCount / joyHatCount, the parse limits
#include <XInput.h>
#include <stdio.h>
@@ -79,6 +80,20 @@ namespace
{ "Throttle", BindAxisThrottle },
{ "LeftPedal", BindAxisLeftPedal }, { "RightPedal", BindAxisRightPedal },
{ "JoystickY", BindAxisJoystickY }, { "JoystickX", BindAxisJoystickX },
{ "Pedals", BindAxisPedals },
};
// DirectInput's axis order, which is what the joy* rows name
const NameValue kJoyAxisNames[] =
{
{ "X", BindJoyAxisX }, { "Y", BindJoyAxisY }, { "Z", BindJoyAxisZ },
{ "RX", BindJoyAxisRX }, { "RY", BindJoyAxisRY }, { "RZ", BindJoyAxisRZ },
{ "SL0", BindJoyAxisSL0 }, { "SL1", BindJoyAxisSL1 },
};
const NameValue kJoyHatNames[] =
{
{ "up", 0 }, { "right", 1 }, { "down", 2 }, { "left", 3 },
};
Logical NameEquals(const char *a, const char *b)
@@ -185,10 +200,91 @@ namespace
}
//---------------------------------------------------------------
// One line of the profile grammar
// Shared tail of the two axis-source rows: [invert] [deadzone <d>]
// [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 ParseLine(char *tokens[], int token_count, PadBindingProfile *profile)
Logical ParseAxisOptions(
char *tokens[], int token_count, int first,
Logical *invert, Scalar *deadzone, Scalar *rate,
Logical *lever = NULL)
{
for (int i = first; i < token_count; ++i)
{
if (NameEquals(tokens[i], "invert"))
{
*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))
{
return False;
}
}
else if (NameEquals(tokens[i], "rate") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], rate))
{
return False;
}
}
else
{
return False;
}
}
return True;
}
//---------------------------------------------------------------
// One line of the profile grammar. joy_slot carries the joydev
// state forward from line to line - joy rows attach to the slot
// most recently declared.
//---------------------------------------------------------------
Logical ParseLine(
char *tokens[], int token_count, PadBindingProfile *profile,
int *joy_slot)
{
//
// joydev is the one row that can be two tokens long ("joydev 1"),
// so it is answered before the four-token floor below.
//
if (NameEquals(tokens[0], "joydev") && token_count >= 2)
{
int slot = -1;
if (sscanf(tokens[1], "%d", &slot) != 1 ||
slot < 0 || slot >= BindJoyDeviceSlots)
{
return False;
}
*joy_slot = slot;
//
// The rest of the line is a product-name substring, rejoined
// with single spaces ("Saitek Pro Flight" is four tokens).
//
profile->joyDeviceMatch[slot][0] = '\0';
for (int i = 2; i < token_count; ++i)
{
if (i > 2)
{
strncat(profile->joyDeviceMatch[slot], " ",
sizeof(profile->joyDeviceMatch[slot]) -
strlen(profile->joyDeviceMatch[slot]) - 1);
}
strncat(profile->joyDeviceMatch[slot], tokens[i],
sizeof(profile->joyDeviceMatch[slot]) -
strlen(profile->joyDeviceMatch[slot]) - 1);
}
return True;
}
if (token_count < 4)
{
return False;
@@ -267,35 +363,96 @@ namespace
{
return False;
}
PadPadAxisBinding *binding = &profile->padAxes[profile->padAxisCount++];
memset(binding, 0, sizeof(*binding));
binding->source = source;
binding->axis = axis;
for (int i = 4; i < token_count; ++i)
//
// 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))
{
if (NameEquals(tokens[i], "invert"))
{
binding->invert = True;
}
else if (NameEquals(tokens[i], "deadzone") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], &binding->deadzone))
{
return False;
}
}
else if (NameEquals(tokens[i], "rate") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], &binding->rate))
{
return False;
}
}
else
{
return False;
}
return False;
}
profile->padAxes[profile->padAxisCount++] = candidate;
return True;
}
//---------------------------------------------------------------
// Generic joystick rows, all attaching to the current joydev slot
//---------------------------------------------------------------
if (NameEquals(tokens[0], "joyaxis") && NameEquals(tokens[2], "axis"))
{
int source = LookupTable(kJoyAxisNames,
sizeof(kJoyAxisNames) / sizeof(kJoyAxisNames[0]), tokens[1]);
int axis = LookupTable(kRioAxisNames,
sizeof(kRioAxisNames) / sizeof(kRioAxisNames[0]), tokens[3]);
if (source < 0 || axis < 0 ||
profile->joyAxisCount >= PadBindingProfile::maxJoyAxes)
{
return False;
}
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"))
{
int button = -1;
int address;
if (sscanf(tokens[1], "%d", &button) != 1 ||
button < 0 || button >= joyButtonCount ||
!ParseAddress(tokens[3], &address) ||
profile->joyButtonCount >= PadBindingProfile::maxJoyButtons)
{
return False;
}
Logical toggle = (token_count > 4 && NameEquals(tokens[4], "toggle"));
PadJoyButtonBinding *binding =
&profile->joyButtons[profile->joyButtonCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->button = button;
binding->address = address;
binding->toggle = toggle;
return True;
}
if (NameEquals(tokens[0], "joyhat") && token_count >= 5 &&
NameEquals(tokens[3], "button"))
{
int hat = -1;
int direction = LookupTable(kJoyHatNames,
sizeof(kJoyHatNames) / sizeof(kJoyHatNames[0]), tokens[2]);
int address;
if (sscanf(tokens[1], "%d", &hat) != 1 ||
hat < 0 || hat >= joyHatCount || direction < 0 ||
!ParseAddress(tokens[4], &address) ||
profile->joyHatCount >= PadBindingProfile::maxJoyHats)
{
return False;
}
PadJoyHatBinding *binding = &profile->joyHats[profile->joyHatCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->hat = hat;
binding->direction = direction;
binding->address = address;
return True;
}
@@ -320,10 +477,20 @@ namespace
"# key <name> axis <axis> rate <n-per-second>\n"
"# 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] [lever] [deadzone <d>] [rate <n>]\n"
"# joybutton <n> button <addr> [toggle]\n"
"# joyhat <n> <up|down|left|right> button <addr>\n"
"#\n"
"# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad\n"
"# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).\n"
"# <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. 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"
@@ -336,6 +503,44 @@ namespace
"# back on release; 'rate' walks the axis by <n> per second and the\n"
"# position sticks (the throttle). Every lamp button is also clickable\n"
"# on the on-screen cockpit, so unbound addresses are never stranded.\n"
"#\n"
"# ---- Flight sticks, HOTAS throttles and rudder pedals --------------\n"
"#\n"
"# EASIEST: run joyconfig.bat once. It asks you to move each control,\n"
"# works out which device and axis you moved and which way round it\n"
"# reads, and writes the joy* rows below a marker line at the end of\n"
"# this file. Everything you have written yourself is kept. Xbox-class\n"
"# pads need none of this - they are the pad* rows above.\n"
"#\n"
"# By hand: joydev picks the device for the rows that follow it - a\n"
"# name substring binds that product, a bare slot number binds the Nth\n"
"# stick Windows lists. <src> for joyaxis is the DirectInput axis name,\n"
"# X Y Z RX RY RZ SL0 SL1: a twist grip is usually RZ and a HOTAS\n"
"# throttle usually Z or SL0. A joyaxis on Throttle with no 'rate' is\n"
"# treated as a real lever and OWNS the channel - its full travel is\n"
"# 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"
"# joyaxis RZ axis Pedals deadzone 0.08\n"
"# 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"
@@ -376,8 +581,11 @@ namespace
"pad DPadRight button 0x43 # Hat Right\n"
"pad LeftShoulder button 0x3D # Panic\n"
"pad RightShoulder button 0x3F # Throttle button (reverse)\n"
"pad Start button 0x37 # config 1\n"
"pad Back button 0x36 # config 2\n"
"\n"
"# Start and Back are left free - map them to whatever you want, e.g.\n"
"# the config buttons on the Upper Right MFD:\n"
"#pad Start button 0x37\n"
"#pad Back button 0x36\n"
"\n"
"# ---- Upper MFD bank: the number row is the top MFD row and the\n"
"# ---- QWERTY row is the row under it, left to right across the\n"
@@ -496,6 +704,7 @@ void
char line[256];
int line_number = 0;
int error_count = 0;
int joy_slot = 0; // joy rows before any joydev belong to slot 0
const char *cursor = source;
while (*cursor != '\0')
{
@@ -520,7 +729,7 @@ void
{
continue;
}
if (!ParseLine(tokens, token_count, profile))
if (!ParseLine(tokens, token_count, profile, &joy_slot))
{
++error_count;
DEBUG_STREAM << "PadBindings: " << kBindingsFileName << " line "
@@ -539,4 +748,10 @@ void
<< profile->padAxisCount << " pad axes"
<< (error_count ? " (with rejected lines)" : "")
<< "\n" << std::flush;
if (profile->joyAxisCount || profile->joyButtonCount || profile->joyHatCount)
{
DEBUG_STREAM << "PadBindings: joystick - " << profile->joyAxisCount
<< " axes, " << profile->joyButtonCount << " buttons, "
<< profile->joyHatCount << " hat directions\n" << std::flush;
}
}
+90 -1
View File
@@ -18,12 +18,32 @@
// key <name> axis <axis> deflect <n> | rate <n>
// pad <button> button <addr> [toggle]
// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
// joydev <slot> [product-name substring...]
// joyaxis <src> axis <axis> [invert] [lever] [deadzone <d>] [rate <n>]
// joybutton <n> button <addr> [toggle]
// joyhat <n> <up|down|left|right> button <addr>
//
// <addr> is a RIO input address: lamp buttons 0x00-0x47, internal
// keypad 0x50-0x5F, external keypad 0x60-0x6F. Loaded from
// bindings.txt beside the exe; written there (self-documenting, with
// the full default profile) on first run. Bad lines are logged and
// skipped, good lines always win.
//
// The joy* rows drive generic DirectInput devices - flight sticks,
// HOTAS throttles, twist grips, rudder pedals (L4JOY.h). They attach to
// the most recent joydev slot, or slot 0 if no joydev came first. A slot
// naming a product substring binds THAT device; a bare slot binds the
// Nth attached non-XInput device. Source names follow the DirectInput
// 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
@@ -33,6 +53,14 @@ enum PadBindRioAxis
BindAxisRightPedal,
BindAxisJoystickY,
BindAxisJoystickX,
//
// A signed composite, not a channel the pod has: positive presses
// the right pedal, negative the left. One physical control - a
// rudder bar, a twist grip, a stick axis - works the pedal pair the
// way a foot never could, one or the other and never both. It
// decomposes into the real pair when the poll applies it.
//
BindAxisPedals,
BindAxisCount
};
@@ -89,6 +117,55 @@ struct PadPadAxisBinding
Scalar rate; // 0 = direct position, >0 = speed integrate
};
//
// Generic joystick (DirectInput - L4JOY.h). device is a joydev SLOT,
// resolved to a live device at poll time by the slot's name substring or
// by its ordinal, so unplugging and replugging does not rewrite the file.
//
enum { BindJoyDeviceSlots = 4 };
enum PadBindJoyAxis
{
BindJoyAxisX = 0,
BindJoyAxisY,
BindJoyAxisZ,
BindJoyAxisRX,
BindJoyAxisRY,
BindJoyAxisRZ,
BindJoyAxisSL0,
BindJoyAxisSL1,
BindJoyAxisCount
};
struct PadJoyAxisBinding
{
int device; // joydev slot
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
};
struct PadJoyButtonBinding
{
int device; // joydev slot
int button; // 0-31
int address;
Logical toggle;
Logical wasDown;
Logical latched;
};
struct PadJoyHatBinding
{
int device; // joydev slot
int hat; // 0-3
int direction; // 0 up, 1 right, 2 down, 3 left
int address;
};
struct PadBindingProfile
{
enum
@@ -96,7 +173,10 @@ struct PadBindingProfile
maxKeyButtons = 128,
maxKeyAxes = 32,
maxPadButtons = 32,
maxPadAxes = 16
maxPadAxes = 16,
maxJoyAxes = 24,
maxJoyButtons = 48,
maxJoyHats = 16
};
PadKeyButtonBinding keyButtons[maxKeyButtons];
@@ -107,6 +187,15 @@ struct PadBindingProfile
int padButtonCount;
PadPadAxisBinding padAxes[maxPadAxes];
int padAxisCount;
PadJoyAxisBinding joyAxes[maxJoyAxes];
int joyAxisCount;
PadJoyButtonBinding joyButtons[maxJoyButtons];
int joyButtonCount;
PadJoyHatBinding joyHats[maxJoyHats];
int joyHatCount;
// "" = take the slot's ordinal attached device
char joyDeviceMatch[BindJoyDeviceSlots][64];
};
// Load bindings.txt from the working directory into the profile,
+235 -1
View File
@@ -3,6 +3,7 @@
#include "l4padrio.h"
#include "l4keylight.h"
#include "l4joy.h"
#include <XInput.h>
#pragma comment(lib, "xinput9_1_0.lib")
@@ -41,6 +42,70 @@ namespace
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0;
}
//
// A generic stick axis is already normalized -1..1, so the deadzone
// is a plain cut about centre with the remainder rescaled - press
// just past the edge and you get just past zero, not a step.
//
Scalar JoyAxisValue(Scalar raw, Scalar deadzone)
{
if (deadzone <= 0.0f)
{
return raw;
}
if (raw > -deadzone && raw < deadzone)
{
return (Scalar) 0;
}
Scalar value = (raw > 0.0f)
? (raw - deadzone) / (1.0f - deadzone)
: (raw + deadzone) / (1.0f - deadzone);
if (value > 1.0f) value = 1.0f;
if (value < -1.0f) value = -1.0f;
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
// both of their neighbours, which is how a four-way hat is read.
//
Logical JoyHatHeld(int centidegrees, int direction)
{
if (centidegrees < 0)
{
return False;
}
int degrees = (centidegrees / 100) % 360;
switch (direction)
{
case 0: return (degrees >= 315 || degrees <= 45) ? True : False;
case 1: return (degrees >= 45 && degrees <= 135) ? True : False;
case 2: return (degrees >= 135 && degrees <= 225) ? True : False;
case 3: return (degrees >= 225 && degrees <= 315) ? True : False;
}
return False;
}
void KeyLightLog(const char *line)
{
DEBUG_STREAM << line << "\n" << std::flush;
@@ -166,6 +231,29 @@ PadRIO::PadRIO()
activeInstance = this;
DEBUG_STREAM << "PadRIO: virtual RIO active (XInput pad + keyboard)\n" << std::flush;
//
// Only open DirectInput when the profile actually asks for it. A
// player on keyboard and pad should not pay for an enumeration of
// every HID on the machine, and joyconfig.bat is what writes the
// rows that turn this on.
//
if (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0)
{
int found = RPJoyInit();
DEBUG_STREAM << "PadRIO: joystick bindings present, " << found
<< " generic device(s) attached\n" << std::flush;
for (int d = 0; d < found; ++d)
{
const RPJoyDeviceState *state = RPJoyDevice(d);
if (state != NULL)
{
DEBUG_STREAM << "PadRIO: [" << d << "] " << state->name
<< "\n" << std::flush;
}
}
}
}
PadRIO::~PadRIO()
@@ -244,6 +332,11 @@ void
profile.padButtons[i].latched = False;
profile.padButtons[i].wasDown = False;
}
for (int i = 0; i < profile.joyButtonCount; ++i)
{
profile.joyButtons[i].latched = False;
profile.joyButtons[i].wasDown = False;
}
}
void
@@ -391,6 +484,81 @@ void
}
}
}
//---------------------------------------------------------------
// Generic joysticks. The slots are resolved every poll rather than
// cached, so a stick unplugged mid-race simply stops answering and
// one plugged back in picks up where it left off.
//---------------------------------------------------------------
int joyDevice[BindJoyDeviceSlots];
Logical joyLive = False;
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
{
joyDevice[slot] = -1;
}
if (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0)
{
RPJoyPoll();
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
{
joyDevice[slot] = (profile.joyDeviceMatch[slot][0] != '\0')
? RPJoyFindDevice(profile.joyDeviceMatch[slot])
: ((RPJoyDevice(slot) != NULL) ? slot : -1);
if (joyDevice[slot] >= 0)
{
joyLive = True;
}
}
}
for (int i = 0; i < profile.joyButtonCount; ++i)
{
PadJoyButtonBinding *binding = &profile.joyButtons[i];
const RPJoyDeviceState *state =
(binding->device >= 0 && binding->device < BindJoyDeviceSlots)
? RPJoyDevice(joyDevice[binding->device]) : NULL;
Logical down = (state != NULL) &&
(state->buttons & (1u << binding->button)) != 0;
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
}
binding->wasDown = down;
if (binding->toggle ? binding->latched : down)
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 &&
binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < profile.joyHatCount; ++i)
{
const PadJoyHatBinding *binding = &profile.joyHats[i];
const RPJoyDeviceState *state =
(binding->device >= 0 && binding->device < BindJoyDeviceSlots)
? RPJoyDevice(joyDevice[binding->device]) : NULL;
if (state != NULL &&
JoyHatHeld(state->hat[binding->hat], binding->direction))
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 &&
binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < buttonUnits; ++i)
{
if (screenButton[i])
@@ -502,6 +670,70 @@ void
}
}
//---------------------------------------------------------------
// Joystick axes. A physical throttle lever is the one source that
// does not add into the pile: it has an absolute position, so its
// full travel IS the channel and it takes ownership rather than
// nudging an accumulator that a spring-centred pad stick has to.
//---------------------------------------------------------------
Logical throttleLever = False;
Scalar throttleLeverValue = (Scalar) 0;
if (joyLive)
{
for (int i = 0; i < profile.joyAxisCount; ++i)
{
const PadJoyAxisBinding *binding = &profile.joyAxes[i];
if (binding->device < 0 || binding->device >= BindJoyDeviceSlots)
{
continue;
}
const RPJoyDeviceState *state = RPJoyDevice(joyDevice[binding->device]);
if (state == NULL)
{
continue;
}
Scalar raw = (Scalar) state->axis[binding->source];
if (binding->invert)
{
raw = -raw;
}
if (binding->axis == BindAxisThrottle && binding->rate == 0.0f)
{
// -1..1 of lever travel onto the 0..1 the pod runs on
throttleLeverValue = (raw + 1.0f) * 0.5f;
throttleLever = True;
continue;
}
Scalar value = binding->lever
? JoyLeverValue(raw, binding->deadzone)
: JoyAxisValue(raw, binding->deadzone);
if (binding->rate > 0.0f)
{
rate[binding->axis] += value * binding->rate;
}
else
{
deflect[binding->axis] += value;
}
}
}
//
// The composite pedal axis becomes the pair the pod actually has.
// One signed source presses one pedal or the other, never both,
// which is what a rudder bar or a twist grip does.
//
Scalar pedals = deflect[BindAxisPedals];
if (pedals > 0.0f)
{
deflect[BindAxisRightPedal] += pedals;
}
else if (pedals < 0.0f)
{
deflect[BindAxisLeftPedal] += -pedals;
}
throttleAccum = Clamp01(throttleAccum + rate[BindAxisThrottle] * delta_t);
Scalar x = deflect[BindAxisJoystickX];
@@ -511,7 +743,9 @@ void
if (y > 1.0f) y = 1.0f;
if (y < -1.0f) y = -1.0f;
Throttle = Clamp01(throttleAccum + deflect[BindAxisThrottle]);
Throttle = throttleLever
? Clamp01(throttleLeverValue)
: Clamp01(throttleAccum + deflect[BindAxisThrottle]);
LeftPedal = Clamp01(deflect[BindAxisLeftPedal]);
RightPedal = Clamp01(deflect[BindAxisRightPedal]);
// The profile encodes the pod's stick sign convention; L4PADFLIP
+27
View File
@@ -3,6 +3,7 @@
#include "l4plasmascreen.h"
#include "l4app.h"
#include "l4mfdview.h" // RPWindowLayout_*
namespace
{
@@ -45,6 +46,15 @@ namespace
}
break;
case WM_EXITSIZEMOVE:
//
// Dragged to a new spot. Write the whole arrangement now rather
// than trusting teardown, so a hard kill still leaves what was
// on screen. No-op unless RP412MFDLAYOUT is save.
//
RPWindowLayout_Save();
return 0;
case WM_CLOSE:
// The glass is part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE);
@@ -176,6 +186,7 @@ PlasmaScreen::~PlasmaScreen()
}
if (window != NULL)
{
RPWindowLayout_Forget(window);
RemovePropA((HWND) window, "PlasmaBitmapInfo");
RemovePropA((HWND) window, "PlasmaPixels");
DestroyWindow((HWND) window);
@@ -275,6 +286,22 @@ void
SetPropA((HWND) window, "PlasmaPixels", pixelBuffer->Data.MapPointer);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE);
activeInstance = this;
//
//-----------------------------------------------------------
// Join the saved layout. Position only, like the display
// panes: the glass is 128x32 at L4PLASMASCALE, so its size
// is a setting rather than something to drag.
//
// Loading here rather than leaving it to SVGA16 keeps this
// independent of which is built first - the glass comes from
// the gauge renderer, the panes from the video mode, and
// whichever runs second re-applies a placement the first
// already has.
//-----------------------------------------------------------
//
RPWindowLayout_Register(window, "Plasma Display", False);
RPWindowLayout_Load();
}
else
{
+885 -82
View File
File diff suppressed because it is too large Load Diff
+61
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
@@ -308,6 +319,26 @@ private:
SplitViewCount
};
public:
//------------------------------------------------------------------
// Glass size of one secondary display, in whatever units the caller
// asked for (canvas units while clamping, device pixels on return).
//------------------------------------------------------------------
struct GlassSize
{
int w, h;
};
protected:
//------------------------------------------------------------------
// The six displays sized for a given canvas scale, honouring the
// player's per-display scaling and clamped to keep the pod
// arrangement legal. Every layout goes through here, first and
// resized alike, so the two can never disagree.
//------------------------------------------------------------------
static void
CockpitGlassSizes(int scale, GlassSize sizes[SplitViewCount]);
void
FillSplitMFD(
SplitViewID view,
@@ -315,12 +346,42 @@ private:
int shift
);
public:
//------------------------------------------------------------------
// Fit the cockpit into a client area: one uniform scale keeps the
// 1920x1080 canvas at its own aspect (so an ultrawide letterboxes
// rather than stretching), centred in whatever space it is given.
// Re-run whenever the cockpit window is resized.
//------------------------------------------------------------------
void
LayoutCockpit(int client_w, int client_h);
static SVGA16 *
GetCockpit()
{ return activeCockpit; }
//------------------------------------------------------------------
// Take the six secondary displays off screen and leave the whole
// canvas to the viewscreen. The Winners Circle wants the podium
// uncluttered, and the panes are only bezels over the 3D - hiding
// them uncovers what is already being drawn behind.
//
// There is no matching show: the mission is over by the time this
// is called, and the next race builds a fresh cockpit.
//------------------------------------------------------------------
void
HideSecondaryDisplays();
protected:
Logical
splitViews;
MFDSplitView
*splitView[SplitViewCount];
HWND
cockpitViewscreen; // child pane the 3D scene presents into
static SVGA16
*activeCockpit; // the instance owning the cockpit window
};
//########################################################################
+514 -22
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;
@@ -1797,6 +1801,19 @@ DPLRenderer::DPLRenderer(
backgroundGreen = 0.0f;
backgroundBlue = 0.0f;
viewAngle = 30.0f;
// the Winners Circle camera is off until the podium asks for it. No
// restore path is needed: a fresh DPLRenderer is built per mission, so
// the next race re-reads viewangle from RPDPL.INI.
mPresentationCamera = False;
mPresentationFog = False;
mPresentationAspect = 0.0f;
mPresentationFadeTime = 0.0f;
mPresentationFogRed = 0.0f;
mPresentationFogGreen = 0.0f;
mPresentationFogBlue = 0.0f;
mPresentationFogNear = 0.0f;
mPresentationFogFar = 0.0f;
D3DXMatrixIdentity(&mPresentationView);
dplMainView = NULL;
dplDeathZone = NULL;
dplMainZone = NULL;
@@ -2343,10 +2360,53 @@ void
}
break;
case winnersCircleFogStyle:
//
// The presentation shot, restored from the DPL body below. The
// stand sits far off the track in open ground, so the track's own
// fog leaves it in the dark - this is the lighter blue-violet the
// original used to lift it, with the fog pushed back to 100/1050.
//
// HACK!! This really shouldn't reset the clip planes, but since
// it only happens at the end of the review, it should be safe for now.
//
fogRed = 0.32f;
fogGreen = 0.30f;
fogBlue = 0.65f;
fogNear = 100.0f;
fogFar = 1050.0f;
// the per-frame FOGSTART/FOGEND come from these
currentFogNear = fogNear;
currentFogFar = fogFar;
clipNear = 0.25f;
clipFar = 1100.0f;
// tells the end-of-mission fade to stand down
mPresentationFog = True;
//
// Crop to the shape the stand was composed for. 4:3 unless
// RP412PODIUMASPECT says otherwise; 0 turns it off and lets
// the podium run full width.
//
mPresentationAspect = 4.0f / 3.0f;
{
const char *aspect = getenv("RP412PODIUMASPECT");
if (aspect != NULL)
{
mPresentationAspect = (float) atof(aspect);
}
}
// unconditional: fogUpdating is off while a vehicle drives its own
// headlight fog, and the podium overrides all of that
if (mDevice != NULL)
{
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
(int)(255 * fogBlue)));
}
// dpl_SetViewClipPlanes ( dplMainView, 0.25f, 1100.0f );
// dpl_SetViewFog(
// dplMainView,
@@ -5608,6 +5668,24 @@ void
intersect_mask = INTERSECT_ALL;
}
//
// RP412RENDERDIAG=1: name what actually gets built, so a missing
// Winners Circle can be told from one that is simply out of shot.
//
{
static const char *diag = getenv("RP412RENDERDIAG");
if (diag != NULL && atoi(diag) != 0)
{
DEBUG_STREAM << "RenderDiag: building class"
<< entity->GetClassID() << " res "
<< entity->GetResourceID() << " at "
<< entity->localOrigin.linearPosition.x << ","
<< entity->localOrigin.linearPosition.y << ","
<< entity->localOrigin.linearPosition.z
<< "\n" << std::flush;
}
}
Logical first_object = True;
video_iterator.First();
@@ -5909,11 +5987,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
@@ -5997,12 +6148,110 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
if (mCamShipHUD)
mCamShipHUD->Execute();
//
// The eye renderable has just written D3DTS_VIEW from the viewpoint
// entity. For the Winners Circle the shot comes from off the stand
// instead, so stomp it here - before the read-back below that feeds
// every draw call.
//
if (mPresentationCamera)
{
mDevice->SetTransform(D3DTS_VIEW, &mPresentationView);
}
//
// RP412RENDERDIAG=1: report what the frame is actually made of once the
// mission is ending, which is the only way to tell an empty scene from a
// misaimed camera.
//
{
static const char *diag = getenv("RP412RENDERDIAG");
static int reported = 0;
if (diag != NULL && atoi(diag) != 0 &&
currentAppState == Application::EndingMission && reported < 4)
{
++reported;
int dynamic_count = 0;
SChainIteratorOf<HierarchicalDrawComponent*> diag_iter(&mRenderables);
while (diag_iter.ReadAndNext() != NULL)
{
++dynamic_count;
}
DEBUG_STREAM << "RenderDiag: ending frame " << reported
<< " dead=" << (l4_application->IsDead() ? 1 : 0)
<< " statics=" << (int) mConsolidatedStaticObjects.size()
<< " renderables=" << dynamic_count
<< " camera=" << (mPresentationCamera ? 1 : 0)
<< "\n" << std::flush;
}
}
gNumBatches = 0;
static Time lastFrameTime = mTargetRenderTime;
Scalar dT = mTargetRenderTime - lastFrameTime;
lastFrameTime = mTargetRenderTime;
currentFrameTime = Now();
//
// Fade the presentation up out of black, if one is running. Same lever
// the end-of-mission fade pulls: scale the fog colour and both fog
// distances, here from nothing up to what the podium asked for.
//
if (mPresentationFadeTime > 0.0f)
{
Scalar remaining = mPresentationFadeEnd - Now();
Scalar fade = 1.0f - (remaining / mPresentationFadeTime);
if (fade >= 1.0f)
{
fade = 1.0f;
mPresentationFadeTime = 0.0f; // done
}
if (fade < 0.0f)
{
fade = 0.0f;
}
fogRed = mPresentationFogRed * fade;
fogGreen = mPresentationFogGreen * fade;
fogBlue = mPresentationFogBlue * fade;
currentFogNear = mPresentationFogNear * fade;
currentFogFar = mPresentationFogFar * fade;
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
(int)(255 * fogBlue)));
}
//
// Pillarbox: black the whole target first, then narrow the viewport so
// everything after this - clear, scene, reticle - lands inside the crop
// and the surround stays black. Restored after Present.
//
if (mPresentationAspect > 0.0f)
{
D3DVIEWPORT9 full;
full.X = 0;
full.Y = 0;
full.Width = mPresentParams.BackBufferWidth;
full.Height = mPresentParams.BackBufferHeight;
full.MinZ = 0.0f;
full.MaxZ = 1.0f;
mDevice->SetViewport(&full);
mDevice->Clear(0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0, 0, 0), 1.0f, 0);
DWORD cropped = (DWORD)
(mPresentParams.BackBufferHeight * mPresentationAspect);
if (cropped > mPresentParams.BackBufferWidth)
{
cropped = mPresentParams.BackBufferWidth;
}
D3DVIEWPORT9 crop = full;
crop.Width = cropped;
crop.X = (mPresentParams.BackBufferWidth - cropped) / 2;
mDevice->SetViewport(&crop);
}
DWORD currentFog;
mDevice->GetRenderState(D3DRS_FOGCOLOR, &currentFog);
hr = mDevice->Clear(0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, currentFog, 1.0f, 0);
@@ -6152,15 +6401,70 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
mDevice->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1);
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
if (mReticle && !l4_application->IsDead())
//
// No gunsight on the podium - the race is over and nothing is being
// aimed at. It is drawn in the 2D pass, so it survives everything else
// the presentation turns off.
//
if (mReticle && !l4_application->IsDead() && !InPresentation())
mReticle->Render(0, &viewTransform);
if (mCamShipHUD)
if (mCamShipHUD && !InPresentation())
mCamShipHUD->Render(0, &viewTransform);
hr = mDevice->EndScene();
hr = mDevice->Present(NULL, NULL, gMainPresentWindow, NULL);
// hand the whole target back
if (mPresentationAspect > 0.0f)
{
D3DVIEWPORT9 full;
full.X = 0;
full.Y = 0;
full.Width = mPresentParams.BackBufferWidth;
full.Height = mPresentParams.BackBufferHeight;
full.MinZ = 0.0f;
full.MaxZ = 1.0f;
mDevice->SetViewport(&full);
}
//
// RP412RENDERDIAG=1: did the ending frame actually reach the window, and
// what colour did it clear to? A black clear with a blue-violet fog set
// means something is overwriting the fog behind us.
//
{
static const char *diag = getenv("RP412RENDERDIAG");
static int presented = 0;
if (diag != NULL && atoi(diag) != 0 &&
application->GetApplicationState() == Application::EndingMission &&
presented < 4)
{
++presented;
DWORD fog_now = 0;
mDevice->GetRenderState(D3DRS_FOGCOLOR, &fog_now);
HWND present_window = (HWND) gMainPresentWindow;
RECT present_rect;
present_rect.left = present_rect.top = 0;
present_rect.right = present_rect.bottom = 0;
if (present_window != NULL)
{
GetClientRect(present_window, &present_rect);
}
DEBUG_STREAM << "RenderDiag: present " << presented
<< " hr=0x" << std::hex << (unsigned int) hr << std::dec
<< " fogcolor=0x" << std::hex << (unsigned int) fog_now << std::dec
<< " window=" << (void*) present_window
<< " visible=" << (present_window != NULL &&
IsWindowVisible(present_window) ? 1 : 0)
<< " rect=" << present_rect.right << "x" << present_rect.bottom
<< " backbuf=" << mPresentParams.BackBufferWidth << "x"
<< mPresentParams.BackBufferHeight
<< "\n" << std::flush;
}
}
if (hr == D3DERR_DEVICELOST)
{
int bbCount = mPresentParams.BackBufferCount;
@@ -6736,27 +7040,167 @@ void
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
//
// Un-stubbed for the Winners Circle, which asks for 45 degrees to get all
// eight racers in frame. The DPL body below it built a dpl_View; the D3D9
// path builds the same two matrices DPLReadINIPage does and pushes them,
// because mProjectionMatrix only reaches the device on the LaunchingMission
// edge and on the sky-pass restore - rebuilding alone would not show until
// the next frame.
//
// Pass degrees, as the INI does. RPDPL.INI ships viewangle=40, which is what
// to hand back afterwards - not the 30 in the constructor, which the INI
// overwrites at load.
//
void DPLRenderer::SetViewAngle(Degree new_angle)
{
//STUBBED: DPL RB 1/14/07
//Check(this);
////
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//// Convert From Degree To Radian
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
////
//Radian view_angle;
//view_angle = new_angle;
//viewAngle = view_angle;
//viewRatio = tan(viewAngle/2.0f);
////
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//// Calc Aspect Ratio and Set View Projection
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
////
//aspectRatio = (float) y_size / (float) x_size;
//dpl_SetViewProjection ( dplMainView, -1.0f, -aspectRatio, 1.0f, aspectRatio, 1.0f/viewRatio);
//dpl_FlushView(dplMainView);
Check(this);
viewAngle = (float) new_angle.angle;
Radian view_angle;
view_angle = new_angle;
// tan(half angle): the culling helper GetViewRatio() reads this, and
// nothing has written it since the DPL body was stubbed out
viewRatio = (float) tan(view_angle / 2.0f);
aspectRatio = (float) y_size / (float) x_size;
// pillarboxed shots render into a narrower viewport, so the projection
// has to use that shape or the scene comes out squashed rather than
// cropped
float projection_aspect = (mPresentationAspect > 0.0f)
? mPresentationAspect
: ((float) x_size / (float) y_size);
D3DXMatrixIdentity(&mProjectionMatrix);
D3DXMatrixPerspectiveFovLH(
&mProjectionMatrix,
viewAngle * (PI / 180.0f),
projection_aspect,
clipNear,
clipFar);
mProjectionMatrix(0, 0) *= -1; // handedness flip - the view is RH
mDecalProjectionMatrix = mProjectionMatrix;
mDecalProjectionMatrix._33 -= mDecalEpsilon;
if (mDevice != NULL)
{
mDevice->SetTransform(D3DTS_PROJECTION, &mProjectionMatrix);
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The presentation camera. DPLEyeRenderable writes D3DTS_VIEW from the
// viewpoint entity every frame; ExecuteImplementation reads it straight back
// out of the device and hands it to every draw call. Overriding it between
// those two points is enough to move the shot without touching the eye
// renderable or building a CameraShip.
//
void
DPLRenderer::SetPresentationCamera(const Point3D &eye, const Point3D &look_at)
{
Check(this);
D3DXVECTOR3 from((float) eye.x, (float) eye.y, (float) eye.z);
D3DXVECTOR3 at((float) look_at.x, (float) look_at.y, (float) look_at.z);
//
// LookAt*LH*, to match the LH projection. The two differ by exactly the
// sign of the view direction, and RH here points the camera the opposite
// way: ask to look down at the stand and you get sky behind you.
//
// The engine's own eye renderable does use RH, and is right to - its
// forward and up come out of the entity matrix already in that
// convention. A camera aimed with plain world coordinates does not, so
// it wants the handedness the projection was built with.
//
D3DXVECTOR3 up(0.0f, 1.0f, 0.0f);
D3DXMatrixLookAtLH(&mPresentationView, &from, &at, &up);
mPresentationCamera = True;
}
void
DPLRenderer::ClearPresentationCamera()
{
Check(this);
mPresentationCamera = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Give the pilot a vehicle to look at. Their own is built insideEntity - a
// cockpit and no hull - so on the podium they would be the one empty spot.
// outsideEntity is what builds the exterior, and the renderables for it are
// added to what is already there.
//
void
DPLRenderer::ShowViewpointFromOutside()
{
Check(this);
Entity *viewpoint = GetLinkedEntity();
if (viewpoint == NULL)
{
DEBUG_STREAM << "WinnersCircle: no viewpoint entity to turn around\n"
<< std::flush;
return;
}
//
// Add the exterior alongside what is already there rather than tearing
// the entity down and rebuilding it. The teardown path is safe for
// scenery going out of range, but not for the viewpoint entity: it is
// never uninteresting, the eye renderable goes with it, and doing it
// mid-mission stops the scene rendering altogether.
//
// This is what NotifyOfNewInterestingEntity does, minus the teardown
// and with the view type forced.
//
Check(application);
ResourceFile *resource_file = application->GetResourceFile();
Check(resource_file);
ResourceDescription *video_resource = resource_file->SearchList(
viewpoint->GetResourceID(),
ResourceDescription::VideoModelResourceType);
if (video_resource == NULL)
{
DEBUG_STREAM << "WinnersCircle: own vehicle has no exterior model\n"
<< std::flush;
return;
}
video_resource->Lock();
MakeEntityRenderables(viewpoint, video_resource, outsideEntity);
video_resource->Unlock();
DEBUG_STREAM << "WinnersCircle: own vehicle given an exterior\n"
<< std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Come up out of the black. SetFogStyle(winnersCircleFogStyle) has already
// put the target fog in place, so remember it and ramp toward it - the same
// multiply the end-of-mission fade uses, run the other way.
//
void
DPLRenderer::StartPresentationFadeIn(Scalar seconds)
{
Check(this);
mPresentationFogRed = fogRed;
mPresentationFogGreen = fogGreen;
mPresentationFogBlue = fogBlue;
mPresentationFogNear = currentFogNear;
mPresentationFogFar = currentFogFar;
mPresentationFadeTime = (seconds > 0.0f) ? seconds : 0.01f;
mPresentationFadeEnd = Now();
mPresentationFadeEnd += mPresentationFadeTime;
}
//
//#############################################################################
@@ -6800,8 +7244,56 @@ void DPLRenderer::SortAndReloadNameBitmaps()
LoadBitSliceTexture(name_bitmap, mNameTextures[index]);
}
}
LoadOrdinalBitmaps();
BindNamePlates();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Put the callsigns on the Winners Circle plates.
//
// mNameTextures is indexed by finishing place (rank + 1), which is how the
// signs are numbered, so the plate beside each spot names whoever is standing
// on it. The plates are static geometry and have already been merged into the
// consolidated mesh by now, so it is the consolidated draw ops that have to be
// re-pointed - the objects they were built from are no longer drawn.
//
void DPLRenderer::BindNamePlates()
{
int name_count = (int) (sizeof(mNameTextures) / sizeof(mNameTextures[0]));
int bound = 0;
int found = 0;
std::list<d3d_OBJECT*>::const_iterator iter;
for (iter = mConsolidatedStaticObjects.begin();
iter != mConsolidatedStaticObjects.end(); ++iter)
{
d3d_OBJECT *object = *iter;
if (object == NULL)
{
continue;
}
for (int op = 0; op < object->GetDrawOpCount(); ++op)
{
L4DRAWOP *draw_op = object->GetDrawOp(op);
int plate = d3d_OBJECT::NamePlateFor(draw_op->texture.texture);
if (plate == 0)
{
continue;
}
++found;
if (plate < name_count && mNameTextures[plate] != NULL)
{
draw_op->texture.texture = mNameTextures[plate];
++bound;
}
}
}
DEBUG_STREAM << "NamePlates: " << bound << " of " << found
<< " plates given a callsign\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+63
View File
@@ -310,10 +310,57 @@ public:
void SetViewAngle(Degree new_angle);
//------------------------------------------------------------------
// Winners Circle presentation camera.
//
// The eye normally rides the viewpoint entity - your own vehicle -
// and DPLEyeRenderable writes D3DTS_VIEW from it each frame. For the
// podium the shot has to come from off the stand looking back, with
// every racer including you in frame, so this overrides that view
// for as long as it is set. Culling still runs from the viewpoint
// entity, which is standing on the podium, so the stand and everyone
// on it stay resident.
//
// Eye and target are world space. ClearPresentationCamera() hands
// the view back to the entity.
//------------------------------------------------------------------
void SetPresentationCamera(const Point3D &eye, const Point3D &look_at);
void ClearPresentationCamera();
//------------------------------------------------------------------
// True once the Winners Circle has taken the screen. The end-of-
// mission fade-to-black checks this and stands down: it multiplies
// the fog to black over FADE_OUT_TIME, which would otherwise black
// out the podium no matter what is drawn.
//------------------------------------------------------------------
Logical InPresentation() const { return mPresentationFog; }
//------------------------------------------------------------------
// Bring the presentation up out of black over the given seconds. The
// race's own fade-to-black has already run by this point; this is
// its mirror image, ramping the fog colour and both fog distances
// back up from nothing to what the Winners Circle asked for.
//------------------------------------------------------------------
void StartPresentationFadeIn(Scalar seconds);
//------------------------------------------------------------------
// Rebuild the viewpoint entity - the pilot's own vehicle - as an
// exterior. It is normally built insideEntity, which is a cockpit
// and no hull, so from any camera that is not in it you see nothing
// where your own vehicle should be. Disconnected_Eye is the engine's
// own switch for this: "so higher level renderers can fix the eye in
// one spot and watch the viewpoint entity drive around."
//------------------------------------------------------------------
void ShowViewpointFromOutside();
unsigned int* MakeBitSliceStorage();
void SortAndReloadNameBitmaps();
// re-point the Winners Circle sign faces at the current callsign
// textures; the plates live in the consolidated static mesh
void BindNamePlates();
void LoadNameBitmaps();
void LoadOrdinalBitmaps();
@@ -438,6 +485,22 @@ private:
D3DXMATRIX mDecalProjectionMatrix;
float mDecalEpsilon;
// Winners Circle: overrides D3DTS_VIEW while set (see
// SetPresentationCamera)
D3DXMATRIX mPresentationView;
Logical mPresentationCamera;
Logical mPresentationFog;
// >0 pillarboxes the scene to this aspect. The Winners Circle was
// built to be looked at from a 4:3 pod monitor and its platform runs
// out at the sides of a 16:9 canvas, so the shot is cropped to the
// shape it was composed for and the surround left black.
float mPresentationAspect;
// fade-in: end time, duration, and the fog it is ramping toward
Time mPresentationFadeEnd;
Scalar mPresentationFadeTime;
float mPresentationFogRed, mPresentationFogGreen, mPresentationFogBlue;
float mPresentationFogNear, mPresentationFogFar;
void FindBestAdapterIndices(bool isWindowed);
float mCloudRed, mCloudGreen, mCloudBlue;
+14
View File
@@ -2283,6 +2283,20 @@ void
//
case FadeOutState:
{
//
// The Winners Circle has taken the screen, so leave it alone.
// This fade multiplies the fog colour and both fog distances
// toward zero every frame, which blacks out the whole scene -
// correct when the race just ends, fatal to a podium shown
// afterwards. Stand down and stop running.
//
if (myRenderer->InPresentation())
{
myState = WaitForStartState;
myRenderer->RemoveDynamicRenderable(this);
break;
}
percent_time_left = (myStateTimer - current_time)/FADE_OUT_TIME;
if(percent_time_left <= 0.0f)
{
+4
View File
@@ -227,6 +227,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" />
@@ -263,6 +264,7 @@
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<ClCompile Include=".\L4PADBINDINGS.cpp" />
<ClCompile Include=".\L4JOY.cpp" />
<ClCompile Include=".\L4PADRIO.cpp" />
<ClCompile Include=".\L4PCSPAK.cpp" />
<ClCompile Include=".\L4PLASMA.cpp" />
@@ -431,6 +433,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" />
@@ -454,6 +457,7 @@
<ClInclude Include=".\L4NETTRANSPORT.h" />
<ClInclude Include=".\L4KEYLIGHT.h" />
<ClInclude Include=".\L4PADBINDINGS.h" />
<ClInclude Include=".\L4JOY.h" />
<ClInclude Include=".\L4STEAMTRANSPORT.h" />
<ClInclude Include=".\L4PARTICLES.h" />
<ClInclude Include=".\L4MFDVIEW.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)
{
+79 -16
View File
@@ -1,16 +1,16 @@
# Red Planet 4.12 — the Steamification
**Red Planet** is VWE's pod-racing game: eight-player VTV races on Mars,
originally played from inside full-motion cockpit pods. This repo is the
third life of that code:
**Red Planet** is VWE's pod-racing game: eight-player VTV contests on Mars
**Martian Death Race** and **Martian Football** — originally played from
inside VWE Tesla cockpit pods. This repo is the third life of that code:
| Generation | What it was |
|------------|-------------|
| **Red Planet 4.10** | The original game, running on the **Tesla 1** pod platform (DOS-era MUNGA engine, serial RIO cockpit hardware, operator console, batch-file relaunch between missions) |
| **Red Planet 4.11** | The **Win32 port** of 4.10 ([RP411](https://gitea.mysticmachines.com/VWE/RP411.git)) — DirectX 9, WinSock TCP, TeslaConsole/TeslaLauncher session control; still arcade pods, still a LAN |
| **Red Planet 4.12** | **This repo: the Steamification of 4.11** — the same engine, the same wire protocol, the same missions, made sellable on Steam and playable over the internet with no cockpit hardware |
| **Red Planet 4.11** | The **Win32 port** of 4.10 ([RP411](https://gitea.mysticmachines.com/VWE/RP411.git)) — DirectX 9, WinSock TCP, TeslaConsole/TeslaLauncher session control; still runs in pods, still a LAN |
| **Red Planet 4.12** | **This repo: the Steamification of 4.11** — the same engine, the same wire protocol, the same missions, made distributable on Steam and playable over the internet with no cockpit hardware |
The architecture is deliberately conservative: the arcade's design survives
The architecture is deliberately conservative: the VWE's design survives
intact, with each pod-era dependency replaced by a consumer equivalent
behind the engine's existing seams.
@@ -19,33 +19,95 @@ behind the engine's existing seams.
| RIO cockpit board (serial) | **PadRIO** — virtual RIO from XInput pad + keyboard, fully rebindable (`bindings.txt`, vRIO profile format) |
| Seven physical displays | **Single-window cockpit** — all displays composed on a locked 1920×1080 canvas around the viewscreen, with the real button banks lamp-lit and clickable |
| Pod button lamps | On-screen lamps, plus an **RGB keyboard mirror** (Windows Dynamic Lighting) |
| TeslaConsole operator | **In-game front end** — race setup menu builds the mission egg locally; an in-process console marshals every race (missions still only end on a console stop, exactly as designed in 1995) |
| TeslaConsole operator | **In-game front end** — race setup menu builds the mission egg locally; an in-process console marshals every race (missions still only end on a console stop, exactly as designed in 1994) |
| TeslaLauncher relaunch-per-mission | **Single binary** — menu → race → results → menu in one process |
| WinSock TCP LAN mesh | **NetTransport seam** — the deterministic pod mesh unchanged, running over plain TCP (LAN/dev) or **Steam Networking Sockets** (FakeIP + Steam Datagram Relay) |
| Site network / fixed IPs | **Steam lobbies** — lobby owner is the console; members exchange FakeIPs and loadouts as lobby data |
**Status: it works.** Release
[v4.12.1](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.1)
carries the first verified end-to-end internet build: three machines, three
**Status: it works.**
[v4.12.2](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.2)
carried the first verified end-to-end internet build: three machines, three
Steam accounts, lobby → mesh → marshaled five-minute race → deaths and
respawns → timed stop → results on every machine → rematch from the same
lobby.
[v4.12.3](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.3)
is about the screen. The cockpit now scales up as well as down and re-fits
whenever the window changes, keeping 16:9 so a wide panel letterboxes
instead of stretching; `-fit` runs it borderless over the whole monitor and
picks the render size to land on the viewscreen 1:1. The displays are the
player's to arrange — each of the six can be scaled on its own and the
radar moved out of the middle of the road (`environ.ini`). Each display's
button bank now reaches under its glass, so the picture itself is the press
target rather than a sliver at the edge.
[v4.12.4](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.4)
is about the lobby. Both rooms now show the host's mission setup — the track,
then time of day, weather and game length — since everyone flies the owner's
picks and it was the one thing in the room nobody could see for themselves.
The football team sheet names each player's VTV beside their team colours and
position. And a lobby holds **eight** players rather than four, a full grid as
the pod hall ran it, with the room sizing its roster to whatever space the
window gives it.
[v4.12.5](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.5)
restores the **Winners Circle**. The pod hall stood the finishers on a
numbered award platform when the race ended, and all of it was still in this
repo — the stand, eight ranked spots in every map, and the code to put racers
on them — wired only into the mission-review build and so never once run by a
pod. The race now fades out and fades back in on the platform: finishers in
finishing order, each pilot's callsign on the plate beside their spot, the
cockpit glass cleared away, held for a few seconds before the results screen.
Three pieces of it had been stubbed out in the D3D9 port and are working
again.
[v4.12.6](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.6)
is about the windows. Where you put them now survives the
menu-race-menu loop: `RP412MFDLAYOUT` remembers the game window, the
exploded view's display panes and the plasma glass in `mfd_layout.cfg`
beside `bindings.txt`. Append `,noframe` to a line to take that
window's title bar and border off — a cockpit filling the monitor edge
to edge at a rect you chose, rather than `-fit` taking the whole
screen — and the setup screen carries its own **EXIT GAME** button,
since a window with no title bar needs a way out. The Steam buttons
now dim and say *STEAM NOT RUNNING* instead of disappearing. And the
Winners Circle camera is framed off the award stand itself rather than
off whoever is standing on it, so the shot is the same one for every
player at every head count.
[v4.12.7](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.7)
is about sticks. Anything that is not an Xbox-class pad — a flight stick,
a HOTAS throttle, a twist grip, rudder pedals, a wheel — comes in through
DirectInput rather than XInput, and the game could not see any of it.
Now it can, and `joyconfig.bat` sets it up: the wizard asks you to move
each control in turn, works out which device and axis answered and which
way round it reads, and writes the joystick rows of `bindings.txt`,
leaving anything you have edited yourself alone. A twist grip or rudder
bar drives both pedals through one signed `Pedals` axis, and a real
throttle lever owns its channel outright rather than nudging a position
the way a spring-centred stick has to. Ported from the sibling BT411.
## Playing
Grab the release zip (or run `pack-dist.ps1` on a build). Single player:
run `start-windowed.bat` — the game boots into the race setup menu. Steam
multiplayer: see
run `start-windowed.bat` — the game boots into the setup menu, where the
SCENARIO group picks Death Race or Football (Football swaps in the
team/position columns and its own track list). Steam multiplayer: see
[docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) (until RP412
has its own AppID it runs under Spacewar, 480).
The two config files beside the exe are self-documenting: **environ.ini**
(every engine option, commented) and **bindings.txt** (every key, pad
button, and axis; written with the full default layout on first run).
The config files beside the exe are self-documenting and none of them
ship: the game writes each one the first time it needs it and then leaves
it alone, so a new build dropped over an existing folder keeps every
setting. **environ.ini** is every engine option, commented; **bindings.txt**
every key, pad button and axis; **pilot.cfg** your callsign and loadout;
**mfd_layout.cfg** where you dragged the windows. Delete any of them to
start that part over with the current defaults.
Default controls: numpad flies (8/2/4/6 stick, 7/9 pedals, 0 trigger),
Shift/Ctrl throttle, Alt reverse, arrows look, Space fires, letter rows
are the MFD button banks as printed on the panel. **Alt+Q** aborts a
mission.
mission. Full map with pad and keyboard diagrams:
[docs/CONTROLS.md](docs/CONTROLS.md).
## Building
@@ -58,6 +120,7 @@ mission.
| Doc | Contents |
|-----|----------|
| [docs/CONTROLS.md](docs/CONTROLS.md) | Controls map — pad and keyboard diagrams, the panel button banks, rebinding |
| [docs/RP412-ROADMAP.md](docs/RP412-ROADMAP.md) | The original plan and workstreams |
| [docs/RP412-FRONTEND-DESIGN.md](docs/RP412-FRONTEND-DESIGN.md) | TeslaConsole analysis, the egg format, the console protocol, and the Steam mapping — with status notes as each layer landed |
| [docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) | Multiplayer test procedure, Steam Input notes, the abort key |
+28
View File
@@ -11,6 +11,16 @@
#include "..\munga\hostmgr.h"
#include "..\munga\nttmgr.h"
//
// How long the mission stays up after the buzzer, for the Winners Circle.
// This timer is the only thing holding the simulation and the renderer
// open once the race is over - when it runs out the player dispatches the
// StopMission that retires the application. The stock 3 seconds is the
// 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;
//#############################################################################
//######################## RPPlayer__StatusMessage ######################
//#############################################################################
@@ -188,6 +198,24 @@ void
ForceUpdate();
}
}
//
//---------------------------------------------------------------------
// Hold the mission open for the Winners Circle.
//
// The base handler sets a 3 second fade, and when it runs out the
// player dispatches the StopMission that retires the application and
// takes the renderer with it. That is the only thing keeping the sim
// and the renderer alive after the race, so the podium gets exactly as
// long as this timer says. Sim and render both keep running throughout
// EndingMission - neither has a case for it that bails out.
//
// Only on a clean finish: an abort should still leave promptly.
//
if (application->GetApplicationState() == Application::EndingMission)
{
fadeTimeRemaining = winnersCircleHoldTime;
}
Check_Fpu();
}
+165 -19
View File
@@ -22,11 +22,15 @@
#include "rpl4pb.h"
#include "rpl4fe.h"
#include "rpl4environ.h"
#include "rpl4console.h"
#include "rpl4lobby.h"
#include "..\munga_l4\l4steamtransport.h"
#include "..\munga_l4\l4splr.h"
#include "..\munga_l4\l4mfdview.h" // RPWindowLayout_*
#include "..\munga_l4\l4joy.h" // RPJoyConfigWizard
#include "rpl4ver.h"
#include "rpl4build.h" // generated: RP412_VERSION / RP412_VERSION_LONG
#include "..\munga\resver.h"
#include "..\munga\resource.h"
// added for game status drawing support
@@ -38,6 +42,7 @@
#include <strsafe.h>
#include <direct.h>
#include <shellapi.h>
#include <time.h> // the test-build expiry check
#define SPOOL_SIZE 0x600000
@@ -123,6 +128,15 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
return 0;
}
break;
case WM_EXITSIZEMOVE:
//
// A drag or resize just finished. Here rather than in the
// cockpit's subclass so it also covers the console screen, which
// is where the window gets moved before there is any cockpit to
// subclass. No-op unless RP412MFDLAYOUT is save.
//
RPWindowLayout_Save();
return 0;
}
return DefWindowProc(hWnd, uMsg, wParam, lParam);
}
@@ -138,6 +152,15 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
SetUnhandledExceptionFilter(RPL4CrashDumpFilter);
//
// Which build this is, before anything else can fail. The patch number
// is this repository's commit count and the hash beside it names the
// commit, so a log from a test machine says exactly where it came from.
// A trailing '+' means the tree had uncommitted changes when it was
// built. See stamp-version.ps1.
//
DEBUG_STREAM << "Red Planet " << RP412_VERSION_LONG << std::endl << std::flush;
// load up our environment variables
//controls
if(getenv("L4CONTROLS") == NULL)
@@ -152,32 +175,105 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
putenv("TARGETFPS=60");
if(getenv("MAXPARTICLES") == NULL)
putenv("MAXPARTICLES=8192");
FILE *file;
char line[1024];
if (fopen_s(&file, "environ.ini", "r") == 0)
//
// environ.ini: written on first run and read here. The exe owns the
// template rather than the packaging script laying one down on every
// unzip, so a tester can drop a new build over an old folder and keep
// their settings. See rpl4environ.h.
//
RPL4Environ_Load();
//
//-------------------------------------------------------------------------
// Test builds have a shelf life.
//
// A tester still racing a fortnight-old binary reports things that were
// fixed a week ago, and the afternoon spent chasing them is gone. So the
// build says plainly that it is out of date and stops, rather than
// running on and being quietly wrong about what it is.
//
// This is a nudge, not a lock: the date comes from the machine's own
// clock, so anyone determined can wind it back, and RP412NOEXPIRY=1 is
// there for us when an old build has to be run on purpose. It is
// deliberately not listed in environ.ini - a bypass every tester can see
// is a bypass every tester will use, and then the build never goes stale
// for the one person it was meant to stop.
//
// $expireDays in stamp-version.ps1 is what sets this, and 0 turns it off
// for a real release.
//-------------------------------------------------------------------------
//
#if RP412_EXPIRES
{
while (!feof(file))
const char *no_expiry = getenv("RP412NOEXPIRY");
Logical overridden = (no_expiry != NULL && atoi(no_expiry) != 0);
__time64_t raw_now = _time64(NULL);
struct tm today;
if (!overridden && _localtime64_s(&today, &raw_now) == 0)
{
if (fgets(line, sizeof(line), file))
int now_stamp =
(today.tm_year + 1900) * 10000 + (today.tm_mon + 1) * 100 + today.tm_mday;
int expiry_stamp =
RP412_EXPIRY_YEAR * 10000 + RP412_EXPIRY_MONTH * 100 + RP412_EXPIRY_DAY;
if (now_stamp > expiry_stamp)
{
for (int i = strlen(line); i >= 0; i--)
if (line[i] == '\n' || line[i] == '\r')
line[i] = 0;
// the file is self-documenting: skip comments, blanks,
// and anything that is not KEY=VALUE
char *setting = line;
while (*setting == ' ' || *setting == '\t')
++setting;
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
continue;
putenv(setting);
DEBUG_STREAM << "Build expired on " << RP412_EXPIRY_TEXT
<< " - refusing to run\n" << std::flush;
char notice[512];
sprintf(notice,
"This Red Planet test build has expired.\n\n"
" Build %s\n"
" 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"
"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);
return 1;
}
//
// The last few days get a line in the log, so somebody reading a
// report can see the build was nearly out rather than wondering.
//
struct tm expiry_day;
memset(&expiry_day, 0, sizeof(expiry_day));
expiry_day.tm_year = RP412_EXPIRY_YEAR - 1900;
expiry_day.tm_mon = RP412_EXPIRY_MONTH - 1;
expiry_day.tm_mday = RP412_EXPIRY_DAY;
//
// The END of the expiry day, because that is the rule the check
// above enforces - the build is good for all of that date and
// refuses the morning after. Anchoring at midday instead would
// report one day fewer than the build actually has left.
//
expiry_day.tm_hour = 23;
expiry_day.tm_min = 59;
expiry_day.tm_sec = 59;
expiry_day.tm_isdst = -1;
__time64_t expiry_time = _mktime64(&expiry_day);
if (expiry_time != (__time64_t) -1)
{
int days_left = (int)((expiry_time - raw_now) / (24 * 60 * 60));
if (days_left <= 3)
{
DEBUG_STREAM << "Build expires " << RP412_EXPIRY_TEXT << " ("
<< days_left << " day(s) left)\n" << std::flush;
}
}
}
fclose(file);
else if (overridden)
{
DEBUG_STREAM << "Build expiry (" << RP412_EXPIRY_TEXT
<< ") waived by RP412NOEXPIRY\n" << std::flush;
}
}
#endif
DEBUG_STREAM << "Red Planet 4.12.2" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM
@@ -254,6 +350,43 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
return FALSE;
ShowWindow(hWnd, nShowCmd);
//
// Sticky placement, from the first frame the player sees. SVGA16 also
// registers and reloads when it builds the cockpit, but that is not
// until a mission starts - without this the console screen would come
// up at the default rect with its title bar still on, and only jump
// to the saved placement once a race began. -fit takes the whole
// monitor and has no placement of the player's to restore.
//
if (!L4Application::GetFitDisplay() && !L4Application::GetFullscreen())
{
RPWindowLayout_Register(hWnd, "RPL4", True);
RPWindowLayout_Load();
}
//
//-------------------------------------------------------------------------
// RP412JOYCONFIG=1 (joyconfig.bat): the joystick setup wizard, before
// anything else claims the screen. It asks the player to move each
// control on their stick, HOTAS or pedals and writes the joy* rows of
// bindings.txt, then falls through into the game so they can try them
// straight away.
//
// One shot: the variable is cleared from this process so a rebuilt
// PadRIO later in the session cannot run the wizard a second time.
//-------------------------------------------------------------------------
//
{
const char *joyconfig = getenv("RP412JOYCONFIG");
if (joyconfig != NULL && *joyconfig != '\0' && atoi(joyconfig) != 0)
{
RPJoyConfigWizard();
SetEnvironmentVariableA("RP412JOYCONFIG", NULL);
_putenv("RP412JOYCONFIG=");
}
}
#if !_DEBUG
// Arcade pods have no mouse - but desktop/windowed play needs the
// cursor for the on-screen cockpit buttons, so hide it only when
@@ -475,6 +608,19 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
}
//
// Last word on the window's placement. Every finished drag has already
// written it and SVGA16 writes again as the cockpit comes down, but a
// session that never started a race has neither - and quitting from
// the console screen is exactly how somebody would leave after moving
// the window to where they want it. No-op unless the mode is save.
//
if (IsWindow(hWnd))
{
RPWindowLayout_Save();
}
RPWindowLayout_Forget(hWnd);
#if !_DEBUG
// symmetric with the fullscreen-only hide at startup
if (L4Application::GetFullscreen())
+427
View File
@@ -15,12 +15,56 @@
#include "..\rp\vtv.h"
#include "rpl4mppr.h"
#include "..\rp\rpplayer.h"
#include "..\munga_l4\l4video.h"
#include "..\munga\dropzone.h"
#include "..\munga\director.h"
#include "..\munga\nttmgr.h"
#include "..\munga_l4\l4vb16.h"
//
//#############################################################################
// RPL4Application
//#############################################################################
//
const Receiver::HandlerEntry
RPL4Application::MessageHandlerEntries[]=
{
MESSAGE_ENTRY(RPL4Application, StopMission),
MESSAGE_ENTRY(RPL4Application, WinnersCircle)
};
Receiver::MessageHandlerSet& RPL4Application::GetMessageHandlers()
{
static Receiver::MessageHandlerSet messageHandlers(
ELEMENTS(RPL4Application::MessageHandlerEntries),
RPL4Application::MessageHandlerEntries,
L4Application::GetMessageHandlers());
return messageHandlers;
}
Derivation* RPL4Application::GetClassDerivations()
{
static Derivation classDerivations(
L4Application::GetClassDerivations(), "RPL4Application");
return &classDerivations;
}
RPL4Application::SharedData
RPL4Application::DefaultData(
RPL4Application::GetClassDerivations(),
RPL4Application::GetMessageHandlers()
);
//
// How long to sit on black between the race fading out and the podium
// fading in. The race fade is FADE_OUT_TIME (half a second), so this is
// that plus a beat, to land on black rather than on the tail of it.
//
const Scalar winnersCircleFadeOutTime = 0.7f;
// and how quickly the stand comes up out of the black afterwards
const Scalar winnersCircleFadeInTime = 0.45f;
RPL4Application::RPL4Application(
HINSTANCE hInstance,
HWND hWnd,
@@ -43,6 +87,389 @@ RPL4Application::~RPL4Application()
Check_Fpu();
}
//
//#############################################################################
// ShowWinnersCircle
//#############################################################################
//
// The pod hall's award platform. Every map carries a "wcircle" stand at
// (1200,0,0) with eight ranked dropzones named win1..win8 on it - rank 1 at
// the front on the low tier, ranks 4-8 across the back on the high one. All
// this shipped; only the mission-review build ever drove it.
//
// Stand the finishers on their spots in finishing order, freeze them, and
// look back at the stand from in front of it.
//
void
RPL4Application::ShowWinnersCircle()
{
Check(this);
// RP412PODIUM=0 skips the whole thing
const char *podium_mode = getenv("RP412PODIUM");
if (podium_mode != NULL && atoi(podium_mode) == 0)
{
DEBUG_STREAM << "WinnersCircle: disabled\n" << std::flush;
return;
}
EntityManager *entity_manager = GetEntityManager();
if (entity_manager == NULL)
{
return;
}
EntityGroup *dropzones = entity_manager->FindGroup("DropZones");
if (dropzones == NULL)
{
DEBUG_STREAM << "WinnersCircle: no DropZones group\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Walk the finishing order. Player::CalcRanking has been ranking every
// scoring player by score each frame, in football as much as in a race
// (CalcFootballRanking exists but is never called), so rank order is
// meaningful in both.
//---------------------------------------------------------------------
//
//
//---------------------------------------------------------------------
// The stand's own geometry first, before anybody stands on it.
//
// The eight spots are fixed furniture on every map, so reading them up
// front gives one set of numbers to both jobs below: where to stand
// the finishers, and where to put the camera. Only the first is
// allowed to care who finished.
//---------------------------------------------------------------------
//
enum { winnersSpotCount = 8 };
DropZone *spot[winnersSpotCount];
int spots_found = 0;
char winners_spot[] = "win?";
char *place = winners_spot + 3;
for (int i = 0; i < winnersSpotCount; ++i)
{
*place = (char) ('1' + i);
spot[i] = NULL;
ChainIteratorOf<Node*> iterator(dropzones->groupMembers);
DropZone *dropzone;
while ((dropzone = (DropZone*) iterator.ReadAndNext()) != NULL)
{
if (!strcmp(dropzone->GetDropZoneName(), winners_spot))
{
spot[i] = dropzone;
++spots_found;
Point3D at = dropzone->localOrigin.linearPosition;
DEBUG_STREAM << "WinnersCircle: " << winners_spot << " at "
<< at.x << "," << at.y << "," << at.z << "\n" << std::flush;
break;
}
}
}
if (spot[0] == NULL)
{
DEBUG_STREAM << "WinnersCircle: no win1 spot on this map\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Stand the finishers up, in finishing order.
//---------------------------------------------------------------------
//
int placed = 0;
Player *p;
for (int rank = 0; (p = CameraDirector::FindPlayerByRank(rank)) != NULL; ++rank)
{
if (rank >= winnersSpotCount)
{
break; // only eight spots exist on the stand
}
if (spot[rank] == NULL)
{
continue;
}
Entity *vehicle = p->GetPlayerVehicle();
if (vehicle == NULL || vehicle->GetClassID() != VTVClassID)
{
continue;
}
VTV *vtv = (VTV*) vehicle;
vtv->Reset(spot[rank]->localOrigin, VTV::MissionReviewReset);
vtv->SetPerformance(&VTV::DoNothing);
vtv->FlushEvents();
++placed;
}
if (placed == 0)
{
DEBUG_STREAM << "WinnersCircle: nobody to place\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Switch the world back on.
//
// Dying collapses the view and sets the application "dead" until the
// pilot reincarnates - while that flag is up the renderer skips every
// static object, which is the whole map. A pilot killed near the buzzer
// is still waiting for a respawn that will never come, so the flag is
// still up and the podium would play out against a black screen.
//
// The mission-review build never hit this: it watches from a CameraShip,
// which cannot die, so nothing ever set the flag there.
//
// Standing the finishers up IS the resurrection, so clear it.
//---------------------------------------------------------------------
//
SetIsDead(false);
DPLRenderer *dpl_renderer = GetVideoRenderer();
if (dpl_renderer == NULL)
{
return;
}
//
//---------------------------------------------------------------------
// Your own vehicle is built as a cockpit with no hull, so from any
// camera outside it there is nothing where you should be - on the stand
// you would be the one empty spot. Turn it inside out before the shot.
//---------------------------------------------------------------------
//
dpl_renderer->ShowViewpointFromOutside();
//
//---------------------------------------------------------------------
// The name plates are drawn per rank slot, so they have to be re-sorted
// now that the finishing order is final - otherwise the signs read in
// whatever order the players were created.
//---------------------------------------------------------------------
//
dpl_renderer->SortAndReloadNameBitmaps();
//
//---------------------------------------------------------------------
// Widen to 45 degrees and pull back in front of the stand so the whole
// line-up frames up. The stand runs from z~3 (rank 1, low) to z~39
// (ranks 4-8, high) and x~1180..1219; at 45 degrees that needs roughly
// 30 units of standoff. Eye is above the top tier looking slightly
// down at the winner's spot.
//
// Framed off the stand, NOT off who is standing on it. This used to
// average the filled spots and anchor on the first one filled, which
// meant the camera moved with the head count: eight finishers put the
// anchor in the middle of the tiers and the eye about 58 units out,
// one finisher collapsed it onto the winner's spot and brought the eye
// in to 36. Same stand, different shot per race - and per machine, if
// a remote player's vehicle was not there to be placed. The furniture
// does not move, so the camera does not either.
//---------------------------------------------------------------------
//
Point3D standFront = spot[0]->localOrigin.linearPosition;
//
// The stand's own axis: back rows to the winner's spot, which points
// out of the front however the map has the stand turned. Taken from
// the geometry rather than assumed, so a map that mounts its stand at
// another angle still gets photographed from the front.
//
Point3D standBack(0.0f, 0.0f, 0.0f);
int back_count = 0;
for (int i = 1; i < winnersSpotCount; ++i)
{
if (spot[i] != NULL)
{
standBack += spot[i]->localOrigin.linearPosition;
++back_count;
}
}
if (back_count > 0)
{
standBack.x /= (Scalar) back_count;
standBack.y /= (Scalar) back_count;
standBack.z /= (Scalar) back_count;
}
Vector3D facing;
facing.x = standFront.x - standBack.x;
facing.y = 0.0f;
facing.z = standFront.z - standBack.z;
Scalar reach = (Scalar) sqrt(facing.x * facing.x + facing.z * facing.z);
if (reach < 0.01f)
{
// a one-spot stand, or all eight stacked: nothing to take a
// direction from, so fall back to the way the shipped stands face
facing.x = 0.0f; facing.z = -1.0f; reach = 1.0f;
}
//
// Framing is tunable while the shot is being dialled in:
// RP412PODIUMSTANDOFF distance out in front of the stand
// RP412PODIUMHEIGHT eye height above the group
// RP412PODIUMAIM height of the aim point above the group
//
//
// Framed off the stand itself: down low and tilted up across the tiers,
// which is how you photograph a podium. It is a balance in both
// directions - drop the camera further or tilt harder and the sky takes
// the top half while the winner's spot slides off the bottom; tilt down
// instead and it becomes a floor plan. Pillarboxing to 4:3 is what lets
// it sit this close without the platform trailing off at the sides.
//
Scalar standoff = 36.0f;
Scalar height = 12.0f;
Scalar aim_lift = 2.0f;
const char *tune = getenv("RP412PODIUMSTANDOFF");
if (tune != NULL && atof(tune) != 0.0) standoff = (Scalar) atof(tune);
tune = getenv("RP412PODIUMHEIGHT");
if (tune != NULL && atof(tune) != 0.0) height = (Scalar) atof(tune);
tune = getenv("RP412PODIUMAIM");
if (tune != NULL) aim_lift = (Scalar) atof(tune);
Point3D eye;
eye.x = standFront.x + (facing.x / reach) * standoff;
eye.y = standFront.y + height;
eye.z = standFront.z + (facing.z / reach) * standoff;
Point3D aim = standFront;
aim.y += aim_lift;
//
// RP412PODIUMCAM=0 leaves the view in the cockpit, which is also the
// way to tell a bad camera from a scene that is not drawing at all.
//
//
// Clear the cockpit glass away. The MFDs and the radar sit over the
// viewscreen like the pod's bezels and have nothing to say once the
// race is over; the podium gets the whole canvas.
//
SVGA16 *cockpit = SVGA16::GetCockpit();
if (cockpit != NULL)
{
cockpit->HideSecondaryDisplays();
}
//
// Fog first: it pulls the clip plane in to 1100, and SetViewAngle is what
// rebuilds the projection that reads it.
//
dpl_renderer->SetFogStyle(DPLRenderer::winnersCircleFogStyle);
const char *camera_mode = getenv("RP412PODIUMCAM");
if (camera_mode == NULL || atoi(camera_mode) != 0)
{
dpl_renderer->SetViewAngle(Degree(45.0f));
dpl_renderer->SetPresentationCamera(eye, aim);
}
else
{
// still rebuild the projection so the new clip plane takes effect
dpl_renderer->SetViewAngle(Degree(40.0f));
}
if (camera_mode != NULL && atoi(camera_mode) == 0)
{
DEBUG_STREAM << "WinnersCircle: presentation camera disabled\n"
<< std::flush;
}
//
// Everything is in place behind the black - bring it up.
// RP412PODIUMFADEIN sets the ramp in seconds.
//
Scalar fade_in = winnersCircleFadeInTime;
const char *fade_tune = getenv("RP412PODIUMFADEIN");
if (fade_tune != NULL && atof(fade_tune) > 0.0)
{
fade_in = (Scalar) atof(fade_tune);
}
dpl_renderer->StartPresentationFadeIn(fade_in);
//
// placed is reported for diagnosis only - the camera numbers beside it
// must not move when it does.
//
DEBUG_STREAM << "WinnersCircle: " << placed << " placed on "
<< spots_found << " spots; aim "
<< aim.x << "," << aim.y << "," << aim.z
<< " eye " << eye.x << "," << eye.y << "," << eye.z
<< "\n" << std::flush;
}
//
//#############################################################################
// StopMissionMessageHandler
//#############################################################################
//
// The mission is over. Show the podium, then let the base handler run - it
// puts the player into MissionEndingState, whose fade timer is what actually
// keeps the sim and the renderer alive until teardown.
//
void
RPL4Application::StopMissionMessageHandler(StopMissionMessage *message)
{
Check(this);
//
// StopMission arrives twice: once from the console at the buzzer, and
// again from the player when the ending fade runs out - that second one
// is what actually retires the application. Only the first is the end of
// the race.
//
if (GetApplicationState() != Application::EndingMission)
{
//
//-------------------------------------------------------------
// Don't cut straight to the podium. The race gets its own
// fade-to-black first - that fade is already running by the time
// this returns - and the Winners Circle comes up out of the
// black afterwards. Standing everyone up now would just fade out
// the podium instead of the race.
//
// FADE_OUT_TIME is half a second; a beat more than that lands on
// black rather than on the tail of the fade.
//-------------------------------------------------------------
//
Receiver::Message podium_message(
WinnersCircleMessageID, sizeof(Receiver::Message));
Time event_time;
event_time = Now();
event_time += winnersCircleFadeOutTime;
Post(LowEventPriority, this, &podium_message, event_time);
DEBUG_STREAM << "WinnersCircle: race over, fading out\n" << std::flush;
}
L4Application::StopMissionMessageHandler(message);
Check_Fpu();
}
//
//#############################################################################
// WinnersCircleMessageHandler
//#############################################################################
//
// The race has faded to black. Set the stand up behind the black and fade
// back in to it.
//
void
RPL4Application::WinnersCircleMessageHandler(Receiver::Message *)
{
Check(this);
DEBUG_STREAM << "WinnersCircle: standing the finishers up\n" << std::flush;
ShowWinnersCircle();
Check_Fpu();
}
//
//#############################################################################
// MakeRegistry
+34
View File
@@ -26,4 +26,38 @@ private:
Mission* MakeMission(NotationFile *notation_file, ResourceFile *resources);
Entity* MakeViewpointEntity(Entity__MakeMessage *);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The Winners Circle.
//
// The podium is engine content that only the mission-review build ever
// ran: geometry, the eight ranked dropzones and the presentation code all
// ship, but the sequence lived on RPL4PlaybackApplication behind a spool
// file. This is the same sequence on the path the pods actually race.
//
public:
enum
{
// posted at the buzzer, fires once the race has faded out
WinnersCircleMessageID = L4Application::NextMessageID,
NextMessageID
};
static const HandlerEntry
MessageHandlerEntries[];
static MessageHandlerSet& GetMessageHandlers();
static Derivation *GetClassDerivations();
static SharedData DefaultData;
void
StopMissionMessageHandler(StopMissionMessage *message);
// the podium proper, once the screen is already black
void
WinnersCircleMessageHandler(Receiver::Message *message);
private:
// stand the finishers on their ranked spots and frame the shot
void
ShowWinnersCircle();
};
+66
View File
@@ -352,6 +352,55 @@ namespace
gPhase = PhaseStopped;
}
//---------------------------------------------------------------
// The countdown the engine shows, taken from the clock that will
// actually end the race (gMissionClockHook - see APPMGR.h).
//
// Called on the game thread, reading two volatile LONGs the console
// thread writes with InterlockedExchange. Aligned 32-bit reads, and
// a torn value could only mistime the cockpit clock by one tick of
// a countdown nobody reads to the millisecond - not worth a lock on
// the frame path.
//---------------------------------------------------------------
Logical MissionClock(Scalar *seconds_remaining)
{
//
// Only answer for the race this console is actually marshalling.
// Nothing ever uninstalls the hook, so a player who hosts a race
// and then joins somebody else's lobby still has it wired up -
// and in that race the console is a bystander whose gLengthMs and
// gRunStartTick belong to the previous mission entirely.
//
if (gWatchedApp == NULL || gWatchedApp != application)
{
return False;
}
if (!gMissionRunning)
{
return False; // not started, or already stopped
}
LONG length_ms = gLengthMs;
if (length_ms <= 0)
{
return False; // endless: nothing to count down
}
// DWORD subtraction, so a GetTickCount wrap costs nothing
LONG elapsed_ms = (LONG)(GetTickCount() - (DWORD) gRunStartTick);
LONG left_ms = length_ms - elapsed_ms;
if (left_ms < 0)
{
//
// The console polls at 250 ms, so the clock reaches zero
// slightly before the stop is dispatched. Hold at zero
// rather than showing negative time in the cockpit.
//
left_ms = 0;
}
*seconds_remaining = (Scalar) left_ms / 1000.0f;
return True;
}
//---------------------------------------------------------------
// The game-thread tick: state reporting + engine-safe execution
//---------------------------------------------------------------
@@ -498,6 +547,19 @@ namespace
<< mission_seconds << "s\n" << std::flush;
}
//
// RP412MISSIONSECONDS overrides the menu's game length. The shortest
// the menu offers is 3:00, which is a long wait when what you are
// testing is what happens at the buzzer.
//
const char *seconds_override = getenv("RP412MISSIONSECONDS");
if (seconds_override != NULL && atoi(seconds_override) > 0)
{
mission_seconds = atoi(seconds_override);
DEBUG_STREAM << "LocalConsole: length overridden to "
<< mission_seconds << "s by RP412MISSIONSECONDS\n" << std::flush;
}
gMissionSeconds = mission_seconds;
InterlockedExchange(&gLengthMs, (LONG) mission_seconds * 1000);
gPhase = PhaseWaiting;
@@ -509,6 +571,10 @@ namespace
// game-thread execution point
gPerFrameHook = &ConsoleTick;
// the cockpit clock now counts down the same clock that will stop
// the race, rather than the engine's own reckoning of it
gMissionClockHook = &MissionClock;
// results intake from the RP layer
gConsoleScoreSink = &CollectFinalScore;
+569
View File
@@ -0,0 +1,569 @@
#include "rpl4.h"
#pragma hdrstop
#include "rpl4environ.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//########################################################################
// environ.ini - see rpl4environ.h for why the exe owns this rather than
// the packaging script.
//########################################################################
namespace
{
const char kEnvironFileName[] = "environ.ini";
//-------------------------------------------------------------------
// The shipped configuration, verbatim. Lifted out of pack-dist.ps1
// so there is one source of truth and the exe alone can produce a
// working install.
//-------------------------------------------------------------------
const char kEnvironTemplate[] =
"# ============================================================================\n"
"# environ.ini - Red Planet 4.12 configuration\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments; anything without an = is ignored. Delete a line (or\n"
"# comment it out) to fall back to the built-in default.\n"
"#\n"
"# Input bindings live in bindings.txt beside the exe (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"#\n"
"# Your callsign and loadout are remembered in pilot.cfg beside the exe.\n"
"# Set them on the setup screen once and they come back every session,\n"
"# however you left - launching, joining a lobby, or quitting. Delete\n"
"# that file to start over.\n"
"\n"
"# ---- Core (the shipped configuration) --------------------------------------\n"
"\n"
"# Control stack: tokens separated by ; or , processed left to right.\n"
"# PAD the virtual RIO (XInput controller + keyboard,\n"
"# rebindable via bindings.txt)\n"
"# RIO real serial cockpit hardware on COM1\n"
"# RIO:COMn same, on another port (RIO:COM3, ...)\n"
"# KEYBOARD the engine keyboard handler\n"
"# MOUSE, JOYSTICK, FLIGHTSTICKPRO, THRUSTMASTER, DIJOYSTICK\n"
"# legacy pointer/joystick drivers (untested here)\n"
"# Unset falls back to KEYBOARD alone.\n"
"L4CONTROLS=PAD;KEYBOARD\n"
"\n"
"# Renderer bring-up argument. Only its presence is checked (the DPL\n"
"# resolution parsing it once fed is gone) and the game refuses to start\n"
"# without it - any non-empty value works. Leave as shipped.\n"
"DPLARG=1\n"
"\n"
"# DPL (renderer/scene) configuration file, searched beside the exe.\n"
"# Any notation file name; RPDPL.INI is the one that ships.\n"
"L4DPLCFG=RPDPL.INI\n"
"\n"
"# Gauge (MFD/instrument) canvas. Must name a page of GAUGE\\L4GAUGE.INI:\n"
"# 640x480x8 | 640x480x16 | 800x600x16\n"
"# Unset disables the gauge renderer (and with it all MFDs).\n"
"L4GAUGE=640x480x16\n"
"\n"
"# Plasma display.\n"
"# SCREEN render the pod's plasma glass in-window (currently\n"
"# parked off-layout)\n"
"# COM1, COM2... drive real plasma glass on that serial port\n"
"# (9600 baud, N81)\n"
"# Unset = no plasma display.\n"
"L4PLASMA=SCREEN\n"
"\n"
"# 0 = classic separate gauge windows; 1 = the single-window glass\n"
"# cockpit (all seven displays composed on a locked 1920x1080 canvas\n"
"# around the viewscreen); 2 = exploded diagnostic view (each display\n"
"# in its own native-resolution desktop window - MFDs 640x480, map\n"
"# 480x640 - decoded exactly as the pod's VDB split them, no downscale).\n"
"L4MFDSPLIT=1\n"
"\n"
"# The game window - and in the exploded view (L4MFDSPLIT=2) each display\n"
"# window - is placed fresh every launch, so moving one somewhere useful\n"
"# never survived the menu-race-menu loop. This remembers where you put\n"
"# them, in mfd_layout.cfg beside this file:\n"
"# off / 0 / unset computed placement only, no file (default)\n"
"# load put the windows back where they were saved\n"
"# save the same, and re-save on every finished drag\n"
"# The game window gets its size back too, so you can size the cockpit to\n"
"# suit your monitor once and keep it. The display windows get position\n"
"# only: their size follows their content and their button banks, so an\n"
"# old one is never restored over them. Arrange everything once with\n"
"# save, then leave it on load.\n"
"#\n"
"# The plasma display window takes part too, under \"Plasma Display\".\n"
"#\n"
"# Each line in mfd_layout.cfg reads <title>=<x>,<y>,<w>,<h>, and you can\n"
"# append ,noframe to take that window's title bar and border off - a\n"
"# cockpit that fills the monitor edge to edge without -fit taking the\n"
"# whole screen. Put the window where you want it first: a bare window\n"
"# has nothing to drag by. Delete the flag to get the frame back.\n"
"#RP412MFDLAYOUT=off\n"
"\n"
"# Size of the six secondary displays in the glass cockpit, as a\n"
"# percentage of their pod size. The pod bolted them down at one size;\n"
"# on a big panel there is room to trade viewscreen for instrument, so\n"
"# turn these up if you want to actually read the other displays while\n"
"# you fly. 100 = as the pod had them. Range 25-200 (out-of-range and\n"
"# unreadable values fall back to the group setting, then to 100).\n"
"#\n"
"# The scaling is applied in canvas units, before the cockpit is fitted\n"
"# to your window, so a given number looks the same on every monitor.\n"
"# The layout stays legal whatever you ask for - the panes are clamped\n"
"# against their actual neighbours, shrinking uniformly so a display\n"
"# never comes out stretched. They do overlap the viewscreen, exactly\n"
"# as the pod's bezels did, but never each other.\n"
"#\n"
"# L4MFDSCALE sets all five green MFDs at once.\n"
"L4MFDSCALE=100\n"
"\n"
"# ...and any single display can override it. Uncomment one to size it\n"
"# on its own - useful if you only care about, say, the damage readout.\n"
"# UL upper left UC upper center UR upper right\n"
"# LL lower left LR lower right\n"
"#L4MFDSCALE_UL=100\n"
"#L4MFDSCALE_UC=100\n"
"#L4MFDSCALE_UR=100\n"
"#L4MFDSCALE_LL=100\n"
"#L4MFDSCALE_LR=100\n"
"\n"
"# The portrait radar/map, sized on its own (it already sits at 1.35x\n"
"# the MFDs by default). It shares the canvas with whichever MFD is\n"
"# above it, so at extreme settings one of the two gives way.\n"
"L4RADARSCALE=100\n"
"\n"
"# Where the radar sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER / LEFTCENTRE)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER / RIGHTCENTRE)\n"
"# Anywhere but CENTER stops it blocking the middle of the road, which\n"
"# is worth having on a wide screen.\n"
"#\n"
"# In a bottom corner it is one of three panes along the bottom, and the\n"
"# lower MFD whose corner it takes slides inboard beside it. Halfway up\n"
"# a side it leaves the bottom row entirely and sits between that side's\n"
"# two MFDs - roomy on a tall radar, but if the MFDs on that side are\n"
"# also scaled up, the radar is the one that gives way (it has to clear\n"
"# both of them, and it grows from the middle in both directions).\n"
"L4RADARPOS=CENTER\n"
"\n"
"# The Winners Circle: at the end of a race the finishers are stood on\n"
"# the award platform in finishing order, with each pilot's callsign on\n"
"# the plate beside their spot, and held there for a few seconds before\n"
"# the results screen. 1 = show it, 0 = straight to the results.\n"
"RP412PODIUM=1\n"
"\n"
"# The shot is framed for you, but these move the camera if you want it\n"
"# somewhere else. Distances are in game units, measured from the middle\n"
"# of the group of finishers.\n"
"# STANDOFF how far out in front of the stand the camera sits\n"
"# HEIGHT how far above the group\n"
"# AIM height of the point it looks at, relative to the group -\n"
"# negative tilts down, positive tilts up\n"
"# ASPECT the stand was composed for a 4:3 pod monitor, so the shot\n"
"# is cropped to that shape with black either side. 0 runs it\n"
"# full width instead.\n"
"# FADEIN seconds to come up out of the black after the race fades\n"
"# CAM 0 watches from your own cockpit rather than off the stand\n"
"#RP412PODIUMSTANDOFF=36\n"
"#RP412PODIUMHEIGHT=12\n"
"#RP412PODIUMAIM=2\n"
"#RP412PODIUMASPECT=1.333\n"
"#RP412PODIUMFADEIN=0.45\n"
"#RP412PODIUMCAM=1\n"
"\n"
"# Override the game length the menu picked, in seconds. The shortest the\n"
"# menu offers is 3:00, which is a long wait when what you are testing is\n"
"# what happens at the buzzer. Unset = use the menu's choice.\n"
"#RP412MISSIONSECONDS=20\n"
"\n"
"# Simulation/render frame rate, integer frames/second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"TARGETFPS=60\n"
"\n"
"# How long one background pass may spend drawing cockpit gauges, in\n"
"# milliseconds. The gauges and the MFD/map displays are redrawn in the\n"
"# time left over after the 3D view; on a big, busy map there is none\n"
"# left, and at the original one-gauge-per-pass the map and the countdown\n"
"# clock could sit frozen for seconds at a time - until something (a\n"
"# death, say) lightened the 3D view enough for the backlog to drain.\n"
"# Working to a slice ties the refresh rate to elapsed time instead. Set\n"
"# 0 for the old behaviour; raise it to favour the displays over frame\n"
"# rate.\n"
"RP412GAUGESLICE=2\n"
"\n"
"# 1 = log how many times a second every cockpit display is actually\n"
"# refreshed, to rpl4.log. Watching the screen cannot tell a display that\n"
"# has stopped refreshing from one whose picture simply is not changing.\n"
"#RP412GAUGEDIAG=1\n"
"\n"
"# 0 = light the on-screen cockpit buttons on the same slow cadence the\n"
"# arcade pod's serial hardware used. The lamp state is filled once per\n"
"# gauge cycle, so under the load described above the lit buttons froze\n"
"# and flashing ones stalled while the 3D view stayed perfectly smooth.\n"
"# On by default: the buttons are refreshed every frame instead. Ignored\n"
"# when real RIO hardware is selected - the pod keeps its own cadence.\n"
"#RP412LAMPSWEEP=0\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running and steam_appid.txt beside the exe; without them the game\n"
"# logs the reason and falls back to plain TCP. 0 = TCP only.\n"
"RP412STEAM=1\n"
"\n"
"# Line up each remote player's clock with ours, so their vehicle is\n"
"# extrapolated from when its update was SENT rather than when it\n"
"# arrived. Without it every remote pod sits one network latency behind\n"
"# where it should be - invisible on the 1ms arcade LAN the engine was\n"
"# written for, a constant 50-150ms of lag over the internet. 0 restores\n"
"# the old arrival-time behaviour if you want to compare.\n"
"#RP412NETCLOCK=0\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"
"# lamp buttons glow with the panel, flash modes and all.\n"
"# Unset or nonzero = on (the default); 0 = off.\n"
"#RP412KEYLIGHT=0\n"
"\n"
"# The cabinets ran the game at unity and did all their volume and tone\n"
"# shaping outside it, in an amplifier and a 3-way crossover. You almost\n"
"# certainly have neither, so these two stand in for them. Both default\n"
"# to leaving the mix exactly as the pod played it.\n"
"\n"
"# Master volume, 0.0 to 2.0, the amplifier's knob. 1.0 is unity. The\n"
"# sound effects now carry the pitch, layering and dynamics the original\n"
"# AWE32 soundbanks ask for, which is a good deal livelier than earlier\n"
"# 4.12 builds - lower this if the whole thing sits too hot.\n"
"#\n"
"# PageUp and PageDown change it while you play, in steps of 0.05, and\n"
"# whatever you leave it on is written to volume.cfg beside the exe and\n"
"# used from then on - so this line only decides where a machine that has\n"
"# never been touched starts out. Delete volume.cfg to come back here.\n"
"#RP412AUDIOVOLUME=0.8\n"
"\n"
"# Bass trim, 0.0 to 1.0, the crossover's low band. 1.0 is the low end\n"
"# exactly as authored. The soundbanks put real weight under collisions,\n"
"# engines and explosions - deep layers earlier builds played at the\n"
"# wrong rate, so they barely sounded at all. Lower this to pull that\n"
"# back; it eases in below 22kHz of playback rate and reaches full cut\n"
"# on the deepest layers, leaving the mid and top alone.\n"
"#\n"
"# Home and End change it while you play, in steps of 0.05, and what you\n"
"# leave it on is written to bass.cfg beside the exe and used from then\n"
"# on - so this line only decides where an untouched machine starts.\n"
"# Delete bass.cfg to come back here.\n"
"#RP412AUDIOBASS=0.7\n"
"\n"
"# Invert the stick on top of whatever bindings.txt produces:\n"
"# X = invert X only, Y = invert Y only, XY = both (case-insensitive).\n"
"#L4PADFLIP=XY\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9:\n"
"# 0 = off, else 2..16 as the GPU supports (1 selects the driver's\n"
"# \"nonmaskable\" mode; unsupported counts fail device creation).\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# On-screen plasma glass (L4PLASMA=SCREEN only). SCALE = integer pixel\n"
"# size 1..16, default 4 (out-of-range values are ignored). POS = window\n"
"# top-left as X,Y screen coordinates; unset = auto, parked below the\n"
"# main window.\n"
"#L4PLASMASCALE=4\n"
"#L4PLASMAPOS=0,0\n"
"\n"
"# Fixed random seed (repeatable runs): any unsigned integer.\n"
"# Unset seeds from the clock.\n"
"#RANDOM=12345\n"
"\n"
"# ---- LAN play without Steam -------------------------------------------------\n"
"# Host a race over plain TCP: list the member pods' console channels\n"
"# (members run: rpl4opt.exe -windowed -res 1920 1080 -net 1501).\n"
"# RP412HOSTPODS comma-separated IP[:port] list, one entry per member\n"
"# pod; port defaults to 1501 per entry\n"
"# RP412HOSTPORT this machine's console port, integer > 0\n"
"# (default 1501)\n"
"# RP412HOSTADDR this machine's LAN IP as members can reach it\n"
"# (default 127.0.0.1)\n"
"#RP412HOSTPODS=192.168.1.20:1501,192.168.1.21:1501\n"
"#RP412HOSTPORT=1501\n"
"#RP412HOSTADDR=192.168.1.10\n"
"\n"
"# ---- Developer / testing ----------------------------------------------------\n"
"\n"
"# Nonzero arms the debug keys: Alt+W wireframe, Alt+V predator vision,\n"
"# Alt+F frame dump, Alt+/ perf stats, Alt+E event-queue dump.\n"
"# 0 or unset = off. (Alt+Q, the mission abort, is always live.)\n"
"#RP412DEVKEYS=1\n"
"\n"
"# Console race-length override, integer seconds (short test races).\n"
"# Values <= 0 are ignored.\n"
"#L4CONSOLELEN=30\n"
"\n"
"# Nonzero = Steam transport loopback self-test at boot (logs PASS/FAIL).\n"
"#RP412STEAMSELFTEST=1\n"
"\n"
"# ---- Arcade heritage (multi-monitor pods; not used on the desktop) ----------\n"
"# PRIMGAUGE / SECGAUGE / MFDGAUGE / MFDGAUGE2 pin a display to a monitor\n"
"# by adapter index (0, 1, 2...). SPANDISABLE: 0 = let the MFDs span one\n"
"# wide surface, nonzero = separate windows (setting MFDGAUGE2 alone also\n"
"# forces spanning off). L4EYES = \"x y z xrot yrot zrot [type]\" floats\n"
"# for a detached camera; a type starting with r offsets it relative to\n"
"# the pod. L4INTERCOM enables the crew intercom - only its presence\n"
"# matters (traditionally COM2). NOMODES skips the mode/lamp programming;\n"
"# presence alone triggers it, even NOMODES=0. LOGSIZE > 0 sizes the\n"
"# trace log in dev builds compiled with tracing.\n"
"#PRIMGAUGE=1\n"
"#SECGAUGE=2\n"
"#MFDGAUGE=3\n"
"#MFDGAUGE2=4\n"
"#SPANDISABLE=1\n"
"#L4EYES=1\n"
"#L4INTERCOM=COM2\n"
"#NOMODES=1\n"
"#LOGSIZE=1000000\n"
;
//-------------------------------------------------------------------
// Does the player's file mention this key at all - set, or commented
// out, or with whitespace in front of it?
//
// Deliberately generous: a key that is mentioned in ANY form is left
// alone. The alternative failure is worse than a missed notice, since
// environ.ini is applied line by line and a second copy of a key
// further down the file would silently override the player's own.
//-------------------------------------------------------------------
Logical FileMentionsKey(const char *text, const char *key, int key_length)
{
const char *cursor = text;
while ((cursor = strstr(cursor, key)) != NULL)
{
//
// Must be a whole key: preceded by start-of-line, whitespace
// or a comment mark, and followed by '='.
//
const char *after = cursor + key_length;
Logical starts_token =
(cursor == text) ||
(cursor[-1] == '\n') || (cursor[-1] == '\r') ||
(cursor[-1] == ' ') || (cursor[-1] == '\t') ||
(cursor[-1] == '#') || (cursor[-1] == ';');
if (starts_token)
{
const char *scan = after;
while (*scan == ' ' || *scan == '\t')
{
++scan;
}
if (*scan == '=')
{
return True;
}
}
cursor = after;
}
return False;
}
//-------------------------------------------------------------------
// Name every template key the player's file has never heard of. Not
// a fix - their file stays theirs - but it puts the reason for a
// missing feature in the log we already ask testers for.
//-------------------------------------------------------------------
void ReportUnmentionedKeys(const char *file_text)
{
char missing[1024]; // what gets printed
char seen[1024]; // the same keys as "KEY=", so the mention
// test above can dedupe against them
missing[0] = '\0';
seen[0] = '\0';
int count = 0; // how many are missing
int listed = 0; // how many fitted in the line
const char *cursor = kEnvironTemplate;
while (*cursor != '\0')
{
const char *line = cursor;
const char *end = strchr(line, '\n');
int length = (end != NULL) ? (int)(end - line) : (int) strlen(line);
cursor = (end != NULL) ? (end + 1) : (line + length);
//
// A template key line is "KEY=..." or "#KEY=..." - the
// commented ones are options that ship switched off, and a
// player who has never seen them wants to know they exist.
//
const char *scan = line;
int remaining = length;
if (remaining > 0 && *scan == '#')
{
++scan;
--remaining;
}
if (remaining <= 0 || !(isalpha((unsigned char) *scan) || *scan == '_'))
{
continue;
}
int key_length = 0;
while (key_length < remaining &&
(isalnum((unsigned char) scan[key_length]) || scan[key_length] == '_'))
{
++key_length;
}
if (key_length >= remaining || scan[key_length] != '=' || key_length > 60)
{
continue;
}
char key[64];
memcpy(key, scan, key_length);
key[key_length] = '\0';
if (FileMentionsKey(file_text, key, key_length))
{
continue;
}
//
// Templates list some keys twice (documented once, shown
// again in an example); do not name one twice.
//
if (FileMentionsKey(seen, key, key_length))
{
continue;
}
++count;
if (strlen(seen) + key_length + 3 < sizeof(seen))
{
strcat(seen, key);
strcat(seen, "=\n");
}
if (strlen(missing) + key_length + 3 < sizeof(missing))
{
if (missing[0] != '\0')
{
strcat(missing, ", ");
}
strcat(missing, key);
++listed;
}
}
if (count > 0)
{
//
// Say when the list is short of the count rather than letting
// a full buffer quietly shorten the answer.
//
DEBUG_STREAM << "Environ: " << kEnvironFileName << " does not mention "
<< count << " option(s) this build knows: " << missing;
if (listed < count)
{
DEBUG_STREAM << ", and " << (count - listed) << " more";
}
DEBUG_STREAM << "\nEnviron: they are at their built-in defaults - delete "
<< kEnvironFileName << " to get the documented file back\n"
<< std::flush;
}
}
}
void
RPL4Environ_Load()
{
//
// First run: lay down the documented default. From here on the file
// belongs to whoever is sitting at this machine.
//
FILE *file = fopen(kEnvironFileName, "rb");
if (file == NULL)
{
FILE *out = fopen(kEnvironFileName, "wb");
if (out != NULL)
{
fwrite(kEnvironTemplate, 1, strlen(kEnvironTemplate), out);
fclose(out);
DEBUG_STREAM << "Environ: wrote default " << kEnvironFileName
<< "\n" << std::flush;
}
else
{
DEBUG_STREAM << "Environ: could not write " << kEnvironFileName
<< " - running on built-in defaults\n" << std::flush;
}
file = fopen(kEnvironFileName, "rb");
}
if (file == NULL)
{
return;
}
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0)
{
fclose(file);
return;
}
char *text = new char[size + 1];
size_t read = fread(text, 1, size, file);
text[read] = '\0';
fclose(file);
//
// One KEY=VALUE per line. Comments, blanks and anything without an
// '=' are skipped; everything else goes into the environment, which
// is why a line here beats a variable set in the shell.
//
int applied = 0;
char line[1024];
const char *cursor = text;
while (*cursor != '\0')
{
int length = 0;
while (cursor[length] != '\0' && cursor[length] != '\n' &&
length < (int) sizeof(line) - 1)
{
line[length] = cursor[length];
++length;
}
line[length] = '\0';
cursor += length;
while (*cursor == '\n' || *cursor == '\r')
{
++cursor;
}
for (int i = length - 1; i >= 0; --i)
{
if (line[i] == '\r' || line[i] == '\n')
{
line[i] = '\0';
}
}
char *setting = line;
while (*setting == ' ' || *setting == '\t')
{
++setting;
}
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
{
continue;
}
putenv(setting);
++applied;
}
DEBUG_STREAM << "Environ: " << applied << " setting(s) from "
<< kEnvironFileName << "\n" << std::flush;
ReportUnmentionedKeys(text);
delete[] text;
}
+44
View File
@@ -0,0 +1,44 @@
//===========================================================================//
// File: rpl4environ.h //
// Project: MUNGA Brick: Red Planet LBE Application //
// Contents: environ.ini - written on first run, then the player's //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
#include "..\munga\style.h"
//########################################################################
//
// environ.ini is the game's configuration: one KEY=VALUE per line, read
// once at startup and pushed into the environment, so every option the
// engine reads through getenv can be set from a file a player can open.
//
// The exe owns the template and writes it when the file is absent, the
// same way bindings.txt works, rather than the packaging script laying
// one down on every unzip. That is what lets a tester drop a new build
// over an old folder and keep their settings: the file is theirs from
// the moment it exists, and nothing overwrites it.
//
// It cannot simply be optional. Without it L4GAUGE is unset, which
// disables the gauge renderer and takes every MFD with it, and
// L4MFDSPLIT is unset, which is the packed-window arcade layout rather
// than the glass cockpit. The shipped values are the desktop game; the
// built-in getenv fallbacks are the 1995 pod.
//
// The cost of a file that is never overwritten is that a tester carrying
// one across many builds stops being offered new options. Options added
// later default to "behave as before", so nothing breaks - but it does
// go unnoticed, so the load names any template key the player's file
// does not mention. That line in rpl4.log is what turns "the podium does
// not work" into "your environ.ini predates RP412PODIUM".
//
//########################################################################
// Write environ.ini if it is not there, then read it into the
// environment. Call once, before anything reads a setting.
void
RPL4Environ_Load();
+1288 -145
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -50,6 +50,66 @@ int
void
RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge);
//---------------------------------------------------------------
// This player's mission setup as display names. Only the host's
// picks decide the mission, so the lobby owner publishes these for
// everyone else to read - names rather than keys, because the map
// catalog differs between race and football and resolving it here
// saves the room from knowing which table a key belongs to.
//---------------------------------------------------------------
const char *
RPL4FrontEnd_SelectedMapName();
const char *
RPL4FrontEnd_SelectedTimeName();
const char *
RPL4FrontEnd_SelectedWeatherName();
const char *
RPL4FrontEnd_SelectedLengthName();
//---------------------------------------------------------------
// Catalog key -> display name, for the keys that travel as lobby
// member data. Unknown keys come back unchanged rather than blank.
//---------------------------------------------------------------
const char *
RPL4FrontEnd_VehicleNameForKey(const char *key);
const char *
RPL4FrontEnd_PositionNameForKey(const char *key);
const char *
RPL4FrontEnd_TeamNameForKey(const char *key);
//---------------------------------------------------------------
// Football team / position: the catalogs, and this player's picks.
// The lobby publishes the picks as member data and lets members
// cycle them in the room; the host's egg builder honors them.
//---------------------------------------------------------------
int
RPL4FrontEnd_TeamCount();
const char *
RPL4FrontEnd_TeamName(int index);
const char *
RPL4FrontEnd_TeamKey(int index);
int
RPL4FrontEnd_PositionCount();
const char *
RPL4FrontEnd_PositionName(int index);
const char *
RPL4FrontEnd_PositionKey(int index);
int
RPL4FrontEnd_GetTeamIndex();
void
RPL4FrontEnd_SetTeamIndex(int index);
int
RPL4FrontEnd_GetPositionIndex();
void
RPL4FrontEnd_SetPositionIndex(int index);
// True when this player's setup menu has Football selected (the host's
// pick decides the mission; members use it to know what to show).
Logical
RPL4FrontEnd_IsFootballSelected();
// Hosted-race pilots fed by the Steam lobby: overrides the
// RP412HOSTPODS parsing with real personas and loadouts. owner_address
// is this pod's mesh IP (the FakeIP); count 0 clears the override.
@@ -60,6 +120,9 @@ struct FEHostedPilot
char vehicle[24];
char color[16];
char badge[24];
// football: the member's own picks, empty = assign one for them
char team[32]; // team key ("Red/Pink", ...)
char position[16]; // "runner" / "crusher" / "blocker"
};
void
RPL4FrontEnd_SetHostedPilots(
+363 -14
View File
@@ -11,6 +11,7 @@
//########################################################################
Logical RPL4Lobby_Available() { return False; }
Logical RPL4Lobby_Configured() { return False; }
Logical RPL4Lobby_InRoom() { return False; }
int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; }
@@ -36,7 +37,10 @@ void RPL4Lobby_PullRaceResults() { }
namespace
{
enum { kMaxLobbyMembers = 4 };
// A full grid is eight pods, as the pod hall ran it. The egg builder
// carries the owner plus maxExtraPilots (8), so eight members fit
// with room to spare.
enum { kMaxLobbyMembers = 8 };
const char kLobbyTagKey[] = "rp412";
const char kGoKey[] = "go";
@@ -45,6 +49,27 @@ namespace
int gLastGoNonce = 0; // launches we already answered
int gShownResultsNonce = 0; // score sheets we already displayed
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";
const char kWeatherKey[] = "wx";
const char kLengthKey[] = "ln";
// async call-result plumbing
Logical gCallDone = False;
@@ -120,6 +145,12 @@ namespace
}
}
Logical IsOwner()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
}
void PublishMemberData()
{
char value[64];
@@ -140,12 +171,62 @@ namespace
SteamMatchmaking()->SetLobbyMemberData(gLobby, "vh", vehicle);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge);
// football: this member's own team and position pick
SteamMatchmaking()->SetLobbyMemberData(gLobby, "tm",
RPL4FrontEnd_TeamKey(RPL4FrontEnd_GetTeamIndex()));
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
// whether their team/position pick matters) and the setup
// everyone is about to fly into.
//---------------------------------------------------------------
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,
RPL4FrontEnd_SelectedMapName());
SteamMatchmaking()->SetLobbyData(gLobby, kTimeKey,
RPL4FrontEnd_SelectedTimeName());
SteamMatchmaking()->SetLobbyData(gLobby, kWeatherKey,
RPL4FrontEnd_SelectedWeatherName());
SteamMatchmaking()->SetLobbyData(gLobby, kLengthKey,
RPL4FrontEnd_SelectedLengthName());
}
}
Logical IsOwner()
Logical FootballLobby()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
if (!gInLobby)
{
return False;
}
const char *scenario = SteamMatchmaking()->GetLobbyData(gLobby, kScenarioKey);
return (scenario != NULL && strcmp(scenario, "football") == 0);
}
//-------------------------------------------------------------------
// Lobby data read back as a string, empty when the owner has not
// published it yet (a member can be in the room a beat before the
// owner's first publish lands).
//-------------------------------------------------------------------
const char *LobbyText(const char *key)
{
if (!gInLobby)
{
return "";
}
const char *text = SteamMatchmaking()->GetLobbyData(gLobby, key);
return (text != NULL) ? text : "";
}
//---------------------------------------------------------------
@@ -162,6 +243,9 @@ namespace
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick
char position[16];
char netRev[8]; // simulation protocol revision
Logical published;
};
@@ -196,6 +280,15 @@ namespace
strncpy(member->badge,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "bd"),
sizeof(member->badge) - 1);
strncpy(member->team,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "tm"),
sizeof(member->team) - 1);
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;
}
@@ -241,6 +334,8 @@ namespace
strncpy(pilot->vehicle, members[i].vehicle, sizeof(pilot->vehicle) - 1);
strncpy(pilot->color, members[i].color, sizeof(pilot->color) - 1);
strncpy(pilot->badge, members[i].badge, sizeof(pilot->badge) - 1);
strncpy(pilot->team, members[i].team, sizeof(pilot->team) - 1);
strncpy(pilot->position, members[i].position, sizeof(pilot->position) - 1);
if (pods[0] != '\0')
{
@@ -274,8 +369,11 @@ namespace
HFONT titleFont;
RECT launchRect; // owner only
RECT leaveRect;
RECT teamRect; // football: cycle my team
RECT positionRect; // football: cycle my position
Logical launchClicked;
Logical leaveClicked;
Logical pickChanged;
Logical closed;
MemberInfo members[kMaxLobbyMembers];
int memberCount;
@@ -303,15 +401,148 @@ namespace
RECT title = client;
title.top = client.bottom / 28;
title.bottom = title.top + client.bottom / 10;
DrawTextA(mem, "STEAM RACE LOBBY", -1, &title,
DT_CENTER | DT_TOP | DT_SINGLELINE);
DrawTextA(mem,
FootballLobby() ? "STEAM FOOTBALL LOBBY" : "STEAM RACE LOBBY",
-1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
SelectObject(mem, room->textFont);
int row_h = client.bottom / 16;
int top = client.bottom / 4;
int left = client.right / 4;
int right = 3 * client.right / 4;
// wider than the old middle-half: the right column now carries
// full catalog names, and in football three of them
int left = client.right / 6;
int right = client.right - client.right / 6;
char text[128];
//---------------------------------------------------------------
// The buttons are laid out upward from the bottom edge while the
// roster runs down from the top, so everything between the title
// and the topmost button has to share one band.
//---------------------------------------------------------------
int ceiling = client.bottom;
if (FootballLobby() && room->teamRect.top > 0)
{
ceiling = room->teamRect.top;
}
else if (IsOwner() && room->launchRect.top > 0)
{
ceiling = room->launchRect.top;
}
else if (room->leaveRect.top > 0)
{
ceiling = room->leaveRect.top;
}
//---------------------------------------------------------------
// Size the rows to that band rather than to a fixed fraction of
// the window: a full lobby is eight players, and eight rows at a
// sixteenth each would run off the bottom of every window we
// support. Counted in half rows, the band holds
//
// setup lines + gap + kMaxLobbyMembers rows + gap + hint
//
// so the row height falls out of the space actually available.
// It never grows past the old sixteenth (a two-player lobby
// should not have circus-sized rows) and never shrinks below the
// font, which is what would actually break - overlapping text.
//---------------------------------------------------------------
// tmHeight already includes the font's own leading, so a row that
// tall cannot clip or collide - asking for more than that just
// costs setup lines the room would rather keep.
TEXTMETRICA metrics;
GetTextMetricsA(mem, &metrics);
int min_row = metrics.tmHeight + 2;
int max_row = client.bottom / 16;
int setup_top = title.bottom + client.bottom / 64;
int band = ceiling - setup_top;
int setup_lines = 2;
int row_h = 0;
for (;;)
{
// halves: setup, half gap, roster, half gap, hint
int halves = 2 * setup_lines + 1 + 2 * kMaxLobbyMembers + 1 + 2;
row_h = (2 * band) / halves;
if (row_h >= min_row || setup_lines == 0)
{
break;
}
--setup_lines; // buy room back from the setup lines
}
if (row_h > max_row) row_h = max_row;
if (row_h < 12) row_h = 12;
//---------------------------------------------------------------
// On a window too short to give eight rows the room the standard
// font wants, draw this block smaller rather than letting the
// rows overlap each other or run under the buttons. Nobody plays
// at that size, but a squeezed roster still has to be readable.
//---------------------------------------------------------------
HFONT squeezed = NULL;
HFONT previous = NULL;
if (row_h < min_row)
{
squeezed = CreateFontA(-(row_h * 2 / 3), 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_TT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
FIXED_PITCH | FF_MODERN, "Consolas");
if (squeezed != NULL)
{
previous = (HFONT) SelectObject(mem, squeezed);
}
}
int top = setup_top;
const char *map_name = LobbyText(kMapKey);
if (map_name[0] != '\0' && setup_lines > 0)
{
RECT setup;
setup.left = client.right / 12;
setup.right = client.right - client.right / 12;
setup.top = setup_top;
setup.bottom = setup.top + row_h;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, map_name, -1, &setup,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
//-----------------------------------------------------------
// Conditions on the line below. Game length is the host's
// pick too, and the one people ask about first.
//-----------------------------------------------------------
const char *time_name = LobbyText(kTimeKey);
const char *weather_name = LobbyText(kWeatherKey);
const char *length_name = LobbyText(kLengthKey);
text[0] = '\0';
if (time_name[0] != '\0')
{
strcat(text, time_name);
}
if (weather_name[0] != '\0')
{
if (text[0] != '\0') strcat(text, " - ");
strcat(text, weather_name);
}
if (length_name[0] != '\0')
{
if (text[0] != '\0') strcat(text, " - ");
strcat(text, length_name);
}
if (text[0] != '\0' && setup_lines > 1)
{
setup.top = setup.bottom;
setup.bottom = setup.top + row_h;
SetTextColor(mem, kGreenDim);
DrawTextA(mem, text, -1, &setup,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
if (setup.bottom + row_h / 2 > top)
{
top = setup.bottom + row_h / 2;
}
}
for (int i = 0; i < room->memberCount; ++i)
{
MemberInfo *member = &room->members[i];
@@ -328,9 +559,29 @@ namespace
is_owner_row ? " [HOST]" : "");
DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
if (member->vehicle[0] != '\0')
//-----------------------------------------------------------
// The right column is what this player brings. Football is a
// team sheet - team colors and position - but the VTV still
// decides how they play it, so name it there too. Keys
// travel on the wire; the catalogs turn them back into
// names, so nobody reads "bttlbrg".
//-----------------------------------------------------------
if (FootballLobby())
{
sprintf(text, "%s / %s", member->vehicle, member->color);
if (member->team[0] != '\0')
{
sprintf(text, "%s - %s - %s",
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
RPL4FrontEnd_TeamNameForKey(member->team),
RPL4FrontEnd_PositionNameForKey(member->position));
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
else if (member->vehicle[0] != '\0')
{
sprintf(text, "%s - %s",
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
member->color);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
@@ -346,12 +597,38 @@ namespace
: "Waiting for the host to launch...",
-1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
// back to the room font for the buttons, which have their own room
if (squeezed != NULL)
{
SelectObject(mem, previous);
DeleteObject(squeezed);
}
HBRUSH frame = CreateSolidBrush(kGreenBright);
//
// Football: my own team and position, click to cycle
//
if (FootballLobby())
{
FrameRect(mem, &room->teamRect, frame);
SetTextColor(mem, kGreenBright);
sprintf(text, "MY TEAM: %s",
RPL4FrontEnd_TeamName(RPL4FrontEnd_GetTeamIndex()));
DrawTextA(mem, text, -1, &room->teamRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
FrameRect(mem, &room->positionRect, frame);
sprintf(text, "MY POSITION: %s",
RPL4FrontEnd_PositionName(RPL4FrontEnd_GetPositionIndex()));
DrawTextA(mem, text, -1, &room->positionRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
if (IsOwner())
{
FrameRect(mem, &room->launchRect, frame);
SetTextColor(mem, kGreenBright);
DrawTextA(mem, "L A U N C H R A C E", -1, &room->launchRect,
DrawTextA(mem, "L A U N C H G A M E", -1, &room->launchRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
FrameRect(mem, &room->leaveRect, frame);
@@ -395,6 +672,18 @@ namespace
{
room->leaveClicked = True;
}
else if (FootballLobby() && PtInRect(&room->teamRect, point))
{
RPL4FrontEnd_SetTeamIndex(
(RPL4FrontEnd_GetTeamIndex() + 1) % RPL4FrontEnd_TeamCount());
room->pickChanged = True;
}
else if (FootballLobby() && PtInRect(&room->positionRect, point))
{
RPL4FrontEnd_SetPositionIndex(
(RPL4FrontEnd_GetPositionIndex() + 1) % RPL4FrontEnd_PositionCount());
room->pickChanged = True;
}
return 0;
}
break;
@@ -465,6 +754,16 @@ namespace
room.leaveRect.top = client.bottom - 3 * row_h;
room.leaveRect.bottom = client.bottom - row_h;
// football pick buttons, above the launch button
room.teamRect.left = (client.right - col_w) / 2;
room.teamRect.right = room.teamRect.left + col_w;
room.teamRect.top = client.bottom - 12 * row_h;
room.teamRect.bottom = room.teamRect.top + 2 * row_h;
room.positionRect.left = room.teamRect.left;
room.positionRect.right = room.teamRect.right;
room.positionRect.top = client.bottom - 9 * row_h;
room.positionRect.bottom = room.positionRect.top + 2 * row_h;
gRoom = &room;
PublishMemberData();
@@ -489,6 +788,18 @@ namespace
outcome = LobbyRoomClosed;
break;
}
//
// A pick changed: republish so everyone's roster updates
//
if (room.pickChanged)
{
room.pickChanged = False;
PublishMemberData();
room.memberCount = CollectMembers(room.members);
InvalidateRect(room.window, NULL, FALSE);
RedrawWindow(room.window, NULL, NULL, RDW_UPDATENOW);
}
if (room.leaveClicked)
{
SteamMatchmaking()->LeaveLobby(gLobby);
@@ -509,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];
@@ -546,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')
{
@@ -632,6 +969,18 @@ Logical
return SteamNetTransport_GetFakeAddressString()[0] != '\0';
}
//
// The environ.ini switch, read the same way RPL4.CPP reads it to decide
// whether to install the transport at all. Says nothing about whether the
// Steam client was actually there.
//
Logical
RPL4Lobby_Configured()
{
const char *steam_switch = getenv("RP412STEAM");
return (steam_switch != NULL && atoi(steam_switch) != 0) ? True : False;
}
Logical
RPL4Lobby_InRoom()
{
+9
View File
@@ -33,6 +33,15 @@ enum RPL4LobbyOutcome
Logical
RPL4Lobby_Available();
// True when this build has Steam and environ.ini asked for it, whether
// or not it actually came up. The menu offers the lobby buttons on this
// and greys them out on Available() - a player who turned Steam on and
// then launched without the client running should be told so, not left
// looking at a menu that quietly has two fewer buttons than the last
// time they saw it.
Logical
RPL4Lobby_Configured();
// True while we sit in a lobby (races return to the room).
Logical
RPL4Lobby_InRoom();
+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)
{
+14 -2
View File
@@ -357,8 +357,20 @@ Logical
//
if (GetApplicationState() == RunningMission)
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
// same rule as Application::ExecuteForeground - the console's
// countdown when there is one, our own reckoning otherwise. There
// is no console in mission review, so this takes the fallback.
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
}
}
CLEAR_FOREGROUND_PROCESSING();
+11
View File
@@ -70,6 +70,15 @@
<RandomizedBaseAddress>false</RandomizedBaseAddress>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<!-- rpl4build.h is generated, not committed: the patch number is the
repository's commit count, so a hardcoded one would be stale the
moment it was committed. The script rewrites the header only when
the stamp actually changes, so this does not drag RPL4.CPP through
a recompile on every build. -->
<PreBuildEvent>
<Command>powershell -NoProfile -ExecutionPolicy Bypass -File "$(ProjectDir)..\stamp-version.ps1"</Command>
<Message>Stamping the build version from git</Message>
</PreBuildEvent>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
@@ -109,6 +118,7 @@
<ClCompile Include=".\RPL4APP.cpp" />
<ClCompile Include=".\RPL4CONSOLE.cpp" />
<ClCompile Include=".\RPL4FE.cpp" />
<ClCompile Include=".\RPL4ENVIRON.cpp" />
<ClCompile Include=".\RPL4LOBBY.cpp" />
<ClCompile Include=".\RPL4ARND.cpp" />
<ClCompile Include=".\RPL4GAUG.cpp" />
@@ -147,6 +157,7 @@
<ClInclude Include=".\RPL4APP.h" />
<ClInclude Include=".\RPL4CONSOLE.h" />
<ClInclude Include=".\RPL4FE.h" />
<ClInclude Include=".\rpl4environ.h" />
<ClInclude Include=".\RPL4LOBBY.h" />
<ClInclude Include=".\RPL4ARND.h" />
<ClInclude Include=".\RPL4GAUG.h" />
+351 -384
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.

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