28 Commits
Author SHA1 Message Date
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
CydandClaude Fable 5 0cf5a23d2e Release 4.12.2: version bump
Carries the worldwide lobby-search filter for cross-region internet
testing (the only change since 4.12.1 besides the README rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:30:36 -05:00
CydandClaude Fable 5 13424cce50 README: the Steamification story; lobby search goes worldwide
README.md rewritten around the lineage: Red Planet 4.10 on the
Tesla 1 pod platform, 4.11 the Win32 port, 4.12 the Steamification
of 4.11 - with the arcade-to-consumer replacement table, current
status (v4.12.1 verified on three machines), playing/building/docs
sections brought up to date.

And ahead of internet-wide beta testing: lobby search now applies the
worldwide distance filter - Steam defaults to roughly same-region
results, which would have hidden lobbies from cross-region testers
even though joining them works fine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:27:45 -05:00
27 changed files with 5106 additions and 290 deletions
+3
View File
@@ -67,3 +67,6 @@ 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
+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;
}
+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;
+141 -31
View File
@@ -10,6 +10,33 @@ namespace
const int buttonGap = 4;
//---------------------------------------------------------------
// 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.
@@ -135,6 +162,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,
@@ -255,10 +286,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 +308,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 +400,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 +429,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 +490,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 +515,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()
{
+19
View File
@@ -65,6 +65,18 @@ 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();
~MFDSplitView();
Logical
@@ -128,4 +140,11 @@ protected:
buttonCount;
int
pressedIndex;
// kept so the bank can be rebuilt when the cockpit re-scales
ButtonStyle
buttonStyle;
int
buttonAnchorA,
buttonAnchorB;
};
+5 -2
View File
@@ -376,8 +376,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"
+705 -78
View File
@@ -3821,6 +3821,519 @@ static LRESULT CALLBACK
return CallWindowProcA(gViewscreenBaseProc, hwnd, message, wParam, lParam);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The cockpit shell: re-fit the canvas whenever the window changes size
// (maximise, restore, drag). The game window's own proc handles the
// rest, so chain to it.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
static WNDPROC gCockpitBaseProc = NULL;
static LRESULT CALLBACK
CockpitShellProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
if (message == WM_SIZE && wParam != SIZE_MINIMIZED)
{
SVGA16 *cockpit = SVGA16::GetCockpit();
if (cockpit != NULL)
{
cockpit->LayoutCockpit(LOWORD(lParam), HIWORD(lParam));
}
}
return CallWindowProcA(gCockpitBaseProc, hwnd, message, wParam, lParam);
}
SVGA16 *SVGA16::activeCockpit = NULL;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The six secondary displays at their pod sizes, in canvas units.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
static const int cockpitMfdBaseW = 320;
static const int cockpitMfdBaseH = 240;
// the radar reads best big: 1.35x the compact glass
// (1.5x proved a touch too large in playtest)
static const int cockpitRadarBaseW = 324;
static const int cockpitRadarBaseH = 432;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// A percentage from the environment, falling back to the given default
// when the variable is unset, unreadable or nonsense. That fallback is
// how a per-display setting inherits the group one.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
static int
DisplayScalePercent(const char *variable, int fallback)
{
const char *text = getenv(variable);
if (text == NULL)
{
return fallback;
}
int percent = atoi(text);
if (percent <= 0)
{
return fallback;
}
if (percent < 25) percent = 25;
if (percent > 200) percent = 200;
return percent;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Shrink a display to fit a limit, uniformly. Clamping one axis alone
// would stretch the glass out of shape, and these are photographs of
// real instruments - they have to keep their proportions.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
static void
ClampGlass(SVGA16::GlassSize *glass, int max_w, int max_h)
{
if (max_w < 1) max_w = 1;
if (max_h < 1) max_h = 1;
if (glass->w > max_w)
{
glass->h = (glass->h * max_w) / glass->w;
glass->w = max_w;
}
if (glass->h > max_h)
{
glass->w = (glass->w * max_h) / glass->h;
glass->h = max_h;
}
if (glass->w < 1) glass->w = 1;
if (glass->h < 1) glass->h = 1;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Where the radar sits. The pod had it dead centre under the
// viewscreen, which on a wide panel is exactly where the road is, so
// L4RADARPOS moves it out of the way: either bottom corner, or halfway
// up either side.
//
// On the bottom row it is one of three panes and the lower MFD whose
// corner it takes slides inboard beside it. Halfway up a side it leaves
// the bottom row altogether and sits between that side's two MFDs.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
enum RadarPlacement
{
RadarBottomCenter = 0,
RadarBottomLeft,
RadarBottomRight,
RadarMidLeft,
RadarMidRight
};
static Logical
RadarOnASide(RadarPlacement placement)
{
return (placement == RadarMidLeft || placement == RadarMidRight)
? True : False;
}
static Logical
RadarOnTheLeft(RadarPlacement placement)
{
return (placement == RadarBottomLeft || placement == RadarMidLeft)
? True : False;
}
static RadarPlacement
RadarPosition()
{
static int cached = -1;
if (cached < 0)
{
//---------------------------------------------------------------
// Two spellings each: the short ones were here first, the long
// ones say which edge out loud now that there are five.
//---------------------------------------------------------------
static const struct
{
const char *name;
int placement;
}
names[] =
{
{ "CENTER", RadarBottomCenter },
{ "CENTRE", RadarBottomCenter },
{ "BOTTOM", RadarBottomCenter },
{ "LEFT", RadarBottomLeft },
{ "BOTTOMLEFT", RadarBottomLeft },
{ "RIGHT", RadarBottomRight },
{ "BOTTOMRIGHT", RadarBottomRight },
{ "MIDLEFT", RadarMidLeft },
{ "LEFTCENTER", RadarMidLeft },
{ "LEFTCENTRE", RadarMidLeft },
{ "MIDRIGHT", RadarMidRight },
{ "RIGHTCENTER", RadarMidRight },
{ "RIGHTCENTRE", RadarMidRight }
};
cached = RadarBottomCenter;
const char *text = getenv("L4RADARPOS");
if (text != NULL)
{
for (int i = 0; i < (int) (sizeof(names) / sizeof(names[0])); ++i)
{
if (!stricmp(text, names[i].name))
{
cached = names[i].placement;
break;
}
}
}
static const char *described[] =
{
"bottom centre",
"bottom left",
"bottom right",
"left side, centred",
"right side, centred"
};
DEBUG_STREAM << "SVGA16: radar on the " << described[cached]
<< "\n" << std::flush;
}
return (RadarPlacement) cached;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The six secondary displays sized for a given canvas scale.
//
// The pod bolted these down at fixed sizes; a desktop panel has room to
// trade viewscreen for instrument, so the player scales them. Each has
// its own setting, falling back to the group one - L4MFDSCALE sets all
// five MFDs, L4MFDSCALE_UL and friends override individually, and
// L4RADARSCALE the radar.
//
// Scaling happens in canvas units, before the canvas is fitted to the
// window, so a given number means the same thing on every monitor.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
SVGA16::CockpitGlassSizes(int scale, GlassSize sizes[SplitViewCount])
{
static Logical settings_read = False;
static int percent[SplitViewCount];
if (!settings_read)
{
settings_read = True;
int group = DisplayScalePercent("L4MFDSCALE", 100);
percent[SplitMFDUpperLeft] =
DisplayScalePercent("L4MFDSCALE_UL", group);
percent[SplitMFDUpperCenter] =
DisplayScalePercent("L4MFDSCALE_UC", group);
percent[SplitMFDUpperRight] =
DisplayScalePercent("L4MFDSCALE_UR", group);
percent[SplitMFDLowerLeft] =
DisplayScalePercent("L4MFDSCALE_LL", group);
percent[SplitMFDLowerRight] =
DisplayScalePercent("L4MFDSCALE_LR", group);
percent[SplitMap] = DisplayScalePercent("L4RADARSCALE", 100);
DEBUG_STREAM << "SVGA16: secondary displays at UL "
<< percent[SplitMFDUpperLeft] << "% UC "
<< percent[SplitMFDUpperCenter] << "% UR "
<< percent[SplitMFDUpperRight] << "% LL "
<< percent[SplitMFDLowerLeft] << "% LR "
<< percent[SplitMFDLowerRight] << "% radar "
<< percent[SplitMap] << "%\n" << std::flush;
}
GlassSize glass[SplitViewCount];
for (int view = 0; view < SplitViewCount; ++view)
{
int base_w = (view == SplitMap) ? cockpitRadarBaseW : cockpitMfdBaseW;
int base_h = (view == SplitMap) ? cockpitRadarBaseH : cockpitMfdBaseH;
glass[view].w = (base_w * percent[view]) / 100;
glass[view].h = (base_h * percent[view]) / 100;
}
//-------------------------------------------------------------------
// Keep the pod arrangement legal in canvas units. Sizing each
// display on its own means these limits can be exact rather than one
// conservative rule covering all five: what constrains a display is
// its actual neighbour, not the largest a neighbour might have been.
//
// The panes overlap the viewscreen by design, as the pod's bezels
// did - each other, never.
//-------------------------------------------------------------------
const int canvas_w = 1920;
const int canvas_h = 1080;
const int half_w = canvas_w / 2;
// nothing may exceed the canvas on its own
for (int view = 0; view < SplitViewCount; ++view)
{
ClampGlass(&glass[view], canvas_w, canvas_h);
}
//-------------------------------------------------------------------
// The radar shares its column with the MFDs above and below it, so
// their heights share the canvas. Which MFDs those are, and how much
// room they leave, depends on where the radar was put. It yields,
// being the one that grows into the view.
//-------------------------------------------------------------------
RadarPlacement placement = RadarPosition();
if (RadarOnASide(placement))
{
//---------------------------------------------------------------
// Halfway up a side it has an MFD above AND below, and it grows
// from the middle in both directions - so it must clear the
// taller of the two twice over. That is a real constraint: two
// big MFDs on one side leave a centred radar very little.
//---------------------------------------------------------------
Logical left = RadarOnTheLeft(placement);
int upper = left
? glass[SplitMFDUpperLeft].h : glass[SplitMFDUpperRight].h;
int lower = left
? glass[SplitMFDLowerLeft].h : glass[SplitMFDLowerRight].h;
int tallest = (upper > lower) ? upper : lower;
ClampGlass(&glass[SplitMap], canvas_w, canvas_h - 2 * tallest);
}
else
{
//---------------------------------------------------------------
// On the bottom edge it only has to clear the MFD hanging from
// the top of its column.
//---------------------------------------------------------------
int above =
(placement == RadarBottomLeft) ? glass[SplitMFDUpperLeft].h :
(placement == RadarBottomRight) ? glass[SplitMFDUpperRight].h :
glass[SplitMFDUpperCenter].h;
ClampGlass(&glass[SplitMap], canvas_w, canvas_h - above);
}
//-------------------------------------------------------------------
// The top row is a centred display with one anchored to either edge,
// so a side display gets whatever the centre one leaves of its half.
//-------------------------------------------------------------------
int top_side = half_w - glass[SplitMFDUpperCenter].w / 2;
ClampGlass(&glass[SplitMFDUpperLeft], top_side, canvas_h);
ClampGlass(&glass[SplitMFDUpperRight], top_side, canvas_h);
//-------------------------------------------------------------------
// What the bottom row looks like depends on the radar as well:
// centred it is the middle of that row, in a corner it is a third
// pane along it, and on a side it has left the row entirely and the
// two MFDs have the whole bottom edge to share.
//-------------------------------------------------------------------
if (RadarOnASide(placement))
{
ClampGlass(&glass[SplitMFDLowerLeft], half_w, canvas_h);
ClampGlass(&glass[SplitMFDLowerRight], half_w, canvas_h);
}
else if (placement == RadarBottomCenter)
{
int bottom_side = half_w - glass[SplitMap].w / 2;
ClampGlass(&glass[SplitMFDLowerLeft], bottom_side, canvas_h);
ClampGlass(&glass[SplitMFDLowerRight], bottom_side, canvas_h);
}
else
{
int bottom_share = (canvas_w - glass[SplitMap].w) / 2;
ClampGlass(&glass[SplitMFDLowerLeft], bottom_share, canvas_h);
ClampGlass(&glass[SplitMFDLowerRight], bottom_share, canvas_h);
}
//-------------------------------------------------------------------
// An upper and a lower MFD share a column, anchored to opposite
// edges, so their heights must not sum past the canvas. The lower
// one yields - it is the one the radar sits beside.
//-------------------------------------------------------------------
ClampGlass(&glass[SplitMFDLowerLeft], canvas_w,
canvas_h - glass[SplitMFDUpperLeft].h);
ClampGlass(&glass[SplitMFDLowerRight], canvas_w,
canvas_h - glass[SplitMFDUpperRight].h);
//-------------------------------------------------------------------
// Only now to device pixels: clamping in canvas units keeps every
// limit above independent of the monitor.
//-------------------------------------------------------------------
for (int view = 0; view < SplitViewCount; ++view)
{
sizes[view].w = (glass[view].w * scale) / 100;
sizes[view].h = (glass[view].h * scale) / 100;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Clear the glass off the screen for the Winners Circle.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
SVGA16::HideSecondaryDisplays()
{
Check_Pointer(this);
if (!splitViews)
{
return;
}
for (int view = 0; view < SplitViewCount; ++view)
{
if (splitView[view] != NULL)
{
splitView[view]->Hide();
}
}
DEBUG_STREAM << "SVGA16: secondary displays hidden for the podium\n"
<< std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Fit the 1920x1080 cockpit canvas into the given client area: one
// uniform scale (so the canvas never stretches - a wider desktop just
// letterboxes), centred, with every pane re-scaled and re-placed.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
SVGA16::LayoutCockpit(int client_w, int client_h)
{
Check_Pointer(this);
if (!splitViews || client_w <= 0 || client_h <= 0)
{
return;
}
const int canvas_w = 1920;
const int canvas_h = 1080;
int fit_w = (client_w * 100) / canvas_w;
int fit_h = (client_h * 100) / canvas_h;
int scale = (fit_w < fit_h) ? fit_w : fit_h;
if (scale < 25) scale = 25;
int view_w = (canvas_w * scale) / 100;
int view_h = (canvas_h * scale) / 100;
// centre the canvas: the leftover is black on the long axis
int origin_x = (client_w - view_w) / 2;
int origin_y = (client_h - view_h) / 2;
//---------------------------------------------------------------
// The viewscreen fills the canvas; the 3D presents into it and
// D3D stretches the back buffer to fit, so -res only decides how
// sharp the scene is, not how big the cockpit is.
//---------------------------------------------------------------
if (cockpitViewscreen != NULL)
{
SetWindowPos(cockpitViewscreen, NULL,
origin_x, origin_y, view_w, view_h,
SWP_NOZORDER | SWP_NOACTIVATE);
}
GlassSize glass[SplitViewCount];
CockpitGlassSizes(scale, glass);
for (int view = 0; view < SplitViewCount; ++view)
{
if (splitView[view] == NULL)
{
continue;
}
splitView[view]->Resize(glass[view].w, glass[view].h);
}
//---------------------------------------------------------------
// Top row edge to edge across the canvas, bottom cluster on the
// bottom edge - all relative to the centred canvas origin.
//---------------------------------------------------------------
if (splitView[SplitMFDUpperLeft] != NULL)
{
splitView[SplitMFDUpperLeft]->SetPosition(origin_x, origin_y);
}
if (splitView[SplitMFDUpperCenter] != NULL)
{
splitView[SplitMFDUpperCenter]->SetPosition(
origin_x + (view_w - splitView[SplitMFDUpperCenter]->ClientWidth()) / 2,
origin_y);
}
if (splitView[SplitMFDUpperRight] != NULL)
{
splitView[SplitMFDUpperRight]->SetPosition(
origin_x + view_w - splitView[SplitMFDUpperRight]->ClientWidth(),
origin_y);
}
//---------------------------------------------------------------
// The radar: bottom centre, either bottom corner, or halfway up
// either side. Only when it takes a bottom corner does a lower MFD
// have to move - on a side it is out of that row's way already.
//---------------------------------------------------------------
RadarPlacement placement = RadarPosition();
int radar_w = (splitView[SplitMap] != NULL)
? splitView[SplitMap]->ClientWidth() : 0;
if (splitView[SplitMap] != NULL)
{
int radar_h = splitView[SplitMap]->ClientHeight();
int radar_x, radar_y;
switch (placement)
{
case RadarBottomLeft:
radar_x = origin_x;
radar_y = origin_y + view_h - radar_h;
break;
case RadarBottomRight:
radar_x = origin_x + view_w - radar_w;
radar_y = origin_y + view_h - radar_h;
break;
case RadarMidLeft:
radar_x = origin_x;
radar_y = origin_y + (view_h - radar_h) / 2;
break;
case RadarMidRight:
radar_x = origin_x + view_w - radar_w;
radar_y = origin_y + (view_h - radar_h) / 2;
break;
default:
radar_x = origin_x + (view_w - radar_w) / 2;
radar_y = origin_y + view_h - radar_h;
break;
}
splitView[SplitMap]->SetPosition(radar_x, radar_y);
}
int slide_left = (placement == RadarBottomLeft) ? radar_w : 0;
int slide_right = (placement == RadarBottomRight) ? radar_w : 0;
if (splitView[SplitMFDLowerLeft] != NULL)
{
splitView[SplitMFDLowerLeft]->SetPosition(
origin_x + slide_left,
origin_y + view_h - splitView[SplitMFDLowerLeft]->ClientHeight());
}
if (splitView[SplitMFDLowerRight] != NULL)
{
splitView[SplitMFDLowerRight]->SetPosition(
origin_x + view_w - splitView[SplitMFDLowerRight]->ClientWidth()
- slide_right,
origin_y + view_h - splitView[SplitMFDLowerRight]->ClientHeight());
}
// The panes go OVER the main display (pod bezel occlusion): pin
// the viewscreen to the bottom of the sibling z-order so the 3D
// present clips around every pane.
if (cockpitViewscreen != NULL)
{
SetWindowPos(cockpitViewscreen, HWND_BOTTOM, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
DEBUG_STREAM << "SVGA16: cockpit fitted " << view_w << "x" << view_h
<< " at " << scale << "% in a " << client_w << "x" << client_h
<< " client\n" << std::flush;
}
SVGA16::SVGA16(
int mode,
int init_width,
@@ -3842,11 +4355,19 @@ SVGA16::SVGA16(
//------------------------------------------------------------------
splitViews = False;
cockpitViewscreen = NULL;
Logical explodedViews = False;
{
const char *split_string = getenv("L4MFDSPLIT");
if (split_string != NULL && atoi(split_string) != 0)
{
splitViews = True;
// L4MFDSPLIT=2+ : exploded diagnostic view - each display
// in its own native-resolution desktop window instead of
// the composited glass cockpit.
if (atoi(split_string) >= 2)
{
explodedViews = True;
}
}
}
for (int view = 0; view < SplitViewCount; ++view)
@@ -3856,7 +4377,88 @@ SVGA16::SVGA16(
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
if (splitViews)
if (splitViews && explodedViews)
{
//---------------------------------------------------------------
// Exploded diagnostic view: every display in its own full-size
// desktop window (MFDs native 640x480, map 480x640 rotated),
// decoded exactly as the pod's VDB split them from the single
// gauge canvas - no cockpit compositing, no downscale. Each MFD
// can be read and screenshotted at full resolution, matching the
// emulator's per-channel reference windows. Laid out in the pod
// arrangement (top row / bottom row) across the work area; the
// windows are draggable (dragging a title bar briefly pauses the
// game's single-per-frame message pump, as expected).
//---------------------------------------------------------------
RECT work;
work.left = 0; work.top = 0; work.right = 1920; work.bottom = 1080;
SystemParametersInfoA(SPI_GETWORKAREA, 0, &work, 0);
int work_w = work.right - work.left;
int work_h = work.bottom - work.top;
//---------------------------------------------------------------
// Each display carries its own button bank here too, at native
// size: the banks reach under the glass with their indicator
// strips clearing the edge, exactly as in the composited
// cockpit - so the buttons can be read and pressed full size.
//---------------------------------------------------------------
splitView[SplitMFDUpperLeft] = new MFDSplitView(
"MFD upper left", init_width, init_height,
init_width, init_height, work.left, work.top,
MFDSplitView::MFDStrips, 0x2F, 0, 0);
splitView[SplitMFDUpperCenter] = new MFDSplitView(
"MFD upper center", init_width, init_height,
init_width, init_height, work.left, work.top,
MFDSplitView::MFDStrips, 0x27, 0, 0);
splitView[SplitMFDUpperRight] = new MFDSplitView(
"MFD upper right", init_width, init_height,
init_width, init_height, work.left, work.top,
MFDSplitView::MFDStrips, 0x37, 0, 0);
splitView[SplitMFDLowerLeft] = new MFDSplitView(
"MFD lower left", init_width, init_height,
init_width, init_height, work.left, work.top,
MFDSplitView::MFDStrips, 0x0F, 0, 0);
splitView[SplitMFDLowerRight] = new MFDSplitView(
"MFD lower right", init_width, init_height,
init_width, init_height, work.left, work.top,
MFDSplitView::MFDStrips, 0x07, 0, 0);
// map is portrait-mounted: source rotated to 480x640
splitView[SplitMap] = new MFDSplitView(
"Map", init_height, init_width,
init_height, init_width, work.left, work.top,
MFDSplitView::SideColumns, 0x10, 0x18, 0);
//---------------------------------------------------------------
// Lay them out in the pod arrangement from their measured sizes
// (the banks make each window bigger than its glass).
//---------------------------------------------------------------
RECT probe;
probe.left = 0; probe.top = 0; probe.right = 100; probe.bottom = 100;
AdjustWindowRect(&probe,
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, FALSE);
int chrome_w = (probe.right - probe.left) - 100;
int chrome_h = (probe.bottom - probe.top) - 100;
int mfd_w = splitView[SplitMFDUpperLeft]->ClientWidth() + chrome_w;
int mfd_h = splitView[SplitMFDUpperLeft]->ClientHeight() + chrome_h;
int map_w = splitView[SplitMap]->ClientWidth() + chrome_w;
int map_h = splitView[SplitMap]->ClientHeight() + chrome_h;
int mid_x = (work_w - mfd_w) / 2; if (mid_x < 0) mid_x = 0;
int right_x = work_w - mfd_w; if (right_x < 0) right_x = 0;
int bottom_y = work_h - mfd_h; if (bottom_y < 0) bottom_y = 0;
int map_x = (work_w - map_w) / 2; if (map_x < 0) map_x = 0;
int map_y = work_h - map_h; if (map_y < 0) map_y = 0;
splitView[SplitMFDUpperLeft]->SetPosition(work.left, work.top);
splitView[SplitMFDUpperCenter]->SetPosition(work.left + mid_x, work.top);
splitView[SplitMFDUpperRight]->SetPosition(work.left + right_x, work.top);
splitView[SplitMFDLowerLeft]->SetPosition(work.left, work.top + bottom_y);
splitView[SplitMFDLowerRight]->SetPosition(
work.left + right_x, work.top + bottom_y);
splitView[SplitMap]->SetPosition(work.left + map_x, work.top + map_y);
}
else if (splitViews)
{
//---------------------------------------------------------------
// The 1920x1080 internal cockpit canvas.
@@ -3876,8 +4478,20 @@ SVGA16::SVGA16(
HWND cockpit = ApplicationManager::GetCurrentManager()->GetHWnd();
//---------------------------------------------------------------
// -fit takes the whole monitor rather than the work area, and
// pays no chrome: the shell goes borderless below. Otherwise
// stay inside the work area so the taskbar is still reachable.
//---------------------------------------------------------------
bool fit_display = L4Application::GetFitDisplay();
int chrome_w = 16, chrome_h = 40;
if (cockpit != NULL)
if (fit_display)
{
chrome_w = 0;
chrome_h = 0;
}
else if (cockpit != NULL)
{
RECT outer, inner;
GetWindowRect(cockpit, &outer);
@@ -3888,22 +4502,46 @@ SVGA16::SVGA16(
RECT work;
work.left = 0; work.top = 0; work.right = 1920; work.bottom = 1080;
SystemParametersInfoA(SPI_GETWORKAREA, 0, &work, 0);
if (fit_display)
{
MONITORINFO monitor;
memset(&monitor, 0, sizeof(monitor));
monitor.cbSize = sizeof(monitor);
HMONITOR handle = MonitorFromWindow(
(cockpit != NULL) ? cockpit : GetDesktopWindow(),
MONITOR_DEFAULTTOPRIMARY);
if (GetMonitorInfoA(handle, &monitor))
{
work = monitor.rcMonitor;
}
else
{
work.right = GetSystemMetrics(SM_CXSCREEN);
work.bottom = GetSystemMetrics(SM_CYSCREEN);
}
}
else
{
SystemParametersInfoA(SPI_GETWORKAREA, 0, &work, 0);
}
int avail_w = (work.right - work.left) - chrome_w;
int avail_h = (work.bottom - work.top) - chrome_h;
int scale_num = 100;
if (avail_w < canvas_w || avail_h < canvas_h)
{
int fit_w = (avail_w * 100) / canvas_w;
int fit_h = (avail_h * 100) / canvas_h;
scale_num = (fit_w < fit_h) ? fit_w : fit_h;
if (scale_num < 25) scale_num = 25;
//-----------------------------------------------------------
// One uniform scale, up or down: the canvas keeps its 16:9
// shape whatever the monitor is, so a wider-than-16:9 desktop
// letterboxes instead of stretching. Scaling UP is free
// quality on the MFDs - their glass is a downscale of a
// native 640x480 channel until about 200%.
//-----------------------------------------------------------
int fit_w = (avail_w * 100) / canvas_w;
int fit_h = (avail_h * 100) / canvas_h;
int scale_num = (fit_w < fit_h) ? fit_w : fit_h;
if (scale_num < 25) scale_num = 25;
DEBUG_STREAM << "SVGA16: cockpit canvas scaled to " << scale_num
<< "% for the " << (work.right - work.left) << "x"
<< (work.bottom - work.top) << " work area\n" << std::flush;
}
DEBUG_STREAM << "SVGA16: cockpit canvas at " << scale_num
<< "% for the " << (work.right - work.left) << "x"
<< (work.bottom - work.top) << " work area\n" << std::flush;
#define COCKPIT_CANVAS(v) (((v) * scale_num) / 100)
@@ -3915,12 +4553,35 @@ SVGA16::SVGA16(
//---------------------------------------------------------------
if (cockpit != NULL)
{
SetWindowLongPtrA(cockpit, GWL_STYLE,
GetWindowLongPtrA(cockpit, GWL_STYLE) | WS_CLIPCHILDREN);
LONG_PTR style = GetWindowLongPtrA(cockpit, GWL_STYLE);
SetWindowPos(cockpit, NULL, work.left, work.top,
client_w + chrome_w, client_h + chrome_h,
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
if (fit_display)
{
//-------------------------------------------------------
// Borderless over the whole monitor. The window is the
// full panel, wider than 16:9 or not - LayoutCockpit
// centres the canvas inside it and the leftover stays
// black, so this letterboxes exactly like a resize.
//-------------------------------------------------------
style &= ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU |
WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_BORDER |
WS_DLGFRAME);
style |= WS_POPUP | WS_CLIPCHILDREN;
SetWindowLongPtrA(cockpit, GWL_STYLE, style);
SetWindowPos(cockpit, NULL, work.left, work.top,
work.right - work.left, work.bottom - work.top,
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
else
{
SetWindowLongPtrA(cockpit, GWL_STYLE,
style | WS_CLIPCHILDREN);
SetWindowPos(cockpit, NULL, work.left, work.top,
client_w + chrome_w, client_h + chrome_h,
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
}
//---------------------------------------------------------------
@@ -3961,95 +4622,61 @@ SVGA16::SVGA16(
// 0x37.. / lower left 0x0F.. / lower right 0x07.. and the map
// flanked by Secondary 0x10-0x15 / Screen 0x18-0x1D.
//---------------------------------------------------------------
// all five MFDs at the compact size, corners + top center
int top_glass_w = COCKPIT_CANVAS(320);
int top_glass_h = COCKPIT_CANVAS(240);
int bot_glass_w = COCKPIT_CANVAS(320);
int bot_glass_h = COCKPIT_CANVAS(240);
// the radar reads best big: 1.35x the compact glass
// (1.5x proved a touch too large in playtest)
int map_glass_w = COCKPIT_CANVAS(324);
int map_glass_h = COCKPIT_CANVAS(432);
// each display at whatever the player asked for it
GlassSize glass[SplitViewCount];
CockpitGlassSizes(scale_num, glass);
splitView[SplitMFDUpperLeft] = new MFDSplitView(
"MFD upper left", init_width, init_height,
top_glass_w, top_glass_h, 0, 0,
glass[SplitMFDUpperLeft].w, glass[SplitMFDUpperLeft].h, 0, 0,
MFDSplitView::MFDStrips, 0x2F, 0, cockpit);
splitView[SplitMFDUpperCenter] = new MFDSplitView(
"MFD upper center", init_width, init_height,
top_glass_w, top_glass_h, 0, 0,
glass[SplitMFDUpperCenter].w, glass[SplitMFDUpperCenter].h, 0, 0,
MFDSplitView::MFDStrips, 0x27, 0, cockpit);
splitView[SplitMFDUpperRight] = new MFDSplitView(
"MFD upper right", init_width, init_height,
top_glass_w, top_glass_h, 0, 0,
glass[SplitMFDUpperRight].w, glass[SplitMFDUpperRight].h, 0, 0,
MFDSplitView::MFDStrips, 0x37, 0, cockpit);
splitView[SplitMFDLowerLeft] = new MFDSplitView(
"MFD lower left", init_width, init_height,
bot_glass_w, bot_glass_h, 0, 0,
glass[SplitMFDLowerLeft].w, glass[SplitMFDLowerLeft].h, 0, 0,
MFDSplitView::MFDStrips, 0x0F, 0, cockpit);
splitView[SplitMFDLowerRight] = new MFDSplitView(
"MFD lower right", init_width, init_height,
bot_glass_w, bot_glass_h, 0, 0,
glass[SplitMFDLowerRight].w, glass[SplitMFDLowerRight].h, 0, 0,
MFDSplitView::MFDStrips, 0x07, 0, cockpit);
// map is portrait: source rotated 90 degrees clockwise
splitView[SplitMap] = new MFDSplitView(
"Map", init_height, init_width,
map_glass_w, map_glass_h, 0, 0,
glass[SplitMap].w, glass[SplitMap].h, 0, 0,
MFDSplitView::SideColumns, 0x10, 0x18, cockpit);
//---------------------------------------------------------------
// Place the panes from their measured sizes: top row edge to
// edge across the canvas, bottom cluster on the bottom edge.
// Everything above built the panes at a starting size; the
// layout itself lives in LayoutCockpit so a resized window can
// re-run it. Track the instance for the WM_SIZE hook below.
//---------------------------------------------------------------
if (splitView[SplitMFDUpperLeft] != NULL)
{
splitView[SplitMFDUpperLeft]->SetPosition(0, 0);
}
if (splitView[SplitMFDUpperCenter] != NULL)
{
splitView[SplitMFDUpperCenter]->SetPosition(
(client_w - splitView[SplitMFDUpperCenter]->ClientWidth()) / 2, 0);
}
if (splitView[SplitMFDUpperRight] != NULL)
{
splitView[SplitMFDUpperRight]->SetPosition(
client_w - splitView[SplitMFDUpperRight]->ClientWidth(), 0);
}
if (splitView[SplitMFDLowerLeft] != NULL)
{
splitView[SplitMFDLowerLeft]->SetPosition(
0, client_h - splitView[SplitMFDLowerLeft]->ClientHeight());
}
if (splitView[SplitMFDLowerRight] != NULL)
{
splitView[SplitMFDLowerRight]->SetPosition(
client_w - splitView[SplitMFDLowerRight]->ClientWidth(),
client_h - splitView[SplitMFDLowerRight]->ClientHeight());
}
if (splitView[SplitMap] != NULL)
{
splitView[SplitMap]->SetPosition(
(client_w - splitView[SplitMap]->ClientWidth()) / 2,
client_h - splitView[SplitMap]->ClientHeight());
}
activeCockpit = this;
// The panes go OVER the main display (pod bezel occlusion): pin
// the viewscreen to the bottom of the sibling z-order so the 3D
// present clips around every pane.
if (cockpitViewscreen != NULL)
if (cockpit != NULL)
{
SetWindowPos(cockpitViewscreen, HWND_BOTTOM, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
RECT inner;
GetClientRect(cockpit, &inner);
LayoutCockpit(inner.right, inner.bottom);
// catch maximise / restore / drag-resize and re-fit
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
}
else
{
LayoutCockpit(client_w, client_h);
}
// the plasma glass sits out for now
PlasmaScreen::Hide();
DEBUG_STREAM << "SVGA16: single-window cockpit assembled ("
<< client_w << "x" << client_h << " canvas at " << scale_num
<< "%, viewscreen " << view_w << "x" << view_h
<< " centered)\n" << std::flush;
#undef COCKPIT_CANVAS
}
//STUBBED: VIDEO RB 1/15/07
+50
View File
@@ -308,6 +308,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 +335,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
};
//########################################################################
+437 -22
View File
@@ -1797,6 +1797,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 +2356,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 +5664,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();
@@ -5997,12 +6071,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 +6324,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 +6963,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 +7167,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)
{
+95 -24
View File
@@ -1,37 +1,108 @@
# Red Planet 4.12
# Red Planet 4.12 — the Steamification
**Red Planet** is the VWE pod-racing game built on the in-house **MUNGA** engine
(Win32 / DirectX 9, `MUNGA_L4` platform layer). The **4.12** line is the
consumer port: a version of Red Planet that can be **sold on Steam** and played
over **internet multiplayer**, without cockpit-pod hardware.
**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:
Forked from [RP411](https://gitea.mysticmachines.com/VWE/RP411.git)
(the arcade/cockpit 4.11 line) with full history preserved.
| 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 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 |
## Goals
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.
1. **Steam distribution** — Steamworks integration, SteamPipe builds, store-ready packaging.
2. **Internet multiplayer** — take the engine's LAN-era WinSock networking online
(NAT traversal, matchmaking, latency tolerance).
3. **No cockpit required** — keyboard / mouse / gamepad input and on-screen
replacements for the pod's RIO panel and plasma display, drawing on
[vRIO](https://gitea.mysticmachines.com/VWE/VRIO.git).
4. **Self-hosted sessions** — in-game race setup/join replacing the operator-driven
[TeslaConsole](https://gitea.mysticmachines.com/VWE/TeslaSuite.git) flow.
| Arcade (4.10/4.11) | 4.12 replacement |
|--------------------|------------------|
| 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 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 |
See [docs/RP412-ROADMAP.md](docs/RP412-ROADMAP.md) for the plan and the
Steam-multiplayer logistics.
**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.
## Playing
Grab the release zip (or run `pack-dist.ps1` on a build). Single player:
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).
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. Full map with pad and keyboard diagrams:
[docs/CONTROLS.md](docs/CONTROLS.md).
## Building
Unchanged from 4.11 for now — see [BUILD.md](BUILD.md)
(VS 2005/2008 + DirectX SDK June 2010, `Release|Win32`, output `Release\rpl4opt.exe`).
Toolchain modernization is a roadmap item.
**VS 2022 (v143)** + DirectX SDK June 2010 + the vendored Steamworks SDK
(`extern/steamworks_sdk_164`) — see [BUILD.md](BUILD.md). Solution
`WinTesla.sln`, configuration `Release|Win32`, output
`Release\rpl4opt.exe`.
## Documentation
| 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 |
| [BUILD.md](BUILD.md) | Toolchain and build steps |
Dev tooling: `tools/two-pod-test.ps1` races two pods on loopback,
marshaled by a console feeder speaking the arcade Munga protocol.
## Related repositories
| Repo | Role |
|------|------|
| [RP411](https://gitea.mysticmachines.com/VWE/RP411.git) | Upstream arcade source (this repo's base) |
| [VRIO](https://gitea.mysticmachines.com/VWE/VRIO.git) | Virtual RIO panel + vPLASMA display — input bindings and display emulation to fold in |
| [TeslaSuite](https://gitea.mysticmachines.com/VWE/TeslaSuite.git) | TeslaConsole / Launcher / vPOD — the arcade session-control stack 4.12 replaces (and a reference for the Munga control protocol, TCP 1501) |
| [RP411](https://gitea.mysticmachines.com/VWE/RP411.git) | Upstream: the Win32 arcade port this repo Steamifies (full history preserved; remote `rp411` for cross-pulling fixes) |
| [VRIO](https://gitea.mysticmachines.com/VWE/VRIO.git) | Virtual RIO panel + vPLASMA — source of the bindings format, the cockpit layout, and the keyboard lamp mirror |
| [TeslaSuite](https://gitea.mysticmachines.com/VWE/TeslaSuite.git) | TeslaConsole / Launcher / vPOD — the arcade session-control stack 4.12 absorbed (and the reference implementation of the Munga control protocol, TCP 1501) |
+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();
}
+1 -1
View File
@@ -177,7 +177,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
fclose(file);
}
DEBUG_STREAM << "Red Planet 4.12.1" << std::endl << std::flush;
DEBUG_STREAM << "Red Planet 4.12.5" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM
+371
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,333 @@ 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.
//---------------------------------------------------------------------
//
char winners_spot[] = "win?";
char *place = winners_spot + 3;
int placed = 0;
Point3D standFront(0.0f, 0.0f, 0.0f);
Point3D standCentre(0.0f, 0.0f, 0.0f);
Player *p;
for (int rank = 0; (p = CameraDirector::FindPlayerByRank(rank)) != NULL; ++rank)
{
if (rank > 7)
{
break; // only eight spots exist on the stand
}
*place = (char) ('1' + rank);
ChainIteratorOf<Node*> iterator(dropzones->groupMembers);
DropZone *dropzone;
while ((dropzone = (DropZone*) iterator.ReadAndNext()) != NULL)
{
if (!strcmp(dropzone->GetDropZoneName(), winners_spot))
{
break;
}
}
if (dropzone == NULL)
{
continue;
}
Entity *vehicle = p->GetPlayerVehicle();
if (vehicle == NULL || vehicle->GetClassID() != VTVClassID)
{
continue;
}
VTV *vtv = (VTV*) vehicle;
vtv->Reset(dropzone->localOrigin, VTV::MissionReviewReset);
vtv->SetPerformance(&VTV::DoNothing);
vtv->FlushEvents();
Point3D spot = dropzone->localOrigin.linearPosition;
if (placed == 0)
{
// remember where the front of the stand is - the shot is framed
// off it rather than off hardcoded map coordinates
standFront = spot;
}
standCentre += spot;
DEBUG_STREAM << "WinnersCircle: " << winners_spot << " at "
<< spot.x << "," << spot.y << "," << spot.z << "\n" << std::flush;
++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 middle of the group.
//---------------------------------------------------------------------
//
standCentre.x /= (Scalar) placed;
standCentre.y /= (Scalar) placed;
standCentre.z /= (Scalar) placed;
//
// Stand off along the line from the middle of the group out through the
// front spot, so the shot faces the stand however the map has it turned,
// and lift the eye above the top tier to look down on the line-up.
//
Vector3D facing;
facing.x = standFront.x - standCentre.x;
facing.y = 0.0f;
facing.z = standFront.z - standCentre.z;
Scalar reach = (Scalar) sqrt(facing.x * facing.x + facing.z * facing.z);
if (reach < 0.01f)
{
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 = standCentre.x + (facing.x / reach) * standoff;
eye.y = standCentre.y + height;
eye.z = standCentre.z + (facing.z / reach) * standoff;
standCentre.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, standCentre);
}
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);
DEBUG_STREAM << "WinnersCircle: " << placed << " placed; centre "
<< standCentre.x << "," << standCentre.y << "," << standCentre.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();
};
+13
View File
@@ -498,6 +498,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;
+600 -69
View File
@@ -35,6 +35,52 @@ namespace
{ "headoff", "Freezemoon's Freeway" },
};
// Football excludes pain/headoff and adds the football build of
// Freezemoon's Freeway (RPConfig.xml per-scenario invalid lists).
const CatalogEntry kFootballMaps[] =
{
{ "wise", "Wiseguy's Wake" },
{ "lyzlane", "Lyz's Lane" },
{ "yip", "Yip's Yahoorama" },
{ "blade", "Blade's Edge" },
{ "otto", "Berserker's Bahnzai" },
{ "frstrm", "Firestorm's Fury" },
{ "burnt", "Burnt Cookies" },
{ "brewers", "Brewer's Bane" },
{ "headmf", "Freezemoon's Freeway" },
};
const CatalogEntry kScenarios[] =
{
{ "race", "Death Race" },
{ "football", "Football" },
};
// Two-color teams: crushers/blockers wear the team color, the
// runner wears the runner color (that is how you spot the ball).
struct TeamEntry
{
const char *key;
const char *name;
const char *teamColor;
const char *runnerColor;
};
const TeamEntry kTeams[] =
{
{ "Red/Pink", "Red / Pink", "Red", "Pink" },
{ "Blue/Aqua", "Blue / Aqua", "Blue", "Aqua" },
{ "Green/Yellow", "Green / Yellow", "Green", "Yellow" },
{ "Black/Purple", "Black / Purple", "Black", "Purple" },
};
const CatalogEntry kPositions[] =
{
{ "runner", "Runner" },
{ "crusher", "Crusher" },
{ "blocker", "Blocker" },
};
const CatalogEntry kVehicles[] =
{
{ "quark", "Quark" },
@@ -125,12 +171,31 @@ namespace
GroupTime,
GroupWeather,
GroupLength,
GroupScenario,
GroupTeam, // football only
GroupPosition, // football only
GroupLaunch,
GroupSteamHost, // buttons, not selections (Steam builds only)
GroupSteamJoin,
GroupCount
};
Logical IsFootball(const int *selection)
{
return selection[GroupScenario] == 1;
}
const CatalogEntry *ActiveMaps(const int *selection, int *count)
{
if (IsFootball(selection))
{
*count = (int)(sizeof(kFootballMaps) / sizeof(kFootballMaps[0]));
return kFootballMaps;
}
*count = (int)(sizeof(kMaps) / sizeof(kMaps[0]));
return kMaps;
}
struct FEItem
{
int group;
@@ -185,6 +250,8 @@ namespace
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick, "" = assign for them
char position[16];
};
// lobby-fed override (real personas + loadouts); empty = env parsing
@@ -208,6 +275,8 @@ namespace
gHostedPilots[i].color[0] ? gHostedPilots[i].color : "Red");
strcpy(extras[i].badge,
gHostedPilots[i].badge[0] ? gHostedPilots[i].badge : "None");
strcpy(extras[i].team, gHostedPilots[i].team);
strcpy(extras[i].position, gHostedPilots[i].position);
}
return gHostedPilotCount;
}
@@ -259,6 +328,7 @@ namespace
}
ExtraPilot *pilot = &extras[extra_count];
memset(pilot, 0, sizeof(*pilot));
sprintf(pilot->address, "%s:%d", entry, console_port + 1);
sprintf(pilot->name, "PILOT %d", extra_count + 2);
strcpy(pilot->vehicle, kVehicles[
@@ -311,8 +381,10 @@ namespace
int top = client_h / 7;
int y = top;
AddGroupItems(fe, GroupMap, FE_COUNT(kMaps), col1, &y, row_h, col_w);
int y_after_maps = y;
AddGroupItems(fe, GroupScenario, FE_COUNT(kScenarios), col1, &y, row_h, col_w);
int map_count;
ActiveMaps(fe->selection, &map_count);
AddGroupItems(fe, GroupMap, map_count, col1, &y, row_h, col_w);
AddGroupItems(fe, GroupTime, FE_COUNT(kTimes), col1, &y, row_h, col_w);
AddGroupItems(fe, GroupWeather, FE_COUNT(kWeather), col1, &y, row_h, col_w);
AddGroupItems(fe, GroupLength, FE_COUNT(kLengths), col1, &y, row_h, col_w);
@@ -321,8 +393,16 @@ namespace
AddGroupItems(fe, GroupVehicle, FE_COUNT(kVehicles), col2, &y, row_h, col_w);
y = top + 2 * row_h; // leave room for the name edit + header
AddGroupItems(fe, GroupColor, FE_COUNT(kColors), col3, &y, row_h, col_w);
AddGroupItems(fe, GroupBadge, FE_COUNT(kBadges), col3, &y, row_h, col_w);
if (IsFootball(fe->selection))
{
AddGroupItems(fe, GroupTeam, FE_COUNT(kTeams), col3, &y, row_h, col_w);
AddGroupItems(fe, GroupPosition, FE_COUNT(kPositions), col3, &y, row_h, col_w);
}
else
{
AddGroupItems(fe, GroupColor, FE_COUNT(kColors), col3, &y, row_h, col_w);
AddGroupItems(fe, GroupBadge, FE_COUNT(kBadges), col3, &y, row_h, col_w);
}
// launch button
FEItem *launch = &fe->items[fe->itemCount++];
@@ -365,31 +445,45 @@ namespace
{
switch (group)
{
case GroupScenario: return "SCENARIO";
case GroupMap: return "TRACK";
case GroupVehicle: return "VEHICLE";
case GroupColor: return "COLOR";
case GroupBadge: return "BADGE";
case GroupTeam: return "TEAM";
case GroupPosition: return "POSITION";
case GroupTime: return "TIME OF DAY";
case GroupWeather: return "WEATHER";
case GroupLength: return "RACE LENGTH";
case GroupLength: return "GAME LENGTH";
}
return "";
}
// the map rows need the active scenario's list
const int *gItemNameSelection = NULL;
const char *ItemName(int group, int index)
{
switch (group)
{
case GroupMap: return kMaps[index].name;
case GroupScenario: return kScenarios[index].name;
case GroupMap:
if (gItemNameSelection != NULL && IsFootball(gItemNameSelection))
{
return kFootballMaps[index].name;
}
return kMaps[index].name;
case GroupVehicle: return kVehicles[index].name;
case GroupColor: return kColors[index].name;
case GroupBadge: return kBadges[index].name;
case GroupTeam: return kTeams[index].name;
case GroupPosition: return kPositions[index].name;
case GroupTime: return kTimes[index].name;
case GroupWeather: return kWeather[index].name;
case GroupLength: return kLengths[index].name;
case GroupLaunch: return "L A U N C H R A C E";
case GroupSteamHost: return "HOST STEAM RACE";
case GroupSteamJoin: return "JOIN STEAM RACE";
case GroupLaunch: return "L A U N C H G A M E";
case GroupSteamHost: return "HOST STEAM GAME";
case GroupSteamJoin: return "JOIN STEAM GAME";
}
return "";
}
@@ -413,13 +507,17 @@ namespace
SetBkMode(mem, TRANSPARENT);
// title
gItemNameSelection = fe->selection;
SelectObject(mem, fe->titleFont);
SetTextColor(mem, kGreenBright);
RECT title = client;
title.top = client.bottom / 28;
title.bottom = title.top + client.bottom / 10;
DrawTextA(mem, "RED PLANET - MARTIAN DEATH RACE", -1, &title,
DT_CENTER | DT_TOP | DT_SINGLELINE);
DrawTextA(mem,
IsFootball(fe->selection)
? "RED PLANET - MARTIAN FOOTBALL"
: "RED PLANET - MARTIAN DEATH RACE",
-1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
SelectObject(mem, fe->textFont);
@@ -521,6 +619,20 @@ namespace
else
{
fe->selection[item->group] = item->index;
if (item->group == GroupScenario)
{
// the scenario swaps the map list and the
// color/badge vs team/position columns
int map_count;
ActiveMaps(fe->selection, &map_count);
if (fe->selection[GroupMap] >= map_count)
{
fe->selection[GroupMap] = 0;
}
RECT client;
GetClientRect(fe->menuWindow, &client);
LayoutMenu(fe, client.right, client.bottom);
}
InvalidateRect(fe->menuWindow, NULL, FALSE);
}
return;
@@ -655,13 +767,19 @@ namespace
extern const char *kOrdinalsBlock;
//---------------------------------------------------------------
// Egg assembly (console RPMission.ToEggString, race scenario).
// Single player uses the standalone address; a hosted network
// race lists the owner first plus one pilot per member pod.
// Egg assembly (console RPMission.ToEggString / RPFootballMission).
// Single player uses the standalone address; a hosted network game
// lists the owner first plus one pilot per member pod. Football
// adds team=/position= per pilot and the [teams] blocks; the
// owner's team/position picks seed a deterministic assignment
// across two teams (each team's first member without a runner
// becomes one; the rest alternate crusher/blocker; the runner
// wears the team's runner color, everyone else the team color).
//---------------------------------------------------------------
void BuildEggText(const FEState *fe, std::string &egg)
{
char line[256];
Logical football = IsFootball(fe->selection);
ExtraPilot extras[maxExtraPilots];
char owner_address[64];
@@ -672,11 +790,176 @@ namespace
extra_count = 0; // single player
}
//
// One flat pilot table: the owner then the extras
//
struct PilotEntry
{
const char *address;
const char *name;
const char *vehicle;
const char *color;
const char *badge;
int team; // kTeams index, -1 outside football
const char *position;
Logical chosePosition; // picked it themselves
};
PilotEntry pilots[maxExtraPilots + 1];
int pilot_count = 0;
PilotEntry *owner = &pilots[pilot_count++];
owner->address = owner_address;
owner->name = fe->pilotName;
owner->vehicle = kVehicles[fe->selection[GroupVehicle]].key;
owner->color = kColors[fe->selection[GroupColor]].key;
owner->badge = kBadges[fe->selection[GroupBadge]].key;
owner->team = -1;
owner->position = NULL;
owner->chosePosition = False;
for (int p = 0; p < extra_count; ++p)
{
PilotEntry *pilot = &pilots[pilot_count++];
pilot->address = extras[p].address;
pilot->name = extras[p].name;
pilot->vehicle = extras[p].vehicle;
pilot->color = extras[p].color;
pilot->badge = extras[p].badge;
pilot->team = -1;
pilot->position = NULL;
pilot->chosePosition = False;
}
//
// Football: every pilot's own team/position pick is honored;
// unset picks get filled in, then each team is normalized to
// exactly one runner (the console's rule).
//
if (football)
{
//
// 1. Teams: the host's pick, then each member's, then
// alternate whoever did not choose across the two
// most-used teams so the sides stay balanced.
//
owner->team = fe->selection[GroupTeam];
for (int p = 1; p < pilot_count; ++p)
{
for (int t = 0; t < FE_COUNT(kTeams); ++t)
{
if (strcmp(extras[p - 1].team, kTeams[t].key) == 0)
{
pilots[p].team = t;
break;
}
}
}
int fallback_team = (owner->team + 1) % FE_COUNT(kTeams);
int unassigned = 0;
for (int p = 1; p < pilot_count; ++p)
{
if (pilots[p].team < 0)
{
pilots[p].team = (unassigned++ % 2) ? owner->team : fallback_team;
}
}
//
// 2. Positions: honor each pick, default the rest to the
// line, then give every team exactly one runner.
//
for (int p = 1; p < pilot_count; ++p)
{
const char *pick = extras[p - 1].position;
for (int i = 0; i < FE_COUNT(kPositions); ++i)
{
if (strcmp(pick, kPositions[i].key) == 0)
{
pilots[p].position = kPositions[i].key;
pilots[p].chosePosition = True;
break;
}
}
}
owner->position = kPositions[fe->selection[GroupPosition]].key;
owner->chosePosition = True;
for (int t = 0; t < FE_COUNT(kTeams); ++t)
{
int members = 0, runner = -1, spare = -1, line = 0;
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team != t)
{
continue;
}
++members;
if (spare < 0 && !pilots[p].chosePosition)
{
spare = p; // nobody's plans to disturb
}
if (pilots[p].position != NULL &&
strcmp(pilots[p].position, "runner") == 0)
{
if (runner < 0)
{
runner = p; // first runner keeps the ball
}
else
{
// two pilots claimed it - the later one lines up
pilots[p].position = NULL;
pilots[p].chosePosition = False;
}
}
}
if (members == 0)
{
continue;
}
if (runner < 0 && spare >= 0)
{
// no volunteer: the first pilot without a pick of
// their own runs (explicit picks are never overridden)
pilots[spare].position = "runner";
runner = spare;
}
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team == t && p != runner &&
pilots[p].position == NULL)
{
pilots[p].position = (line++ % 2) ? "crusher" : "blocker";
}
}
}
//
// 3. Colors follow team and position (runner wears the
// team's runner color - that is how you spot the ball)
//
for (int p = 0; p < pilot_count; ++p)
{
const TeamEntry *team = &kTeams[pilots[p].team];
pilots[p].color =
(strcmp(pilots[p].position, "runner") == 0)
? team->runnerColor : team->teamColor;
pilots[p].badge = "None";
}
}
//
// [mission]
//
int map_count;
const CatalogEntry *maps = ActiveMaps(fe->selection, &map_count);
egg += "[mission]\n";
egg += "adventure=Red Planet\n";
sprintf(line, "map=%s\n", kMaps[fe->selection[GroupMap]].key);
sprintf(line, "map=%s\n", maps[fe->selection[GroupMap]].key);
egg += line;
sprintf(line, "scenario=%s\n", kScenarios[fe->selection[GroupScenario]].key);
egg += line;
egg += "scenario=race\n";
sprintf(line, "time=%s\n", kTimes[fe->selection[GroupTime]].key);
egg += line;
sprintf(line, "weather=%s\n", kWeather[fe->selection[GroupWeather]].key);
@@ -690,84 +973,120 @@ namespace
egg += line;
}
//
// [pilots] + per-pilot sections
//
egg += "[pilots]\n";
sprintf(line, "pilot=%s\n", owner_address);
egg += line;
for (int p = 0; p < extra_count; ++p)
for (int p = 0; p < pilot_count; ++p)
{
sprintf(line, "pilot=%s\n", extras[p].address);
sprintf(line, "pilot=%s\n", pilots[p].address);
egg += line;
}
sprintf(line, "[%s]\n", owner_address);
egg += line;
egg += "hostType=0\n";
egg += "dropzone=one\n";
sprintf(line, "name=%s\n", fe->pilotName);
egg += line;
egg += "bitmapindex=1\n";
egg += "loadzones=1\n";
sprintf(line, "vehicle=%s\n", kVehicles[fe->selection[GroupVehicle]].key);
egg += line;
sprintf(line, "color=%s\n", kColors[fe->selection[GroupColor]].key);
egg += line;
sprintf(line, "badge=%s\n", kBadges[fe->selection[GroupBadge]].key);
egg += line;
for (int p = 0; p < extra_count; ++p)
for (int p = 0; p < pilot_count; ++p)
{
sprintf(line, "[%s]\n", extras[p].address);
sprintf(line, "[%s]\n", pilots[p].address);
egg += line;
egg += "hostType=0\n";
egg += "dropzone=one\n";
sprintf(line, "name=%s\n", extras[p].name);
sprintf(line, "name=%s\n", pilots[p].name);
egg += line;
sprintf(line, "bitmapindex=%d\n", p + 2);
sprintf(line, "bitmapindex=%d\n", p + 1);
egg += line;
egg += "loadzones=1\n";
sprintf(line, "vehicle=%s\n", extras[p].vehicle);
sprintf(line, "vehicle=%s\n", pilots[p].vehicle);
egg += line;
sprintf(line, "color=%s\n", extras[p].color);
sprintf(line, "color=%s\n", pilots[p].color);
egg += line;
sprintf(line, "badge=%s\n", extras[p].badge);
sprintf(line, "badge=%s\n", pilots[p].badge);
egg += line;
if (football)
{
sprintf(line, "team=team::%s\n", kTeams[pilots[p].team].key);
egg += line;
sprintf(line, "position=%s\n", pilots[p].position);
egg += line;
}
}
egg += "[largebitmap]\n";
sprintf(line, "bitmap=BitMap::Large::%s\n", fe->pilotName);
egg += line;
for (int p = 0; p < extra_count; ++p)
//
// Football team blocks (console RPFootballMission layout)
//
if (football)
{
sprintf(line, "bitmap=BitMap::Large::%s\n", extras[p].name);
std::string teams_list = "[teams]\n";
std::string team_sections, pilots_sections;
std::string teambitmap_list = "[teambitmap]\n";
std::string teambitmap_sections;
int bitmap_index = 1;
for (int side = 0; side < FE_COUNT(kTeams); ++side)
{
Logical team_present = False;
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team == side)
{
team_present = True;
}
}
if (!team_present)
{
continue;
}
const char *team_key = kTeams[side].key;
sprintf(line, "team=team::%s\n", team_key);
teams_list += line;
sprintf(line, "[team::%s]\nbitmapindex=%d\npilots=pilots::%s\n",
team_key, bitmap_index++, team_key);
team_sections += line;
sprintf(line, "[pilots::%s]\n", team_key);
pilots_sections += line;
for (int p = 0; p < pilot_count; ++p)
{
if (pilots[p].team == side)
{
sprintf(line, "pilot=%s\n", pilots[p].address);
pilots_sections += line;
}
}
sprintf(line, "bitmap=BitMap::Small::team::%s\n", team_key);
teambitmap_list += line;
sprintf(line, "[BitMap::Small::team::%s]\n", team_key);
teambitmap_sections += line;
AppendNameBitmap(teambitmap_sections, 64, 16, team_key);
teambitmap_sections += "width=4\n";
}
egg += teams_list;
egg += team_sections;
egg += pilots_sections;
egg += teambitmap_list;
egg += teambitmap_sections;
}
//
// Name bitmaps + ordinals
//
egg += "[largebitmap]\n";
for (int p = 0; p < pilot_count; ++p)
{
sprintf(line, "bitmap=BitMap::Large::%s\n", pilots[p].name);
egg += line;
}
egg += "[smallbitmap]\n";
sprintf(line, "bitmap=BitMap::Small::%s\n", fe->pilotName);
egg += line;
for (int p = 0; p < extra_count; ++p)
for (int p = 0; p < pilot_count; ++p)
{
sprintf(line, "bitmap=BitMap::Small::%s\n", extras[p].name);
sprintf(line, "bitmap=BitMap::Small::%s\n", pilots[p].name);
egg += line;
}
sprintf(line, "[BitMap::Large::%s]\n", fe->pilotName);
egg += line;
AppendNameBitmap(egg, 128, 32, fe->pilotName);
egg += "width=8\n";
sprintf(line, "[BitMap::Small::%s]\n", fe->pilotName);
egg += line;
AppendNameBitmap(egg, 64, 16, fe->pilotName);
egg += "width=4\n";
for (int p = 0; p < extra_count; ++p)
for (int p = 0; p < pilot_count; ++p)
{
sprintf(line, "[BitMap::Large::%s]\n", extras[p].name);
sprintf(line, "[BitMap::Large::%s]\n", pilots[p].name);
egg += line;
AppendNameBitmap(egg, 128, 32, extras[p].name);
AppendNameBitmap(egg, 128, 32, pilots[p].name);
egg += "width=8\n";
sprintf(line, "[BitMap::Small::%s]\n", extras[p].name);
sprintf(line, "[BitMap::Small::%s]\n", pilots[p].name);
egg += line;
AppendNameBitmap(egg, 64, 16, extras[p].name);
AppendNameBitmap(egg, 64, 16, pilots[p].name);
egg += "width=4\n";
}
@@ -775,13 +1094,13 @@ namespace
// [pilots]-order names for the network console's results
strcpy(gLastPilotNamesCsv, fe->pilotName);
for (int p = 0; p < extra_count; ++p)
for (int p = 1; p < pilot_count; ++p)
{
if (strlen(gLastPilotNamesCsv) + strlen(extras[p].name) + 2 <
if (strlen(gLastPilotNamesCsv) + strlen(pilots[p].name) + 2 <
sizeof(gLastPilotNamesCsv))
{
strcat(gLastPilotNamesCsv, ",");
strcat(gLastPilotNamesCsv, extras[p].name);
strcat(gLastPilotNamesCsv, pilots[p].name);
}
}
}
@@ -1061,6 +1380,218 @@ void
strcpy(badge, kBadges[b].key);
}
//########################################################################
// The host's mission setup, as display names.
//
// The lobby shows everyone what they are about to fly into, and only the
// owner knows it - so the owner publishes these into lobby data and the
// members read them back. Names rather than catalog keys: the map lists
// differ between race and football, so resolving here means the room
// never has to know which scenario's table a key came from.
//########################################################################
const char *
RPL4FrontEnd_SelectedMapName()
{
if (!gHavePersist)
{
return kMaps[0].name;
}
int count = 0;
const CatalogEntry *maps = ActiveMaps(gPersistSelection, &count);
int index = gPersistSelection[GroupMap];
if (index < 0 || index >= count)
{
index = 0;
}
return maps[index].name;
}
const char *
RPL4FrontEnd_SelectedTimeName()
{
int index = gHavePersist ? gPersistSelection[GroupTime] : 0;
if (index < 0 || index >= FE_COUNT(kTimes))
{
index = 0;
}
return kTimes[index].name;
}
const char *
RPL4FrontEnd_SelectedWeatherName()
{
int index = gHavePersist ? gPersistSelection[GroupWeather] : 0;
if (index < 0 || index >= FE_COUNT(kWeather))
{
index = 0;
}
return kWeather[index].name;
}
const char *
RPL4FrontEnd_SelectedLengthName()
{
// the menu defaults this one to 5:00, not to entry zero
int index = gHavePersist ? gPersistSelection[GroupLength] : 2;
if (index < 0 || index >= FE_COUNT(kLengths))
{
index = 0;
}
return kLengths[index].name;
}
//########################################################################
// Catalog key -> display name. Member data travels as keys, so the room
// needs these to show "Battle Barge" where the wire says "bttlbrg".
//########################################################################
namespace
{
const char *
NameForKey(const CatalogEntry *table, int count, const char *key)
{
if (key == NULL || key[0] == '\0')
{
return "";
}
for (int i = 0; i < count; ++i)
{
if (strcmp(table[i].key, key) == 0)
{
return table[i].name;
}
}
return key; // unknown to this build: show it raw rather than blank
}
}
const char *
RPL4FrontEnd_VehicleNameForKey(const char *key)
{
return NameForKey(kVehicles, FE_COUNT(kVehicles), key);
}
const char *
RPL4FrontEnd_PositionNameForKey(const char *key)
{
return NameForKey(kPositions, FE_COUNT(kPositions), key);
}
const char *
RPL4FrontEnd_TeamNameForKey(const char *key)
{
if (key == NULL || key[0] == '\0')
{
return "";
}
for (int i = 0; i < FE_COUNT(kTeams); ++i)
{
if (strcmp(kTeams[i].key, key) == 0)
{
return kTeams[i].name;
}
}
return key;
}
//########################################################################
// Football team / position picks (the lobby publishes and cycles these)
//########################################################################
int
RPL4FrontEnd_TeamCount()
{
return FE_COUNT(kTeams);
}
const char *
RPL4FrontEnd_TeamName(int index)
{
if (index < 0 || index >= FE_COUNT(kTeams))
{
return "";
}
return kTeams[index].name;
}
const char *
RPL4FrontEnd_TeamKey(int index)
{
if (index < 0 || index >= FE_COUNT(kTeams))
{
return "";
}
return kTeams[index].key;
}
int
RPL4FrontEnd_PositionCount()
{
return FE_COUNT(kPositions);
}
const char *
RPL4FrontEnd_PositionName(int index)
{
if (index < 0 || index >= FE_COUNT(kPositions))
{
return "";
}
return kPositions[index].name;
}
const char *
RPL4FrontEnd_PositionKey(int index)
{
if (index < 0 || index >= FE_COUNT(kPositions))
{
return "";
}
return kPositions[index].key;
}
int
RPL4FrontEnd_GetTeamIndex()
{
return gHavePersist ? gPersistSelection[GroupTeam] : 0;
}
void
RPL4FrontEnd_SetTeamIndex(int index)
{
if (index < 0 || index >= FE_COUNT(kTeams))
{
return;
}
gPersistSelection[GroupTeam] = index;
gHavePersist = True;
}
int
RPL4FrontEnd_GetPositionIndex()
{
return gHavePersist ? gPersistSelection[GroupPosition] : 0;
}
void
RPL4FrontEnd_SetPositionIndex(int index)
{
if (index < 0 || index >= FE_COUNT(kPositions))
{
return;
}
gPersistSelection[GroupPosition] = index;
gHavePersist = True;
}
Logical
RPL4FrontEnd_IsFootballSelected()
{
return gHavePersist && IsFootball(gPersistSelection);
}
void
RPL4FrontEnd_SetHostedPilots(
const char *owner_address,
+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(
+304 -13
View File
@@ -36,7 +36,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 +48,13 @@ namespace
int gLastGoNonce = 0; // launches we already answered
int gShownResultsNonce = 0; // score sheets we already displayed
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
// 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 +130,12 @@ namespace
}
}
Logical IsOwner()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
}
void PublishMemberData()
{
char value[64];
@@ -140,12 +156,57 @@ 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()));
//---------------------------------------------------------------
// 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())
{
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 +223,8 @@ namespace
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick
char position[16];
Logical published;
};
@@ -196,6 +259,12 @@ 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);
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}
@@ -241,6 +310,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 +345,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 +377,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 +535,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 +573,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 +648,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 +730,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 +764,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);
@@ -665,6 +952,10 @@ int
gCallDone = False;
SteamMatchmaking()->AddRequestLobbyListStringFilter(
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
// the default lobby search is distance-filtered (roughly same
// region) - RP412 races are worldwide
SteamMatchmaking()->AddRequestLobbyListDistanceFilter(
k_ELobbyDistanceFilterWorldwide);
SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
gCalls.listResult.Set(call, &gCalls, &LobbyCalls::OnList);
if (!WaitForCall(15000))
+170
View File
@@ -0,0 +1,170 @@
# Red Planet 4.12 — controls map
Generated from the default `bindings.txt` (the file the game writes
beside the exe on first run). Everything here is rebindable: edit that
file and restart, or delete it to get these defaults back.
Grammar reminder, from the file's own header:
```
key <name> button <addr> [toggle]
key <name> axis <axis> deflect <n> # held, springs back
key <name> axis <axis> rate <n> # walks per second, sticks
pad <button> button <addr> [toggle]
padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
```
---
## Xbox controller
```
( LB ) Panic Throttle / reverse ( RB )
( LT ) Left pedal Right pedal ( RT )
______________________________________________________
/ \
/ [Back] [Start] \
| unbound unbound |
| |
| ,--. (Y) |
| ( L ) stick X / Y Upper |
| `--' (X) (B) |
| +---+ Pinky Middle |
| D-pad --> |hat| (A) |
| +---+ Main |
| |
| ,--. |
| ( R ) throttle (Y axis) |
\ `--' /
\____________________________________________________ _/
```
| Control | Does | RIO address |
|---|---|---|
| Left stick | Joystick X / Y (inverted, deadzone 0.24 — pod convention) | axes |
| Right stick Y | Throttle, as a **rate**: hold to wind it up or down, position sticks | axis |
| Left / right trigger | Left / right pedal (deadzone 0.12) | axes |
| **A** | Main trigger | `0x40` |
| **B** | Middle | `0x46` |
| **X** | Pinky | `0x45` |
| **Y** | Upper | `0x47` |
| **LB** | Panic | `0x3D` |
| **RB** | Throttle button — reverse thrust | `0x3F` |
| D-pad | Hat: up / back / left / right (look) | `0x42` `0x41` `0x44` `0x43` |
| **Start**, **Back** | unbound — yours to map | — |
The config buttons (`0x37`/`0x36`) are the first two buttons of the
Upper Right MFD's top row, reachable from the keyboard on `9` and `0`.
Start and Back ship unbound so you can put anything there; the
commented lines in `bindings.txt` show how.
---
## Keyboard
### Flight cluster — the number pad
```
+-----+-----+-----+
| 7 | 8 | 9 | 7 / 9 left / right pedal
| Lped| fwd |Rped | 8 / 2 stick forward / back
+-----+-----+-----+ 4 / 6 stick left / right
| 4 | 5 | 6 | 0 main trigger
| left| |right|
+-----+-----+-----+
| 1 | 2 | 3 |
| | back| |
+-----+-----+-----+
| 0 | . |
| trigger | |
+-----------+-----+
Shift throttle up Ctrl throttle down Alt reverse thrust
(both throttle keys walk the axis and it holds where you leave it)
```
| Key | Does |
|---|---|
| NumPad 8 / 2 | Stick forward / back |
| NumPad 4 / 6 | Stick left / right |
| NumPad 7 / 9 | Left / right pedal |
| NumPad 0, Space | Main trigger (`0x40`) |
| Shift / Ctrl | Throttle up / down (position holds) |
| Alt | Reverse thrust (`0x3F`) |
| Arrow keys | Hat — look up / back / left / right |
| **Alt + Q** | Abort the mission (score banked, back to menu or lobby) |
### Panel buttons — the letter and number rows
The rest of the keyboard is the pod's button board, laid out as it is
printed on the panel. Each MFD has 8 buttons (a 4-wide top row and a
4-wide bottom row); the keyboard rows run left to right across the
three upper MFDs, then the two lower ones.
```
UPPER MFDs Left MFD Middle MFD Right MFD
top row 1 2 3 4 | 5 6 7 8 | 9 0 - =
bottom row Q W E R | T Y U I | O P [ ]
0x2F..0x2C 0x27..0x24 0x37..0x34
0x2B..0x28 0x23..0x20 0x33..0x30
LOWER MFDs Left MFD Right MFD
top row A S D F (G unbound) H J K L
bottom row Z X C V (B unbound) N M , .
0x0F..0x0C 0x07..0x04
0x0B..0x08 0x03..0x00
COLUMNS beside the map
Secondary F1 F2 F3 F4 F5 F6 0x10..0x15
Screen F7 F8 F9 F10 F11 F12 0x18..0x1D
```
G and B are deliberately unbound: they are the physical gap between the
Lower Left and Lower Right MFD clusters on the real board.
Every one of these buttons is also **clickable on screen** — the red
strips around each MFD and the amber strips beside the map are the same
addresses, and they light up when the game commands their lamps.
### Not bound by default
The pilot keypad (`0x50``0x5F`) and external operator keypad
(`0x60``0x6F`) have no keys assigned — the game never reads them. They
remain bindable if you want them (they arrive as arcade RIO key events).
---
## RGB keyboard lighting
If the machine has a Dynamic Lighting keyboard, bound panel keys mirror
the pod's lamps: red for the MFD banks, amber/yellow for the Secondary
and Screen columns, blinking in step with the on-screen buttons.
Unbound keys go dark. Set `RP412KEYLIGHT=0` in `environ.ini` to switch
it off.
---
## Rebinding
`bindings.txt` sits beside the exe, written with the full default layout
and its own documentation on first run. Change a line, restart the game.
Delete the file to restore these defaults — worth doing after an update,
since an existing file is never overwritten.
Examples:
```
key J button 0x40 # main trigger on J as well
pad X button 0x40 # move the trigger to the X button
key NumPadAdd axis Throttle rate 0.75 # throttle on numpad +/-
key NumPadSubtract axis Throttle rate -0.75
padaxis LeftStickX axis JoystickX deadzone 0.15 # drop 'invert' to flip X
```
Addresses: `0x00``0x47` are the lamp buttons above, `0x50``0x6F` the
keypads. Axis names: `Throttle`, `LeftPedal`, `RightPedal`, `JoystickX`,
`JoystickY`. Key names follow the .NET `Keys` naming (`A``Z`, `D0``D9`
for the digit row, `F1``F12`, `NumPad0``NumPad9`, `Up`, `Down`,
`Left`, `Right`, `Space`, `Return`, `Shift`, `Ctrl`, `Alt`,
`OemMinus`, `Oemplus`, `Oemcomma`, `OemPeriod`, `OemOpenBrackets`,
`OemCloseBrackets`, …).
+26 -8
View File
@@ -155,12 +155,30 @@ launch flow, no in-game rejoin). Useful as a dev tool, not the product.
**C. Hybrid:** in-engine UI, but mission/egg logic in a small shared C++
library so a dev console tool and the game share one egg builder.
### Open decisions
### Open decisions — all resolved
1. Front end location — Option A (in-engine, cockpit-rendered) confirmed?
2. v1 scenario scope — Death Race only, or Football (needs teams UI) too?
3. Results screen — post-race on the cockpit displays (replaces printout)?
4. Steam model — lobby-owner-as-console confirmed? (Implies owner migration
handling later; the egg re-issue path makes host migration plausible.)
5. Pilot identity — Steam persona as pilot name; vehicle/color/badge
persisted per player (Steam Cloud later)?
1. Front end location — Option A (in-engine) **confirmed and shipped**.
2. v1 scenario scope — **both scenarios shipped** (2026-07-24). The setup
menu has a SCENARIO group; picking Football swaps the map list to the
football-legal set (no `pain`/`headoff`, adds `headmf`), replaces the
COLOR/BADGE columns with TEAM/POSITION, and the egg builder emits
`scenario=football`, per-pilot `team=`/`position=`, and the
`[teams]` / `[team::X]` / `[pilots::X]` / `[teambitmap]` blocks in
RPFootballMission's layout. Colors are derived, not chosen: the
runner wears the team's runner color, everyone else the team color.
**Every player picks their own team and position** (2026-07-24):
members publish their picks as lobby member data (`tm`/`ps`), the
room shows the roster as a team sheet and gives each member MY TEAM
/ MY POSITION buttons that cycle and republish live, and the owner
publishes the scenario (`sc`) so members know when those picks
matter. The egg builder honors every explicit pick and only fills
gaps: unpicked pilots are spread across the sides, a team with no
volunteer runner promotes its first *unpicked* member (explicit
picks are never overridden), and a second pilot claiming runner on
one team is moved to the line.
3. Results screen — **shipped**, on the cockpit displays, with the owner
publishing the score sheet to lobby members.
4. Steam model — lobby-owner-as-console **confirmed and verified** on
three machines. (Owner migration still unhandled.)
5. Pilot identity — Steam persona as pilot name **shipped**; loadout
persists in-session (Steam Cloud later).
+7 -7
View File
@@ -9,13 +9,13 @@ the first live test.
1. Steam client installed, logged in (different account per machine),
and RUNNING.
2. Copy the `dist\` folder onto the machine.
3. In the dist folder, create `steam_appid.txt` containing exactly:
`480`
(Spacewar, Valve's public dev AppID — replaced by the real AppID
once the store page exists.)
4. Add to `environ.ini`:
`RP412STEAM=1`
5. Start with `start-windowed.bat` as usual.
3. Start with `start-windowed.bat` as usual.
The packed dist already contains `steam_appid.txt` (480 — Spacewar,
Valve's public dev AppID, replaced by the real one once the store page
exists) and ships `RP412STEAM=1` in `environ.ini`. If a machine has an
older dist, check both before blaming the network: a missing appid
file makes SteamAPI_Init fail and the game quietly falls back to TCP.
Sanity check per machine: `rpl4.log` should show
`SteamNetTransport: up as 169.254.x.y (fake ports N console, M game)`.
File diff suppressed because one or more lines are too long
+241 -33
View File
@@ -1,11 +1,11 @@
# ============================================================================
# pack-dist.ps1 assemble a runnable Red Planet 4.12 package into dist\
# pack-dist.ps1 - assemble a runnable Red Planet 4.12 package into dist\
# ============================================================================
#
# Collects everything the game needs at run time:
# - Release\rpl4opt.exe (+ .pdb for crash debugging)
# - game data from assets\RP411 (AUDIO, GAUGE, VIDEO, INIs, RPL4.RES,
# TEST.EGG) but not the arcade launch scripts or the old 4.10 exe
# TEST.EGG) - but not the arcade launch scripts or the old 4.10 exe
# - libsndfile-1.dll beside the exe; OpenAL32.dll copied from the system
# when installed, with oalinst.exe included as the fallback installer
# - a desktop environ.ini (PAD;KEYBOARD controls, on-screen plasma)
@@ -47,6 +47,13 @@ if (Test-Path $pdb) { Copy-Item $pdb $dist }
# only activates with RP412STEAM=1; plain desktop runs never touch it.
Copy-Item (Join-Path $root 'extern\steamworks_sdk_164\sdk\redistributable_bin\steam_api.dll') $dist
# steam_appid.txt: until RP412 has its own AppID, Steam testing runs
# under Spacewar (480). Without this file SteamAPI_Init fails and the
# game falls back to plain TCP - which is exactly the confusing symptom
# testers hit when the file goes missing. Delete it once we ship under
# our own AppID (the Steam client provides it then).
Set-Content -Path "$dist\steam_appid.txt" -Encoding ascii -Value '480'
# --- game data -------------------------------------------------------------
foreach ($dir in 'AUDIO', 'GAUGE', 'VIDEO') {
Write-Host " copying $dir..."
@@ -57,6 +64,40 @@ foreach ($file in 'RPDPL.INI', 'JOYSTICK.INI', 'RPL4.RES', 'TEST.EGG',
Copy-Item (Join-Path $assets $file) $dist
}
# The controls map travels with the game (players get the diagrams
# without needing the repo). Flattened to ASCII so it reads correctly
# in Notepad - the markdown source keeps its typography.
$controls = Get-Content (Join-Path $root 'docs\CONTROLS.md') -Raw -Encoding UTF8
foreach ($pair in @(
@([char]0x2014, '-'), @([char]0x2013, '-'), @([char]0x2018, "'"),
@([char]0x2019, "'"), @([char]0x201C, '"'), @([char]0x201D, '"'),
@([char]0x00D7, 'x'), @([char]0x2192, '->'), @([char]0x2026, '...'))) {
$controls = $controls.Replace([string]$pair[0], [string]$pair[1])
}
Set-Content -Path "$dist\CONTROLS.txt" -Encoding ascii -Value $controls
# The same map as a page, for anyone who would rather look at the
# diagrams than read them. Its source is the published artifact, which is
# a fragment - the publisher supplies the document shell - so wrap it to
# stand alone: without a doctype the browser drops into quirks mode, and
# without a charset the typography arrives as mojibake. Written without a
# BOM so the charset declaration is the only thing speaking.
$controlsPage = Get-Content (Join-Path $root 'docs\rp412-controls.html') -Raw -Encoding UTF8
$page = @"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
$controlsPage
</body>
</html>
"@
[System.IO.File]::WriteAllText(
"$dist\CONTROLS.html", $page, (New-Object System.Text.UTF8Encoding $false))
# --- OpenAL runtime --------------------------------------------------------
# The exe links OpenAL32.dll (32-bit). Prefer shipping the already-installed
# runtime beside the exe; oalinst.exe covers machines where that misses.
@@ -85,27 +126,128 @@ Set-Content -Path "$dist\environ.ini" -Encoding ascii -Value @"
# ---- Core (the shipped configuration) --------------------------------------
# Control stack. PAD;KEYBOARD = the virtual RIO (XInput controller +
# keyboard, rebindable via bindings.txt). RIO;KEYBOARD = real serial
# cockpit hardware. KEYBOARD = engine keyboard only (no virtual RIO).
# Control stack: tokens separated by ; or , processed left to right.
# PAD the virtual RIO (XInput controller + keyboard,
# rebindable via bindings.txt)
# RIO real serial cockpit hardware on COM1
# RIO:COMn same, on another port (RIO:COM3, ...)
# KEYBOARD the engine keyboard handler
# MOUSE, JOYSTICK, FLIGHTSTICKPRO, THRUSTMASTER, DIJOYSTICK
# legacy pointer/joystick drivers (untested here)
# Unset falls back to KEYBOARD alone.
L4CONTROLS=PAD;KEYBOARD
# Renderer selection argument and its config file. Leave as shipped.
# Renderer bring-up argument. Only its presence is checked (the DPL
# resolution parsing it once fed is gone) and the game refuses to start
# without it - any non-empty value works. Leave as shipped.
DPLARG=1
# DPL (renderer/scene) configuration file, searched beside the exe.
# Any notation file name; RPDPL.INI is the one that ships.
L4DPLCFG=RPDPL.INI
# Gauge (MFD/instrument) canvas: WIDTHxHEIGHTxDEPTH.
# Gauge (MFD/instrument) canvas. Must name a page of GAUGE\L4GAUGE.INI:
# 640x480x8 | 640x480x16 | 800x600x16
# Unset disables the gauge renderer (and with it all MFDs).
L4GAUGE=640x480x16
# Plasma display: SCREEN renders the pod's plasma glass in-window
# (currently parked off-layout); hardware values drive real glass.
# Plasma display.
# SCREEN render the pod's plasma glass in-window (currently
# parked off-layout)
# COM1, COM2... drive real plasma glass on that serial port
# (9600 baud, N81)
# Unset = no plasma display.
L4PLASMA=SCREEN
# 1 = the single-window cockpit: all seven displays composed on a
# locked 1920x1080 canvas around the viewscreen.
# 0 = classic separate gauge windows; 1 = the single-window glass
# cockpit (all seven displays composed on a locked 1920x1080 canvas
# around the viewscreen); 2 = exploded diagnostic view (each display
# in its own native-resolution desktop window - MFDs 640x480, map
# 480x640 - decoded exactly as the pod's VDB split them, no downscale).
L4MFDSPLIT=1
# Simulation/render frame rate.
# Size of the six secondary displays in the glass cockpit, as a
# percentage of their pod size. The pod bolted them down at one size;
# on a big panel there is room to trade viewscreen for instrument, so
# turn these up if you want to actually read the other displays while
# you fly. 100 = as the pod had them. Range 25-200 (out-of-range and
# unreadable values fall back to the group setting, then to 100).
#
# The scaling is applied in canvas units, before the cockpit is fitted
# to your window, so a given number looks the same on every monitor.
# The layout stays legal whatever you ask for - the panes are clamped
# against their actual neighbours, shrinking uniformly so a display
# never comes out stretched. They do overlap the viewscreen, exactly
# as the pod's bezels did, but never each other.
#
# L4MFDSCALE sets all five green MFDs at once.
L4MFDSCALE=100
# ...and any single display can override it. Uncomment one to size it
# on its own - useful if you only care about, say, the damage readout.
# UL upper left UC upper center UR upper right
# LL lower left LR lower right
#L4MFDSCALE_UL=100
#L4MFDSCALE_UC=100
#L4MFDSCALE_UR=100
#L4MFDSCALE_LL=100
#L4MFDSCALE_LR=100
# The portrait radar/map, sized on its own (it already sits at 1.35x
# the MFDs by default). It shares the canvas with whichever MFD is
# above it, so at extreme settings one of the two gives way.
L4RADARSCALE=100
# Where the radar sits:
# CENTER bottom centre, under the viewscreen, as the pod had it
# (default; BOTTOM and CENTRE mean the same)
# LEFT bottom left corner (or BOTTOMLEFT)
# RIGHT bottom right corner (or BOTTOMRIGHT)
# MIDLEFT left edge, halfway up (or LEFTCENTER / LEFTCENTRE)
# MIDRIGHT right edge, halfway up (or RIGHTCENTER / RIGHTCENTRE)
# Anywhere but CENTER stops it blocking the middle of the road, which
# is worth having on a wide screen.
#
# In a bottom corner it is one of three panes along the bottom, and the
# lower MFD whose corner it takes slides inboard beside it. Halfway up
# a side it leaves the bottom row entirely and sits between that side's
# two MFDs - roomy on a tall radar, but if the MFDs on that side are
# also scaled up, the radar is the one that gives way (it has to clear
# both of them, and it grows from the middle in both directions).
L4RADARPOS=CENTER
# The Winners Circle: at the end of a race the finishers are stood on
# the award platform in finishing order, with each pilot's callsign on
# the plate beside their spot, and held there for a few seconds before
# the results screen. 1 = show it, 0 = straight to the results.
RP412PODIUM=1
# The shot is framed for you, but these move the camera if you want it
# somewhere else. Distances are in game units, measured from the middle
# of the group of finishers.
# STANDOFF how far out in front of the stand the camera sits
# HEIGHT how far above the group
# AIM height of the point it looks at, relative to the group -
# negative tilts down, positive tilts up
# ASPECT the stand was composed for a 4:3 pod monitor, so the shot
# is cropped to that shape with black either side. 0 runs it
# full width instead.
# FADEIN seconds to come up out of the black after the race fades
# CAM 0 watches from your own cockpit rather than off the stand
#RP412PODIUMSTANDOFF=36
#RP412PODIUMHEIGHT=12
#RP412PODIUMAIM=2
#RP412PODIUMASPECT=1.333
#RP412PODIUMFADEIN=0.45
#RP412PODIUMCAM=1
# Override the game length the menu picked, in seconds. The shortest the
# menu offers is 3:00, which is a long wait when what you are testing is
# what happens at the buzzer. Unset = use the menu's choice.
#RP412MISSIONSECONDS=20
# Simulation/render frame rate, integer frames/second. The desktop
# default is 60; the arcade pods shipped at 25.
TARGETFPS=60
# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client
@@ -116,48 +258,70 @@ RP412STEAM=1
# ---- Optional ---------------------------------------------------------------
# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to
# lamp buttons glow with the panel, flash modes and all. Default on;
# set 0 to disable.
# lamp buttons glow with the panel, flash modes and all.
# Unset or nonzero = on (the default); 0 = off.
#RP412KEYLIGHT=0
# Invert the stick on top of whatever bindings.txt produces: X, Y, XY.
# Invert the stick on top of whatever bindings.txt produces:
# X = invert X only, Y = invert Y only, XY = both (case-insensitive).
#L4PADFLIP=XY
# Anti-aliasing samples (0 = off).
# Anti-aliasing sample count, passed straight to Direct3D 9:
# 0 = off, else 2..16 as the GPU supports (1 selects the driver's
# "nonmaskable" mode; unsupported counts fail device creation).
#MULTISAMPLE=0
# Particle budget.
# Particle budget, integer. Default 8192.
#MAXPARTICLES=8192
# On-screen plasma glass pixel size and position (when re-homed).
# On-screen plasma glass (L4PLASMA=SCREEN only). SCALE = integer pixel
# size 1..16, default 4 (out-of-range values are ignored). POS = window
# top-left as X,Y screen coordinates; unset = auto, parked below the
# main window.
#L4PLASMASCALE=4
#L4PLASMAPOS=0,0
# Fixed random seed (repeatable runs).
# Fixed random seed (repeatable runs): any unsigned integer.
# Unset seeds from the clock.
#RANDOM=12345
# ---- LAN play without Steam -------------------------------------------------
# Host a race over plain TCP: list the member pods' console channels
# (members run: rpl4opt.exe -windowed -res 1920 1080 -net 1501).
# HOSTADDR is this machine's LAN IP as members can reach it.
# RP412HOSTPODS comma-separated IP[:port] list, one entry per member
# pod; port defaults to 1501 per entry
# RP412HOSTPORT this machine's console port, integer > 0
# (default 1501)
# RP412HOSTADDR this machine's LAN IP as members can reach it
# (default 127.0.0.1)
#RP412HOSTPODS=192.168.1.20:1501,192.168.1.21:1501
#RP412HOSTPORT=1501
#RP412HOSTADDR=192.168.1.10
# ---- Developer / testing ----------------------------------------------------
# 1 arms the debug keys: Alt+W wireframe, Alt+V predator vision,
# Nonzero arms the debug keys: Alt+W wireframe, Alt+V predator vision,
# Alt+F frame dump, Alt+/ perf stats, Alt+E event-queue dump.
# (Alt+Q, the mission abort, is always live.)
# 0 or unset = off. (Alt+Q, the mission abort, is always live.)
#RP412DEVKEYS=1
# Console race-length override in seconds (short test races).
# Console race-length override, integer seconds (short test races).
# Values <= 0 are ignored.
#L4CONSOLELEN=30
# Steam transport loopback self-test at boot (logs PASS/FAIL).
# Nonzero = Steam transport loopback self-test at boot (logs PASS/FAIL).
#RP412STEAMSELFTEST=1
# ---- Arcade heritage (multi-monitor pods; not used on the desktop) ----------
# PRIMGAUGE / SECGAUGE / MFDGAUGE / MFDGAUGE2 pin a display to a monitor
# by adapter index (0, 1, 2...). SPANDISABLE: 0 = let the MFDs span one
# wide surface, nonzero = separate windows (setting MFDGAUGE2 alone also
# forces spanning off). L4EYES = "x y z xrot yrot zrot [type]" floats
# for a detached camera; a type starting with r offsets it relative to
# the pod. L4INTERCOM enables the crew intercom - only its presence
# matters (traditionally COM2). NOMODES skips the mode/lamp programming;
# presence alone triggers it, even NOMODES=0. LOGSIZE > 0 sizes the
# trace log in dev builds compiled with tracing.
#PRIMGAUGE=1
#SECGAUGE=2
#MFDGAUGE=3
@@ -171,19 +335,34 @@ RP412STEAM=1
Set-Content -Path "$dist\start-windowed.bat" -Encoding ascii -Value @"
@echo off
rem Red Planet 4.12 - desktop prototype. Boots into the race-setup
rem front end; the cockpit canvas is 1920x1080 and -res matches it.
rem Red Planet 4.12 - desktop prototype. Boots into the game-setup
rem front end. The cockpit fits itself to the window and keeps 16:9,
rem so a wider screen letterboxes rather than stretching. -res sets the
rem 3D render size - raise it to match a big screen for sharper pixels,
rem e.g. -res 2560 1440.
rem (Add -egg TEST.EGG to skip the menu and run the canned mission.)
cd /d "%~dp0"
start rpl4opt.exe -windowed -res 1920 1080
"@
Set-Content -Path "$dist\start-fullscreen.bat" -Encoding ascii -Value @"
@echo off
rem Red Planet 4.12 - borderless over the whole monitor. -fit sizes the
rem window to the panel AND picks the render size to match the cockpit
rem canvas it lands in, so no -res guessing: the 3D arrives 1:1 instead
rem of being stretched. Pass -res yourself to override it.
rem (Add -egg TEST.EGG to skip the menu and run the canned mission.)
cd /d "%~dp0"
start rpl4opt.exe -fit
"@
Set-Content -Path "$dist\README.txt" -Encoding ascii -Value @"
Red Planet 4.12.1
Red Planet 4.12.5
=================
Run start-windowed.bat (or: rpl4opt.exe -windowed -res 800 600 -egg TEST.EGG).
No cockpit hardware needed. If there is no sound, run oalinst.exe once.
Run start-fullscreen.bat for borderless over the whole monitor, or
start-windowed.bat to keep a title bar. No cockpit hardware needed.
If there is no sound, run oalinst.exe once.
Controls (XInput controller and/or keyboard) - EVERY input is
rebindable: edit bindings.txt beside the exe (written with the full
@@ -196,11 +375,27 @@ documented default layout on first run; delete it to restore).
A / Space / NumPad 0 joystick trigger
RB / Alt reverse thrust
DPad / arrow keys joystick hat (look)
Start,Back / 9,0 config buttons
9 / 0 keys config buttons (pad Start/Back left free)
Number+letter rows MFD bank buttons (as printed on the panel)
F1-F12 secondary / screen columns
Alt+Q abort the mission (score banked)
The cockpit scales to whatever window you give it - maximise it and it
grows, keeping its 16:9 shape (an ultrawide gets black bars rather than
a stretched cockpit).
The 3D itself renders at whatever -res says and is then stretched onto
the cockpit's viewscreen, so a mismatched -res costs sharpness. -fit
takes care of that for you: it measures the monitor, works out the
canvas the cockpit will settle on, and asks for exactly that render
size, so the picture arrives 1:1.
rpl4opt.exe -fit borderless, res chosen for you
rpl4opt.exe -fit -res 1280 720 same window, lighter render
rpl4opt.exe -windowed -res 2560 1440 pick both yourself
(-windowed-fullscreen is accepted as a long spelling of -fit.)
The full pod cockpit comes up in a single window: three green MFDs
across the top, the 3D viewscreen centered with the orange plasma glass
at its left, and the lower MFDs flanking the portrait map. The red
@@ -209,7 +404,11 @@ pod's real button banks: click them with the mouse, and they light up
as the game commands their lamps.
environ.ini is self-documenting: every option ships in the file with
a comment (Steam networking, keyboard lighting, stick inversion, LAN
hosting, developer keys, and more).
hosting, developer keys, display scaling and radar placement, and more).
CONTROLS.html is the full controls map - open it in a browser for the
pad, keyboard and pod-panel diagrams. CONTROLS.txt is the same thing as
plain text.
Known prototype notes: pods race untextured (the player1-8 skins come
from the presets system, not shipped data), and text drawn on the plasma
@@ -223,7 +422,16 @@ $size = (Get-ChildItem $dist -Recurse | Measure-Object Length -Sum).Sum
Write-Host ("dist ready: {0:N1} MB" -f ($size / 1MB))
if ($Zip) {
$zipPath = Join-Path $root 'RedPlanet-4.12.1.zip'
$zipPath = Join-Path $root 'RedPlanet-4.12.5.zip'
Write-Host "zipping to $zipPath..."
Compress-Archive -Path "$dist\*" -DestinationPath $zipPath -Force
# Everything lives under a single RP412\ folder inside the zip, so
# unpacking anywhere gives one self-contained game directory instead
# of scattering files into the extraction folder.
$stage = Join-Path ([System.IO.Path]::GetTempPath()) 'rp412-zipstage'
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
New-Item -ItemType Directory -Force "$stage\RP412" | Out-Null
Copy-Item "$dist\*" "$stage\RP412" -Recurse -Force
Compress-Archive -Path "$stage\RP412" -DestinationPath $zipPath -Force
Remove-Item -Recurse -Force $stage
}